UNPKG

scimgateway

Version:

Using SCIM protocol as a gateway for user provisioning to other endpoints

929 lines (849 loc) 90.6 kB
// ===================================================================================================================== // 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