scimgateway
Version:
Using SCIM protocol as a gateway for user provisioning to other endpoints
929 lines (849 loc) • 90.6 kB
text/typescript
// =====================================================================================================================
// File: plugin-entra-id.js
//
// Author: Jarle Elshaug
//
// Purpose: Entra ID provisioning including licenses e.g. O365
//
// Prereq: Entra ID configuration:
// Entra Application key defined (clientsecret). Other options are upload a certificate or configure "Federated Identity Credentials"
// plugin-entra-ad.json configured with corresponding clientid and clientsecret (or certificate/federated identity credentials)
// Application permission: Directory.ReadWriteAll and Organization.ReadWrite.All
// Application must be member of "User Account Administrator" or "Global administrator"
//
// Notes: For Symantec/Broadcom/CA Provisioning - Use ConnectorXpress, import metafile
// "node_modules\scimgateway\config\resources\Azure - ScimGateway.xml" for creating endpoint
//
// 'GET /Roles' retrieves a list of all available roles specified by type (Permanent or Eligible) and corresponds with the users attribute roles.
// 'GET /Entitlements' retrieves a list of all available entitlements specified by type (License or AccessPackage) and corresponds with the users attribute entitlements.
//
// Using "Custom SCIM" attributes defined in configuration endpoint.entity.map
// Schema generated according mapping configuration.
// Note:
// - 'map.user.signInActivity' requires Entra ID Premium license and API permissions 'AuditLog.Read.All'.
// - 'map.user.entitlements' relates to Licenses and Access Packages. Access Packages requires API permissions 'EntitlementManagement.ReadWrite.All'
// - 'map.user.roles relates to standard Permanent roles and PIM Permanent and Eligible roles.
// PIM is included on tenant having P2 or Governance License and requires following API permissions:
// - PIM Eligible roles requires API permissions 'RoleEligiblitySchedule.ReadWrite.All'
// - PIM Permanent roles requires API permissions 'RoleManagement.ReadWrite.Directory'
// - Remove mapping if conditions not met
//
// /User SCIM (custom) Endpoint (AAD)
// --------------------------------------------------------------------------------------------
// User Principal Name userName userPrincipalName
// Id id id
// Suspended active accountEnabled
// Password passwordProfile.password passwordProfile.password
// First Name name.givenName givenName
// Last Name name.familyName surname
// Fullname displayName displayName
// E-mail mail mail
// Mobile Number mobilePhone mobilePhone
// Phone Number businessPhone businessPhones
// Manager Id manager.managerId manager
// City city city
// Country country country
// Department department department
// Job Title jobTitle jobTitle
// Postal Code postalCode postalCode
// State or Locality state state
// Street Address streetAddress streetAddress
// Mail Nick Name mailNickname mailNickname
// Force Change Password Next Login passwordProfile.forceChangePasswordNextSignIn passwordProfile.forceChangePasswordNextSignIn
// onPremises Immutable ID onPremisesImmutableId onPremisesImmutableId
// onPremises Synchronization Enabled onPremisesSyncEnabled onPremisesSyncEnabled
// User Type userType userType
// Password Policies passwordPolicies passwordPolicies
// Preferred Language preferredLanguage preferredLanguage
// Usage Location usageLocation usageLocation
// Office Location officeLocation officeLocation
// Proxy Addresses proxyAddresses.value proxyAddresses
// Groups groups - virtual readOnly N/A
// Roles roles roles (roleAssignments/roleEligibilitySchedules) - type=Permanent/Eligiable, value=id, display=role display name
// Entitlements entitlements entitlements (assignedLicenses) - type=License, value=skuId and display=user-friendly-license-name / type=AccessPackage, value=AP-id and display=AP-displayName
// SignInActivity signInActivity signInActivity (lastSignInDateTime, lastSuccessfulSignInDateTime and lastNonInteractiveSignInDateTime), Note: Requires Entra ID Premium license and API permissions: 'AuditLog.Read.All'. Remove this mapping if conditions not met".
//
// /Group SCIM (custom) Endpoint (AAD)
// --------------------------------------------------------------------------------------------
// Name displayName displayName
// Id id id
// Description description description
// Members members members
// =====================================================================================================================
import path from 'node:path'
// 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 = true
// end - mandatory plugin initialization
const newHelper = new HelperRest(scimgateway)
const entitlementsByValues: Record<string, any> = {}
const rolesByValues: Record<string, any> = {}
const rolesAssignments: Record<string, any> = {}
const lockEntitlement = new scimgateway.Lock()
const lockRole = new scimgateway.Lock()
const permission: Record<string, any> = {}
// load Azure license mapping JSON-file having skuPartNumber and corresponding user-friendly name
let fs: typeof import('fs')
let licenseMapping: Record<string, any> = {}
async function loadLicenseMapping() {
try {
if (!fs) fs = (await import('fs'))
let mappingPath = path.join(scimgateway.pluginDir, 'azure-license-mapping.json')
if (fs.existsSync(mappingPath)) {
licenseMapping = JSON.parse(fs.readFileSync(mappingPath, 'utf8'))
} else {
mappingPath = path.join(scimgateway.gwDir, 'azure-license-mapping.json')
if (fs.existsSync(mappingPath)) {
licenseMapping = JSON.parse(fs.readFileSync(mappingPath, 'utf8'))
}
}
} catch (err) {
scimgateway.logDebug('plugin-entra-id', `Error loading license mapping: ${err}`)
}
}
loadLicenseMapping()
const mapAttributes: string[] = []
const mapAttributesTo: string[] = []
let userSelectAttributes: string[] = []
for (const key in config.map.user) { // mapAttributesTo = ['id', 'country', 'preferredLanguage', 'mail', 'city', 'displayName', 'postalCode', 'jobTitle', 'businessPhone', 'onPremisesSyncEnabled', 'officeLocation', 'name.givenName', 'passwordPolicies', 'id', 'state', 'department', 'mailNickname', 'manager.managerId', 'active', 'userName', 'name.familyName', 'proxyAddresses.value', 'servicePlan.value', 'mobilePhone', 'streetAddress', 'onPremisesImmutableId', 'userType', 'usageLocation']
if (config.map.user[key].mapTo) {
mapAttributes.push(key)
mapAttributesTo.push(config.map.user[key].mapTo)
let attr = key.split('.')[0]
// complexArray/complexObject are special
if (config.map.user[key].mapTo === 'entitlements') attr = 'assignedLicenses'
if (config.map.user[key].mapTo === 'roles') continue
if (!userSelectAttributes.includes(attr)) userSelectAttributes.push(attr)
}
}
if (!mapAttributes.includes('id')) {
mapAttributes.push('id')
if (!userSelectAttributes.includes('id')) userSelectAttributes.push('id')
}
if (!mapAttributesTo.includes('id')) mapAttributesTo.push('id')
const groupAttributes: string[] = []
for (const key in config.map.group) { // groupAttributes = ['id', 'displayName', 'securityEnabled', 'mailEnabled']
if (config.map.group[key].mapTo) groupAttributes.push(config.map.group[key].mapTo)
}
if (!groupAttributes.includes('id')) groupAttributes.push('id')
if (!groupAttributes.includes('members.value')) groupAttributes.push('members.value')
// check if signinActivity and PIM eligible roles can be used and update permission accordingly
// signInActivity requires Entra ID Premium license and API permissions: 'AuditLog.Read.All'.
// PIM eligible roles requires either Entra ID P2 or Governance License and API permissions: 'RoleEligiblitySchedule.ReadWrite.All'
;(async () => {
for (const baseEntity in config.entity) {
try {
permission[baseEntity] = {}
const [signInResult, eligibleResult, permanentScheduleResult, accessPackageResult] = await Promise.allSettled([
(async () => {
if (!mapAttributesTo.includes('signInActivity')) throw new Error('skipping signInActivity check')
await helper.doRequest(baseEntity, 'GET', '/users?$top=1&$select=id,signInActivity', null, null)
})(),
(async () => {
if (!mapAttributesTo.includes('roles')) throw new Error('skipping eligible check')
await helper.doRequest(baseEntity, 'GET', '/roleManagement/directory/roleEligibilityScheduleInstances?$top=1', null, null)
})(),
(async () => {
if (!mapAttributesTo.includes('roles')) throw new Error('skipping permanent schedule check')
await helper.doRequest(baseEntity, 'GET', '/roleManagement/directory/roleAssignmentScheduleInstances?$top=1', null, null)
})(),
(async () => {
if (!mapAttributesTo.includes('entitlements')) throw new Error('skipping access package check')
await helper.doRequest(baseEntity, 'GET', '/identityGovernance/entitlementManagement/accessPackages?$top=1&$select=id', null, null)
})(),
])
if (signInResult.status === 'fulfilled') {
permission[baseEntity].signInActivity = true
} else {
permission[baseEntity].signInActivity = false
if (mapAttributesTo.includes('signInActivity')) scimgateway.logError(baseEntity, `signInActivity functionality has been deactivatede because it requires Entra ID Premium license, as well as the API permissions 'AuditLog.Read.All'`)
}
if (eligibleResult.status === 'fulfilled') {
permission[baseEntity].eligible = true
} else {
permission[baseEntity].eligible = false
if (mapAttributesTo.includes('roles')) scimgateway.logError(baseEntity, `PIM eligible role functionality has been deactivated because it requires either a P2 or Governance license, as well as the API permission 'RoleEligibilitySchedule.ReadWrite.All'`)
}
if (permanentScheduleResult.status === 'fulfilled') {
permission[baseEntity].permanentSchedule = true
} else {
permission[baseEntity].permanentSchedule = false
if (mapAttributesTo.includes('roles')) scimgateway.logError(baseEntity, `PIM permanent role functionality has been deactivated because it requires either a P2 or Governance license, as well as the API permission 'RoleManagement.ReadWrite.Directory'`)
}
if (accessPackageResult.status === 'fulfilled') {
permission[baseEntity].accessPackage = true
} else {
permission[baseEntity].accessPackage = false
if (mapAttributesTo.includes('roles')) scimgateway.logError(baseEntity, `IGA Access Packages functionality has been deactivated because it requires API permission 'EntitlementManagement.ReadWrite.All'`)
}
} catch (err) {}
}
})()
// =================================================
// getUsers
// =================================================
scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
//
// "getObj" = { attribute: <>, operator: <>, value: <>, rawFilter: <>, startIndex: <>, count: <>, and/or: <getObj> }
// rawFilter is always included when filtering
// attribute, operator and value are included when requesting unique object or simpel filtering
// and/or will be included and the value set to corresponding getObj if the mandatory plugin initialization have 'scimgateway.pluginAndOrFilterEnabled = true' and the request query filter includes simple and/or logic
// See comments in the "mandatory if-else logic - start"
//
// "attributes" is array of attributes to be returned - if empty, all supported attributes should be returned
// Should normally return all supported user attributes having id and userName as mandatory
// id and userName are most often considered as "the same" having value = <UserID>
// Note, the value of returned 'id' will be used as 'id' in modifyUser and deleteUser
// scimgateway will automatically filter response according to the attributes list
//
const action = 'getUsers'
scimgateway.logDebug(baseEntity, `handling ${action} getObj=${getObj ? JSON.stringify(getObj) : ''} attributes=${attributes} passThrough=${ctx ? 'true' : 'false'}`)
const ret: any = {
Resources: [],
totalResults: null,
}
let response: any
let selectAttributes: string[] = []
if (attributes.length > 0) {
for (const attribute of attributes) {
const [endpointAttr] = scimgateway.endpointMapper('outbound', attribute, config.map.user)
let attr = endpointAttr.split('.')[0]
if (!attr) continue
// complexArray/complexObject are special
if (attribute.startsWith('entitlements')) attr = 'assignedLicenses'
if (attribute.startsWith('roles')) continue
if (!selectAttributes.includes(attr)) selectAttributes.push(attr)
}
} else selectAttributes = userSelectAttributes
if (!permission[baseEntity]?.signInActivity) { // remove signInActivity
const index = selectAttributes.indexOf('signInActivity')
if (index > -1) {
selectAttributes.splice(index, 1)
}
}
const method = 'GET'
const body = null
let path: string = ''
let options: Record<string, any> = {}
let isExpandManager = true
if (Object.hasOwn(getObj, 'value')) getObj.value = encodeURIComponent(getObj.value)
if (!Object.hasOwn(getObj, 'count')) getObj.count = 100
if (getObj.count > 100) getObj.count = 100 // Entra ID max 100 (historically max was 999)
// mandatory if-else logic - start
if (getObj.operator) {
if (getObj.operator === 'eq' && ['id'].includes(getObj.attribute)) { // userName/externalId using simpel filtering because direct lookup by upn do not allow select attribute signInActivity
// mandatory - unique filtering - single unique user to be returned - correspond to getUser() in versions < 4.x.x
path = `/users/${getObj.value}?$select=${selectAttributes.join(',')}`
} 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
throw new Error(`${action} error: not supporting groups member of user filtering: ${getObj.rawFilter}`)
} else {
// optional - simpel filtering
if (getObj.attribute) {
let [endpointAttr] = scimgateway.endpointMapper('outbound', getObj.attribute, config.map.user)
if (!endpointAttr) throw new Error(`${action} filter error: not supporting ${getObj.rawFilter} because there are no map.user configuration of SCIM attribute '${getObj.attribute}'`)
if (!operatorMap[getObj.operator]) throw new Error(`${action} error: operator '${getObj.operator}' is not supported in filter: ${getObj.rawFilter}`)
const eArr = endpointAttr.split('.')
if (eArr[0] == 'signInActivity' && eArr.length === 2) {
endpointAttr = eArr.join('/') // signInActivity/lastSuccessfulSignInDateTime - filter=signInActivity.lastSuccessfulSignInDateTime lt "2025-12-04T00:00:00Z"
}
let odataFilter: string | undefined = operatorMap[getObj.operator](endpointAttr, getObj.value)
// role and entitlements filtering
const arr = getObj.attribute.split('.')
if (['roles', 'entitlements'].includes(arr[0])) {
odataFilter = undefined
let type
let obj // set to the filter object based on the "type-object" and the use of and-object
if (getObj.attribute === `${arr[0]}.type`) {
type = getObj.value
if (getObj.and) obj = getObj.and
else obj = getObj
} else if (getObj.and?.attribute === `${arr[0]}.type`) {
type = getObj.and.value
obj = getObj
} else obj = getObj // no type defined
if (config.map.user[arr[0]] && ['complexArray', 'complexObject'].includes(config.map.user[arr[0]]?.type)) {
if (arr[0] === 'roles') {
if (type && type !== 'Permanent' && type !== 'Eligible') throw new Error(`${action} filter error: when using roles.type, the type must be either 'Permanent' or 'Eligible`)
const o = await getUsersByRole(baseEntity, obj, (type) ? decodeURIComponent(type) as 'Permanent' | 'Eligible' : undefined, ctx)
if (!Array.isArray(o) || o.length === 0) return ret
const fnArr: { fn: () => Promise<any> }[] = []
for (const id of o) {
const userPath = `/users/${id}?$select=${selectAttributes.join(',')}`
const fn = () => helper.doRequest(baseEntity, 'GET', userPath, null, ctx?.headers ? { headers: ctx?.headers } : undefined)
fnArr.push({ fn })
}
response = { body: { value: [] } }
await fnCunckExecute(fnArr, response.body.value) // fnCunckExecute results in response.body.value and evaluated later
if (response.body.value.length === 0) return ret
} else if (arr[0] === 'entitlements') { // using entitlements for licenses and access packages
if (getObj.attribute !== 'entitlements.type' && getObj.and?.attribute !== 'entitlements.type') throw new Error(`${action} filter error: mandatory entitlements.type is missing, examples: entitlements[type eq "xxx"], entitlements[type eq "xxx" and value eq "xxx"], entitlements[type eq "xxx" and display <eq/co/sw> "xxx"]`)
if (type === 'License') {
if (obj.operator === 'eq' && obj.attribute === 'entitlements.type') { // entitlements[type eq "License"]
path = `/users?$top=${getObj.count}&$count=true&$filter=assignedLicenses/$count ne 0&$select=${selectAttributes.join(',')}`
isExpandManager = false
} else { // entitlements[type eq "License" and value eq "xxx"], entitlements[type eq "License" and display <eq/co/sw> "xxx"]
const skuIdArr = await searchEntitlementsByValues(baseEntity, obj, 'License', ctx)
if (skuIdArr.length === 0) return ret
if (skuIdArr.length === 1) odataFilter = `assignedLicenses/any(x:x/skuId eq ${skuIdArr[0]})`
else throw new Error(`${action} filter error: not supporting: ${getObj.rawFilter} - entitlements filter resulted in more than one skuId which is not supported. For guaranteed uniqueness use: filter=entitlements[type eq "License" and value eq "<skuId>"]`)
}
} else if (type === 'AccessPackage') {
let o: Record<string, any> | undefined
if (obj.operator === 'eq' && obj.attribute === 'entitlements.type') { // entitlements[type eq "AccessPackage"]
o = await getUsersByAccessPackage(baseEntity, obj, ctx?.headers ? { headers: ctx?.headers } : undefined)
} else { // entitlements[type eq "AccessPackage" and value eq "xxx"], entitlements[type eq "AccessPackage" and display <eq/co/sw> "xxx"]
const idArr = await searchEntitlementsByValues(baseEntity, obj, 'AccessPackage', ctx)
if (idArr.length === 0) return ret
else if (idArr.length > 1) throw new Error(`${action} filter error: not supporting: ${getObj.rawFilter} - entitlements filter resulted in more than one id which is not supported. For guaranteed uniqueness use: filter=entitlements[type eq "AccessPackage" and value eq "<id>"]`)
o = await getUsersByAccessPackage(baseEntity, { attribute: 'entitlements.value', operator: 'eq', value: idArr[0] }, ctx?.headers ? { headers: ctx?.headers } : undefined)
}
if (typeof o !== 'object' || o === null || Object.keys(o).length === 0) return ret
const isAttrsOk = attributes.length > 0 && attributes.length < 3 && (attributes.includes('id') || attributes.includes('displayName'))
const fnArr: { fn: () => Promise<any> }[] = []
for (const key in o) {
if (isAttrsOk) ret.Resources.push(o[key])
else {
const userPath = `/users/${key}?$select=${selectAttributes.join(',')}`
const fn = () => helper.doRequest(baseEntity, 'GET', userPath, null, ctx?.headers ? { headers: ctx?.headers } : undefined)
fnArr.push({ fn })
}
}
if (isAttrsOk) return ret
else {
response = { body: { value: [] } }
await fnCunckExecute(fnArr, response.body.value) // fnCunckExecute results in response.body.value and evaluated later
if (response.body.value.length === 0) return ret
}
} else throw new Error(`${action} error: entitlements.type must be either "License" or "AccessPackage"`)
} else throw new Error(`${action} error: not supporting filtering: ${getObj.rawFilter}`)
if (getObj.and) delete getObj.and // delete to flag done and final check will succeed
} else throw new Error(`${action} error: not supporting filtering: ${getObj.rawFilter}`)
}
if (odataFilter !== undefined) {
if (odataFilter === '') {
const [supported] = scimgateway.endpointMapper('inbound', 'displayName,userPrincipalName,mail,proxyAddresses', config.map.user)
throw new Error(`${action} error: Entra ID only supports operator '${getObj.operator}' for a limited set of attributes (e.g., SCIM attributes: ${supported}) and therefore not supporting filter: ${getObj.rawFilter}`)
}
if (odataFilter.startsWith('$search=')) {
path = `/users?$top=${getObj.count}&$count=true&${odataFilter}&$select=${selectAttributes.join(',')}`
isExpandManager = false // using $search we cannot include $expand=manager
} else { // eq, sw, co, etc.
path = `/users?$top=${getObj.count}&$count=true&$filter=${odataFilter}&$select=${selectAttributes.join(',')}`
}
// advanced queries like 'contains', '$search', and '$count' require the ConsistencyLevel header.
if (!options.headers) options.headers = {}
options.headers.ConsistencyLevel = 'eventual'
}
}
if (getObj.operator === 'pr' || getObj.operator === 'not pr') isExpandManager = false
}
} else if (getObj.rawFilter) {
// optional - advanced filtering having and/or/not - use getObj.rawFilter
// note, advanced filtering "light" using and/or (not combined) is handled by scimgateway through plugin simpel filtering above
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?$top=${getObj.count}&$count=true&$select=${selectAttributes.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 && !response?.body?.value) throw new Error(`${action} error: mandatory if-else logic not fully implemented`)
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'
}
// enable doRequest() OData paging support
let paging = { startIndex: getObj.startIndex }
if (!ctx) ctx = { paging }
else ctx.paging = paging
try {
if (isExpandManager && selectAttributes.includes('manager')) {
path += '&$expand=manager($select=userPrincipalName)'
}
if (!response?.body?.value) {
response = await helper.doRequest(baseEntity, method, path, body, ctx, options)
}
if (!response.body?.value) {
const singleUser = response.body
response.body = { value: [singleUser] }
}
if (!response.body.value) {
throw new Error(`invalid response: ${JSON.stringify(response)}`)
}
const fnArr: { index: number, fn: () => Promise<any> }[] = []
const byValues = await getEntitlementsByValues(baseEntity, ctx)
// include manager
if (!isExpandManager && selectAttributes.includes('manager')) {
for (let i = 0; i < response.body.value.length; ++i) {
if (!response.body.value[i].id) break
const singleUserPath = `/users/${response.body.value[i].id}/manager?$select=userPrincipalName`
const fn = () => helper.doRequest(baseEntity, 'GET', singleUserPath, null, ctx?.headers ? { headers: ctx?.headers } : undefined, options)
fnArr.push({ index: i, fn })
}
await fnCunckExecute(fnArr, response.body.value, 'manager')
}
// include groups (before roles)
if (attributes.length === 0 || attributes.includes('groups')) {
for (let i = 0; i < response.body.value.length; ++i) {
if (!response.body.value[i].id) break
const fn = () => scimgateway.getUserGroups(baseEntity, response.body.value[i].id, ctx?.headers ? { headers: ctx?.headers } : undefined)
fnArr.push({ index: i, fn })
}
await fnCunckExecute(fnArr, response.body.value, 'groups')
}
// attribute cleanup and mapping
for (let i = 0; i < response.body.value.length; ++i) {
const obj = response.body.value[i]
if (obj.manager?.userPrincipalName) {
let managerId = obj.manager.userPrincipalName
if (managerId) obj.manager = managerId
else delete obj.manager
}
if (obj.signInActivity) {
delete obj.signInActivity.lastSignInRequestId
delete obj.signInActivity.lastNonInteractiveSignInRequestId
delete obj.signInActivity.lastSuccessfulSignInRequestId
}
// include roles and entitlements
if (obj.id) {
const roles = async (obj: Record<string, any>): Promise<Record<string, any>[]> => {
// roles type=Permanent/Eligible
if ((attributes.includes('roles') || attributes.length === 0) && mapAttributesTo.includes('roles')) {
return await getUserRoles(baseEntity, obj.id, obj.groups, false, ctx?.headers ? { headers: ctx?.headers } : undefined)
} else return []
}
const entitlements = async (obj: Record<string, any>): Promise<Record<string, any>[]> => {
const result: Record<string, any>[] = []
if ((attributes.includes('entitlements') || attributes.length === 0) && mapAttributesTo.includes('entitlements')) {
// entitlements type=License => assignedLicenses
if (obj.assignedLicenses && Array.isArray(obj.assignedLicenses)) {
for (const lic of response.body.value[i].assignedLicenses) {
if (lic.skuId && byValues[lic.skuId]) result.push(byValues[lic.skuId])
}
}
// entitlements type=AccessPackage
if (permission[baseEntity]?.accessPackage) {
const aps = await getUserAccessPackages(baseEntity, obj.id, false, ctx?.headers ? { headers: ctx?.headers } : undefined)
result.push(...aps)
}
}
return result
}
const arrResolve = await Promise.all([
roles(obj),
entitlements(obj),
])
obj.roles = arrResolve[0]
obj.entitlements = arrResolve[1]
}
// map to inbound
const [scimObj] = scimgateway.endpointMapper('inbound', obj, config.map.user) // endpoint => SCIM/CustomSCIM attribute standard
if (scimObj && typeof scimObj === 'object' && Object.keys(scimObj).length > 0) {
if (obj.groups && !scimObj.groups) scimObj.groups = obj.groups // not included in mapper
ret.Resources.push(scimObj)
}
}
if (getObj.startIndex !== ctx.paging.startIndex) { // changed by doRequest()
ret.startIndex = ctx.paging.startIndex
}
if (ctx.paging.totalResults) ret.totalResults = ctx.paging.totalResults // set by doRequest()
else ret.totalResults = getObj.startIndex ? getObj.startIndex - 1 + response.body.value.length : response.body.value.length
return (ret)
} catch (err: any) {
if (err.message.includes('Request_ResourceNotFound')) return { Resources: [] }
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'}`)
// roles and entitlements only supported for getUsers - readOnly
if (userObj.roles) delete userObj.roles
if (userObj.entitlements) delete userObj.entitlements
const addonObj: Record<string, any> = {}
if (userObj.manager) {
addonObj.manager = userObj.manager
delete userObj.manager
}
if (userObj.proxyAddresses) {
addonObj.proxyAddresses = userObj.proxyAddresses
delete userObj.proxyAddresses
}
if (userObj.entitlements) {
delete userObj.entitlements // entitlements (licenses) not supported for create/modify - use groups for license management
}
const method = 'POST'
const path = '/users'
const [body] = scimgateway.endpointMapper('outbound', userObj, config.map.user)
try {
const res = await helper.doRequest(baseEntity, method, path, body, ctx)
if (Object.keys(addonObj).length > 0) {
const id = res?.body?.id || userObj.userName
await scimgateway.modifyUser(baseEntity, id, addonObj, ctx) // manager, proxyAddresses, servicePlan
}
return res?.body
} catch (err: any) {
const newErr = new Error(`${action} error: ${err.message}`)
if (err.message.includes('userPrincipalName already exists')) newErr.name += '#409' // customErrCode
else if (err.message.includes('Property netId is invalid')) {
newErr.name += '#409'
let addMsg = ''
if (userObj.mail) addMsg = ' e.g., mail'
newErr.message = 'userPrincipalName already exists and/or other unique attribute conflicts' + addMsg
}
throw newErr
}
}
// =================================================
// 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 {
await helper.doRequest(baseEntity, method, path, body, ctx)
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'}`)
// roles and entitlements only supported for getUsers - readOnly
// if (attrObj.roles) delete attrObj.roles
// if (attrObj.entitlements) delete attrObj.entitlements
const [parsedAttrObj]: Record<string, any>[] = scimgateway.endpointMapper('outbound', attrObj, config.map.user) // SCIM/CustomSCIM => endpoint attribute standard
if (parsedAttrObj instanceof Error) throw (parsedAttrObj) // error object
const objManager: Record<string, any> = {}
if (Object.hasOwn(parsedAttrObj, 'manager')) {
objManager.manager = parsedAttrObj.manager
if (objManager.manager === '') objManager.manager = null
delete parsedAttrObj.manager
}
const fnArr: { fn: () => Promise<any> }[] = []
let isRolesChanged = false
const getValueByDisplayName = async (display: string): Promise<string | undefined> => {
const res = await scimgateway.getRoles(baseEntity, { attribute: 'displayName', operator: 'eq', value: display }, [], ctx)
if (Array.isArray(res?.Resources) && res.Resources.length === 1) return res.Resources[0]?.id
return undefined
}
// Roles
if (Object.hasOwn(parsedAttrObj, 'roles') && Array.isArray(parsedAttrObj.roles)) {
const r: Record<string, any>[] = []
for (const el of parsedAttrObj.roles) {
if (!el.type) { // set default according to tenant type (PIM vs no PIM)
if (permission[baseEntity].eligible) el.type = 'Eligible'
else el.type = 'Permanent'
}
if (el.type !== 'Permanent' && el.type !== 'Eligible') throw new Error(`${action} error: roles.type must set to 'Permanent' or 'Eligible'`)
if (el.type === 'Eligible' && !permission[baseEntity]?.eligible) throw new Error(`${action} error: roles.type 'Eligible' is not supported by the endpoint or current configuration. Use 'Permanent' instead.`)
if (!el.value) {
if (el.display) el.value = await getValueByDisplayName(el.display)
if (!el.value) throw new Error(`${action} error: Role modification is missing the 'value' key, or the optional 'display' key is not found or unique.`)
}
const res: Record<string, any> = { value: el.value, type: el.type }
if (el.display) res.display = el.display
if (el.operation === 'delete') {
if (el.value === '62e90394-69f5-4237-9190-012177145e10') throw new Error(`${action} error: Removal of the 'Global Administrator' role is not allowed for security reasons.`)
res.operation = el.operation
}
r.push(res)
}
delete parsedAttrObj.roles
const rolesAdd: Record<string, any> [] = r.filter(m => m.operation !== 'delete')
const rolesRemove: Record<string, any> [] = r.filter(m => m.operation === 'delete')
if (rolesAdd.length > 0 || rolesRemove.length > 0) {
const currentRoles = await getUserRoles(baseEntity, id, [], true, ctx)
for (const r of rolesAdd) {
const roleExist = currentRoles.filter(c => c.value === r.value && c.type === r.type)
if (roleExist.length > 0) continue // exlude adding already assigned
let method = 'POST'
let path = ''
let body: Record<string, any> = {}
if ((r.type === 'Eligible' && permission[baseEntity]?.eligible) || (r.type === 'Permanent' && permission[baseEntity]?.permanentSchedule)) {
path = (r.type === 'Eligible') ? '/roleManagement/directory/roleEligibilityScheduleRequests' : '/roleManagement/directory/roleAssignmentScheduleRequests'
body = {
action: 'AdminAssign',
principalId: id,
roleDefinitionId: r.value,
directoryScopeId: '/',
justification: 'Automated assignment submitted by SCIM Gateway',
scheduleInfo: {
startDateTime: new Date().toISOString(),
expiration: {
type: 'noExpiration',
},
},
}
} else {
if (r.type === 'Eligible') throw new Error(`${action} error: add/remove eligible roles requires permission RoleEligibilitySchedule.ReadWrite.All`)
path = '/roleManagement/directory/roleAssignments'
body = {
principalId: id,
roleDefinitionId: r.value,
directoryScopeId: '/',
}
}
const fn = () => helper.doRequest(baseEntity, method, path, body, ctx)
fnArr.push({ fn })
isRolesChanged = true
}
for (const r of rolesRemove) {
const arrRemove: Record<string, any> [] = []
const removeAssignments = currentRoles.filter(c => c.value === r.value && c.type === r.type && c.assignmentId).map((n) => { return { assignmentId: n.assignmentId, value: n.value, type: n.type } })
arrRemove.push(...removeAssignments)
for (const rm of arrRemove) {
let method = 'POST'
let path = ''
let body: Record<string, any> | null = {}
if (rm.type === 'Eligible' && permission[baseEntity]?.eligible) {
path = '/roleManagement/directory/roleEligibilityScheduleRequests'
body = {
action: 'AdminRemove',
principalId: id,
roleDefinitionId: rm.value,
directoryScopeId: '/',
justification: 'Automated revoke submitted by SCIM Gateway',
}
} else {
if (r.type === 'Eligible') throw new Error(`${action} error: add/remove eligible roles requires permission RoleEligibilitySchedule.ReadWrite.All`)
method = 'DELETE'
path = `/roleManagement/directory/roleAssignments/${rm.assignmentId}`
body = null
}
const fn = () => helper.doRequest(baseEntity, method, path, body, ctx)
fnArr.push({ fn })
isRolesChanged = true
}
}
}
}
// Entitlements - Access Packages - Note, License management not supported through entitlements, instead use groups
if (Object.hasOwn(parsedAttrObj, 'entitlements') && Array.isArray(parsedAttrObj.entitlements)) {
const accessPackagesAdd: Record<string, any> [] = parsedAttrObj.entitlements.filter(m => m.type === 'AccessPackage' && m.operation !== 'delete')
const accessPackagesRemove: Record<string, any> [] = parsedAttrObj.entitlements.filter(m => m.type === 'AccessPackage' && m.operation === 'delete')
if (accessPackagesAdd.length > 0) {
const byValues = await getEntitlementsByValues(baseEntity, ctx)
for (const a of accessPackagesAdd) {
if (!byValues[a.value]) continue
const assignmentPolicyId = byValues[a.value]?.typeInfo?.assignmentPolicies[0]?.id // TODO: note, using the first policy and this might be wrong if more than one defined...
if (!assignmentPolicyId) throw new Error(`${action} error: Access Package could not be assigned to user - entitlements value ${a.value} (Access Package ID) - no policy found for this Access Package`)
const method = 'POST'
let path = `/identityGovernance/entitlementManagement/accessPackageAssignmentRequests`
const body: Record<string, any> = {
requestType: 'AdminAdd',
accessPackageAssignment: {
target: {
'@odata.type': '#microsoft.graph.accessPackageSubject',
'objectId': id,
},
assignmentPolicyId,
accessPackageId: a.value,
},
justification: 'Automated assignment request submitted by SCIM Gateway',
}
const fn = () => helper.doRequest(baseEntity, method, path, body, ctx)
fnArr.push({ fn })
}
}
if (accessPackagesRemove.length > 0) {
const arrRemove: Record<string, any> [] = []
const currentAPs = await getUserAccessPackages(baseEntity, id, true, ctx)
for (const r of accessPackagesRemove) {
const removeAssignments = currentAPs.filter(c => c.value === r.value && c.type === r.type && c.assignmentId)
arrRemove.push(...removeAssignments)
}
for (const rm of arrRemove) {
const method = 'POST'
let path = `/identityGovernance/entitlementManagement/accessPackageAssignmentRequests`
const body: Record<string, any> = {
requestType: 'adminRemove',
accessPackageAssignment: {
id: rm.assignmentId,
},
justification: 'Automated revoke request submitted by SCIM Gateway',
}
const fn = () => helper.doRequest(baseEntity, method, path, body, ctx)
fnArr.push({ fn })
}
}
}
if (fnArr.length > 0) { // update roles/entitlements
try {
await fnCunckExecute(fnArr)
if (isRolesChanged) {
(async () => {
await new Promise(resolve => setTimeout(resolve, 15000))
await getRolesAssignments(baseEntity, ctx, true) // make sure the internal assignments list becomes updated
})()
}
} catch (err: any) {
throw new Error(`${action} roles modify error: ${err.message}`)
}
}
if (parsedAttrObj.roles) delete parsedAttrObj.roles
if (parsedAttrObj.entitlements) delete parsedAttrObj.entitlements
const profile = () => { // patch
return new Promise((resolve, reject) => {
(async () => {
if (JSON.stringify(parsedAttrObj) === '{}') return resolve(null)
let res: any
for (const key in parsedAttrObj) { // if object, the modified Entra ID object must contain all elements, if not they will be cleared e.g. employeeOrgData
if (typeof parsedAttrObj[key] === 'object') { // get original object and merge
const method = 'GET'
const path = `/users/${id}`
try {
if (!res) {
res = await helper.doRequest(baseEntity, method, path, null, ctx)
}
if (res?.body && res.body[key]) {
const fullKeyObj = Object.assign(res.body[key], parsedAttrObj[key]) // merge original with modified
if (fullKeyObj && Object.keys(fullKeyObj).length > 0) {
for (const k in fullKeyObj) {
if (fullKeyObj[k] === '') {
fullKeyObj[k] = null
}
}
parsedAttrObj[key] = fullKeyObj
}
}
} catch (err) {
return reject(err)
}
} else if (parsedAttrObj[key] === '') {
parsedAttrObj[key] = null
}
}
const method = 'PATCH'
const path = `/users/${id}`
try {
await helper.doRequest(baseEntity, method, path, parsedAttrObj, ctx)
resolve(null)
} catch (err) {
return reject(err)
}
})()
})
}
const manager = () => {
return new Promise((resolve, reject) => {
(async () => {
if (!Object.hasOwn(objManager, 'manager')) return resolve(null)
let method: string | null = null
let path: string | null = null
let body: Record<string, any> | null = null
if (objManager.manager) { // new manager
const graphUrl = helper.getGraphUrl()
method = 'PUT'
path = `/users/${id}/manager/$ref`
body = { '@odata.id': `${graphUrl}/users/${objManager.manager}` }
} else { // delete manager (null/undefined/'')
method = 'DELETE'
path = `/users/${id}/manager/$ref`
body = null
}
try {
await helper.doRequest(baseEntity, method, path, body, ctx)
resolve(null)
} catch (err) {
return reject(err)
}
})()
})
}
return Promise.all([profile(), manager()])
.then((_) => { return (null) })
.catch((err) => { 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 = {
Resources: [],
totalResults: null,
}
if (Object.hasOwn(getObj, 'value')) getObj.value = encodeURIComponent(getObj.value)
if (attributes.length === 0) attributes = groupAttributes
let includeMembers = false
if (attributes.length === 0) includeMembers = true
else {
for (const attr of attributes) {
if (attr.startsWith('members')) {
includeMembers = true
break
}
}
}
const [attrs] = scimgateway.endpointMapper('outbound', attributes, config.map.group)
const method = 'GET'
const body = null
let path
let options: Record<string, any> = {}
let isUserMemberOf = getObj?.operator === 'eq' && getObj?.attribute === 'members.value'
if (!Object.hasOwn(getObj, 'count')) getObj.count = 100
if (getObj.count > 100) getObj.count = 100 // Entra ID max 100 (historically max was 999)
// 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') {
if (includeMembers) path = `/groups/${getObj.value}?$select=${attrs.join()}&$expand=members($select=id,displayName)`
else path = `/groups/${getObj.value}?$select=${attrs.join()}`
} else {
if (includeMembers) path = `/groups?$filter=${getObj.attribute} eq '${getObj.value}'&$select=${attrs.join()}&$expand=members($select=id,displayName)`
else path = `/groups?$filter=${getObj.attribute} eq '${getObj.value}'&$select=${attrs.join()}`
}
} else if (isUserMemberOf) {
// 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 = `/users/${getObj.value}/transitiveMemberOf/microsoft.graph.group?$top=${getObj.count}&$count=true&$select=id,displayName`
} else {
// optional - simpel filtering
throw new Error(`${action} error: Entra ID only supports group filter operator 'eq' for a limited set of attributes ('id', 'displayName' and 'members.value') and therefore not supporting filter: ${getObj.rawFilter}`)
}
} else if (getObj.rawFilter) {
// optional - advanced filtering having and/or/not - use getObj.rawFilter
// note, advanced filtering "light" using and/or (not combined) is handled by scimgateway through plugin simpel filtering above
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
if (includeMembers) path = `/groups?$top=${getObj.count}&$count=true&$select=${attrs.join()}&$expand=members($select=id,displayName)`
else path = `/groups?$top=${getObj.count}&$count=true&$select=${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`)
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'
}
// enable doRequest() OData paging support
l