scimgateway
Version:
Using SCIM protocol as a gateway for user provisioning to other endpoints
566 lines (494 loc) • 26.2 kB
text/typescript
// =================================================================================
// File: plugin-generic.ts
//
// Author: Jarle Elshaug
//
// Purpose: Generic REST Webservice user-provisioning according to the configuration file setup
//
// Notes:
// - Uses endpointMapper for flexible attribute mapping. Configuration includes endpoint.map.user/group settings.
// - The default configuration uses one-to-one SCIM mapping, with plugin-loki as the target SCIM endpoint.
// * This means plugin-loki must be up and running (enabled in index.ts).
// * Can be used as a SCIM v1.1 <=> v2.0 gateway.
// - getUsers() and getGroups() are generic:
// * Include logic for target endpoints with or without pagination support.
// * Support OData endpoints.
// * Support allowListing to filter out objects that should not be included.
// - modifyGroup() is currently hardcoded for the SCIM endpoint and must be updated to reflect the configured endpoint.
//
// endpointMapper supports 'valueMap'. Example configuration:
//
// "map": {
// "group": {
// ...
// "displayName": {
// "mapTo": "displayName",
// "type": "string",
// "valueMap": {
// "outboundEndpointGrp1": "inboundScimGrp1",
// "Employees": "Admins"
// }
// },
// ...
// }
// ...
// }
//
// Using the above settings restricts the client using SCIM Gateway with regard to group management.
// The client will only see and be able to manage groups with SCIM names "inboundScimGrp1" and "Admins",
// if their mapped counterparts exist at the target endpoint as "outboundEndpointGrp1" and "Employees".
//
// Use case:
// - Allowlisting specific groups
// - Supporting different inbound/outbound names (e.g., Entra ID group provisioning to SCIM Gateway)
//
// =================================================================================
// start - mandatory plugin initialization
import { ScimGateway, HelperRest } from 'scimgateway'
const scimgateway = new ScimGateway()
const helper = new HelperRest(scimgateway)
const config = scimgateway.getConfig()
scimgateway.authPassThroughAllowed = false
scimgateway.pluginAndOrFilterEnabled = false
// end - mandatory plugin initialization
const isAllowlistingUser = config.map?.user
? Object.values(config.map.user).some((item: any) => typeof item?.valueMap === 'object' && Object.keys(item.valueMap).length > 0)
: false
const isAllowlistingGroup = config.map?.group
? Object.values(config.map.group).some((item: any) => typeof item.valueMap === 'object' && Object.keys(item.valueMap).length > 0)
: false
// =================================================
// getUsers
// =================================================
scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
const action = 'getUsers'
scimgateway.logDebug(baseEntity, `handling ${action} getObj=${getObj ? JSON.stringify(getObj) : ''} attributes=${attributes} passThrough=${ctx ? 'true' : 'false'}`)
const [attrs] = scimgateway.endpointMapper('outbound', attributes, config.map.user)
const method = 'GET'
const body = null
let path
let options: Record<string, any> = {}
// 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=${attrs.join()}` // GET /Users/bjensen?attributes=
else path = `/Users?filter=${getObj.attribute} eq "${getObj.value}"${(attrs.length > 0) ? '&attributes=' + attrs.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}"${(attrs.length > 0) ? '&attributes=' + attrs.join() : ''}`
} else {
// optional - simpel filtering
path = `/Users?filter=${getObj.attribute} ${getObj.operator} "${getObj.value}"${(attrs.length > 0) ? '&attributes=' + attrs.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${(attrs.length > 0 ? '?attributes=' + attrs.join() : '')}`
}
if (getObj.and || getObj.or) {
// plugin have enabled 'scimgateway.pluginAndOrFilterEnabled' and the query includes an additonal and/or getObj that must to be handled and combined with the initial getObj
// we could have this logic above, if not it must be defined here
throw new Error(`${action} error: logic for handling and/or filter is not implemented by plugin, not supporting: ${getObj.rawFilter}`)
}
// end mandatory if-else logic
if (!path) throw new Error(`${action} error: mandatory if-else logic not fully implemented`)
const targetStartIndex = getObj.startIndex || 1
const targetCount = getObj.count || 200
// Notes for OData (nextLink) paging support:
// - request parameters should include "$count=true" when valid
// - see plugin-entra-id.ts which use OData
// - uncomment below
/*
// enable doRequest() OData paging support
let paging = { startIndex: getObj.startIndex || 1 }
if (!ctx) ctx = { paging }
else ctx.paging = paging
if (path.includes('$count=true')) { // $count=true requires ConsistencyLevel
// note: when using $expand, the $count=true might be ignored by target endpoint and the ctx.paging.totalResults updated by doReqest() will be incremental
if (!options.headers) options.headers = {}
options.headers.ConsistencyLevel = 'eventual'
}
*/
const ret: any = { // itemsPerPage will be set by scimgateway
Resources: [],
totalResults: null,
}
let currentStartIndex = isAllowlistingUser ? 1 : targetStartIndex
let allValidResources: any[] = []
let totalSkipped = 0
let targetTotalResults: number | null = null
let iteration = 0
const maxIterations = 5 // Safety limit for look-ahead fetching
const resourcesNeeded = isAllowlistingUser ? targetStartIndex + targetCount - 1 : targetCount
try {
while (allValidResources.length < resourcesNeeded && iteration < maxIterations) {
let currentPath = path
// may skip adding SCIM pagination parameters startIndex/count if not supported by target endpoint
if (currentPath.includes('?')) currentPath += `&startIndex=${currentStartIndex}&count=${targetCount}`
else currentPath += `?startIndex=${currentStartIndex}&count=${targetCount}`
// In this use case the target response body is SCIM and includes the Resources array - response.body.Resources
// Replace the "response.body.Resources" according to target endpoint response syntax
// OData response is supported by the response.body.value check
const response = await helper.doRequest(baseEntity, method, currentPath, body, ctx, options)
if (response.statusCode > 399) {
throw new Error(`${response.statusMessage} - ${JSON.stringify(response.body)}`)
} else if (!response.body) {
throw new Error(`invalid response: ${JSON.stringify(response)}`)
} else if (!response.body.Resources && !Array.isArray(response.body.value)) {
const userObj = response.body
const [scimObj, err] = scimgateway.endpointMapper('inbound', userObj, config.map.user)
if (err && err.message.includes('valueMap')) return null // allowListing when valueMap is configured, skipping objects not being "valueMapped"
return scimObj
}
const resources = response.body.Resources || response.body.value || []
targetTotalResults = (response.body.totalResults !== undefined && response.body.totalResults !== null) ? response.body.totalResults : null
if (ctx?.paging && Object.hasOwn(ctx.paging, 'totalResults')) targetTotalResults = ctx.paging.totalResults
const pageValidResources: any[] = []
for (const res of resources) {
if (!res || Object.keys(res).length < 1) continue
const [scimObj, err] = scimgateway.endpointMapper('inbound', res, config.map.user)
if (err && err.message.includes('valueMap')) continue // allowListing when valueMap is configured, skipping objects not being "valueMapped"
if (scimObj && typeof scimObj === 'object' && Object.keys(scimObj).length > 0) {
pageValidResources.push(scimObj)
}
}
totalSkipped += (resources.length - pageValidResources.length)
allValidResources.push(...pageValidResources)
if (targetTotalResults === null) {
// Target endpoint returned full list
ret.totalResults = isAllowlistingUser ? allValidResources.length : targetStartIndex - 1 + allValidResources.length
ret.Resources = isAllowlistingUser ? allValidResources.slice(targetStartIndex - 1, targetStartIndex - 1 + targetCount) : allValidResources.slice(0, targetCount)
return ret
}
// If we reached the end of target's list
if ((currentStartIndex - 1 + resources.length) >= targetTotalResults) break
// Look-ahead: fetch next page because some results were filtered out
currentStartIndex += resources.length
if (ctx?.paging) ctx.paging.startIndex = currentStartIndex
iteration++
}
if (ctx?.paging && getObj.startIndex !== ctx.paging.startIndex) { // changed by doRequest()
ret.startIndex = ctx.paging.startIndex
}
if (ctx?.paging && Object.hasOwn(ctx.paging, 'totalResults')) targetTotalResults = ctx.paging.totalResults
ret.totalResults = (targetTotalResults !== null && targetTotalResults > totalSkipped) ? targetTotalResults - totalSkipped : (isAllowlistingUser ? allValidResources.length : targetStartIndex - 1 + allValidResources.length)
ret.Resources = isAllowlistingUser ? allValidResources.slice(targetStartIndex - 1, targetStartIndex - 1 + targetCount) : allValidResources.slice(0, targetCount)
if (!ret.startIndex) ret.startIndex = targetStartIndex
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)} passThrough=${ctx ? 'true' : 'false'}`)
const [targetObj] = scimgateway.endpointMapper('outbound', userObj, config.map.user)
const method = 'POST'
const path = '/Users'
const body = targetObj
try {
const response = await helper.doRequest(baseEntity, method, path, body, ctx)
if (response.statusCode > 399) {
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} passThrough=${ctx ? 'true' : 'false'}`)
const method = 'DELETE'
const path = `/Users/${id}`
const body = null
try {
const response = await helper.doRequest(baseEntity, method, path, body, ctx)
if (response.statusCode > 399) {
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)} passThrough=${ctx ? 'true' : 'false'}`)
const [targetObj] = scimgateway.endpointMapper('outbound', attrObj, config.map.user)
const method = 'PATCH'
const path = `/Users/${id}`
let body = targetObj
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 > 399) {
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} passThrough=${ctx ? 'true' : 'false'}`)
const ret: any = { // itemsPerPage will be set by scimgateway
Resources: [],
totalResults: null,
}
const [attrs] = scimgateway.endpointMapper('outbound', attributes, config.map.group)
const method = 'GET'
const body = null
let path
let options: Record<string, any> = {}
// 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
if (getObj.attribute === 'displayName') {
const [obj, err] = scimgateway.endpointMapper('outbound', { displayName: getObj.value }, config.map.group)
if (err && err.message.includes('displayName.valueMap')) return ret // valueMap configured for object having mapTo=displayName and value not allowlisted
getObj.value = obj.displayName
}
if (getObj.attribute === 'id') path = `/Groups/${getObj.value}?attributes=${attrs.join()}` // GET /Users/bjensen?attributes=
else path = `/Groups?filter=${getObj.attribute} eq "${getObj.value}"${(attrs.length > 0) ? '&attributes=' + attrs.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}"${(attrs.length > 0) ? '&attributes=' + attrs.join() : ''}`
} else {
// optional - simpel filtering
path = `/Groups?filter=${getObj.attribute} eq "${getObj.value}"${(attrs.length > 0) ? '&attributes=' + attrs.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${(attrs.length > 0 ? '?attributes=' + attrs.join() : '')}`
}
if (getObj.and || getObj.or) {
// plugin have enabled 'scimgateway.pluginAndOrFilterEnabled' and the query includes an additonal and/or getObj that must to be handled and combined with the initial getObj
// we could have this logic above, if not it must be defined here
throw new Error(`${action} error: logic for handling and/or filter is not implemented by plugin, not supporting: ${getObj.rawFilter}`)
}
// mandatory if-else logic - end
if (!path) throw new Error(`${action} error: mandatory if-else logic not fully implemented`)
const targetStartIndex = getObj.startIndex || 1
const targetCount = getObj.count || 200
// Notes for OData (nextLink) paging support:
// - request parameters should include "$count=true" when valid
// - see plugin-entra-id.ts which use OData
// - uncomment below
/*
// enable doRequest() OData paging support
let paging = { startIndex: getObj.startIndex || 1 }
if (!ctx) ctx = { paging }
else ctx.paging = paging
if (path.includes('$count=true')) { // $count=true requires ConsistencyLevel
// note: when using $expand, the $count=true might be ignored by target endpoint and the ctx.paging.totalResults updated by doReqest() will be incremental
if (!options.headers) options.headers = {}
options.headers.ConsistencyLevel = 'eventual'
}
*/
let currentStartIndex = isAllowlistingGroup ? 1 : targetStartIndex
let allValidResources: any[] = []
let totalSkipped = 0
let targetTotalResults: number | null = null
let iteration = 0
const maxIterations = 5 // Safety limit for look-ahead fetching
const resourcesNeeded = isAllowlistingGroup ? targetStartIndex + targetCount - 1 : targetCount
try {
while (allValidResources.length < resourcesNeeded && iteration < maxIterations) {
let currentPath = path
// may skip adding SCIM pagination parameters startIndex/count if not supported by target endpoint
if (!currentPath.includes('$count')) { // check for OData $count
if (currentPath.includes('?')) currentPath += `&startIndex=${currentStartIndex}&count=${targetCount}`
else currentPath += `?startIndex=${currentStartIndex}&count=${targetCount}`
}
// In this use case the target response body is SCIM and includes the Resources array - response.body.Resources
// Replace the "response.body.Resources" according to target endpoint response syntax
// OData response is supported by the response.body.value check
const response = await helper.doRequest(baseEntity, method, currentPath, body, ctx, options)
if (response.statusCode > 399) {
throw new Error(`${response.statusMessage} - ${JSON.stringify(response.body)}`)
} else if (!response.body) {
throw new Error(`invalid response: ${JSON.stringify(response)}`)
} else if (!response.body.Resources && !Array.isArray(response.body.value)) {
const groupObj = response.body
const [scimObj, err] = scimgateway.endpointMapper('inbound', groupObj, config.map.group)
if (err && err.message.includes('valueMap')) return null // allowListing when valueMap is configured, skipping objects not being "valueMapped"
return scimObj
}
const resources = response.body.Resources || response.body.value || []
targetTotalResults = (response.body.totalResults !== undefined && response.body.totalResults !== null) ? response.body.totalResults : null
if (ctx?.paging && Object.hasOwn(ctx.paging, 'totalResults')) targetTotalResults = ctx.paging.totalResults
const pageValidResources: any[] = []
for (const res of resources) {
if (!res || Object.keys(res).length < 1) continue
const [scimObj, err] = scimgateway.endpointMapper('inbound', res, config.map.group)
if (err && err.message.includes('valueMap')) continue // allowListing when valueMap is configured, skipping objects not being "valueMapped". Example: map.group.displayName.valueMap={"outboundEndpointGrp1":"inboundScimGrp1","Employees":"Admins"}
if (scimObj && typeof scimObj === 'object' && Object.keys(scimObj).length > 0) {
pageValidResources.push(scimObj)
}
}
totalSkipped += (resources.length - pageValidResources.length)
allValidResources.push(...pageValidResources)
if (targetTotalResults === null) {
// Target endpoint returned full list
ret.totalResults = isAllowlistingGroup ? allValidResources.length : targetStartIndex - 1 + allValidResources.length
ret.Resources = isAllowlistingGroup ? allValidResources.slice(targetStartIndex - 1, targetStartIndex - 1 + targetCount) : allValidResources.slice(0, targetCount)
return ret
}
// If we reached the end of target's list
if ((currentStartIndex - 1 + resources.length) >= targetTotalResults) break
// Look-ahead: fetch next page because some results were filtered out
currentStartIndex += resources.length
if (ctx?.paging) ctx.paging.startIndex = currentStartIndex
iteration++
}
if (ctx?.paging && getObj.startIndex !== ctx.paging.startIndex) { // changed by doRequest()
ret.startIndex = ctx.paging.startIndex
}
if (ctx?.paging && Object.hasOwn(ctx.paging, 'totalResults')) targetTotalResults = ctx.paging.totalResults
ret.totalResults = (targetTotalResults !== null && targetTotalResults > totalSkipped) ? targetTotalResults - totalSkipped : (isAllowlistingGroup ? allValidResources.length : targetStartIndex - 1 + allValidResources.length)
ret.Resources = isAllowlistingGroup ? allValidResources.slice(targetStartIndex - 1, targetStartIndex - 1 + targetCount) : allValidResources.slice(0, targetCount)
if (!ret.startIndex) ret.startIndex = targetStartIndex
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)} passThrough=${ctx ? 'true' : 'false'}`)
const [targetObj, err] = scimgateway.endpointMapper('outbound', groupObj, config.map.group)
if (err && err.message.includes('displayName.valueMap')) {
// valueMap configured for object having mapTo=displayName and value not allowlisted
err.message = new Error(`${action} error: ${err.message}`)
throw err
}
const method = 'POST'
const path = '/Groups'
const body = targetObj
try {
const response = await helper.doRequest(baseEntity, method, path, body, ctx)
if (response.statusCode > 399) {
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} passThrough=${ctx ? 'true' : 'false'}`)
const method = 'DELETE'
const path = `/Groups/${id}`
const body = null
try {
const response = await helper.doRequest(baseEntity, method, path, body, ctx)
if (response.statusCode > 399) {
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)} passThrough=${ctx ? 'true' : 'false'}`)
if (!attrObj.members && !attrObj.description) {
throw new Error(`${action} error: only supports modification of members and description`)
}
if (!Array.isArray(attrObj.members)) {
throw new Error(`${action} error: ${JSON.stringify(attrObj)} - correct syntax is { "members": [...] }`)
}
const membersToAdd = attrObj.members.filter(m => m.value && m.operation !== 'delete').map((m) => { return { value: m.value } })
const membersToRemove = attrObj.members.filter(m => m.value && m.operation === 'delete').map((m) => { return { value: m.value } })
// Note, below logic is hardcoded for the SCIM endpoint and must be updated to reflect the configured endpoint.
// Please see other plugins for how it can be implemented
let body: any = {}
if (config.entity[baseEntity].scimVersion && config.entity[baseEntity].scimVersion === '1.1') { // scim v1.1 endpoint
if (attrObj.members.length < 1) return null
body = { members: attrObj.members }
} else { // scim 2.0 endpoint
if (membersToAdd.length < 1 && membersToRemove.length < 1) return null
body = { Operations: [] }
if (membersToAdd.length > 0) {
body.Operations.push(
{
op: 'add',
path: 'members',
value: membersToAdd,
},
)
}
if (membersToRemove.length > 0) {
body.Operations.push(
{
op: 'remove',
path: 'members',
value: membersToRemove,
},
)
}
}
const method = 'PATCH'
const path = `/Groups/${id}`
try {
const response = await helper.doRequest(baseEntity, method, path, body, ctx)
if (response.statusCode > 399) {
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
})