scimgateway
Version:
Using SCIM protocol as a gateway for user provisioning to other endpoints
754 lines (692 loc) • 34 kB
text/typescript
// =====================================================================================================================
// File: plugin-entra-id.js
//
// Author: Jarle Elshaug
//
// Purpose: Entra ID provisioning including licenses e.g. O365
//
// Prereq: Entra ID configuration:
// Application key defined (clientsecret)
// plugin-entra-ad.json configured with corresponding clientid and clientsecret
// 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
//
// Using "Custom SCIM" attributes defined in scimgateway.endpointMap
// Some functionality will also work using standard SCIM
// You could also use your own version of endpointMap
//
// /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 businessPhones 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
//
// /Group SCIM (custom) Endpoint (AAD)
// --------------------------------------------------------------------------------------------
// Name displayName displayName
// Id id id
// Members members members
// =====================================================================================================================
import querystring from 'querystring'
// for supporting nodejs running scimgateway package directly, using dynamic import instead of: import { ScimGateway } from 'scimgateway'
// scimgateway also inclues HelperRest: import { ScimGateway, HelperRest } from 'scimgateway'
// start - mandatory plugin initialization
const ScimGateway: typeof import('scimgateway').ScimGateway = await (async () => {
try {
return (await import('scimgateway')).ScimGateway
} catch (err) {
const source = './scimgateway.ts'
return (await import(source)).ScimGateway
}
})()
const HelperRest: typeof import('scimgateway').HelperRest = await (async () => {
try {
return (await import('scimgateway')).HelperRest
} catch (err) {
const source = './scimgateway.ts'
return (await import(source)).HelperRest
}
})()
const scimgateway = new ScimGateway()
const config = scimgateway.getConfig()
scimgateway.authPassThroughAllowed = false
// end - mandatory plugin initialization
const helper = new HelperRest(scimgateway)
if (config.map) { // having licensDetails map here instead of config file
config.map.licenseDetails = {
servicePlanId: {
mapTo: 'id',
type: 'string',
},
servicePlans: {
mapTo: 'servicePlans',
type: 'array',
},
skuId: {
mapTo: 'skuId',
type: 'string',
},
skuPartNumber: {
mapTo: 'skuPartNumber',
type: 'string',
},
servicePlanName: {
mapTo: 'servicePlanName',
type: 'string',
},
provisioningStatus: {
mapTo: 'provisioningStatus',
type: 'string',
},
appliesTo: {
mapTo: 'appliesTo',
type: 'string',
},
}
}
const userAttributes: string[] = []
for (const key in config.map.user) { // userAttributes = ['country', 'preferredLanguage', 'mail', 'city', 'displayName', 'postalCode', 'jobTitle', 'businessPhones', '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) userAttributes.push(config.map.user[key].mapTo)
}
// =================================================
// getUsers
// =================================================
scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
//
// "getObj" = { attribute: <>, operator: <>, value: <>, rawFilter: <>, startIndex: <>, count: <> }
// rawFilter is always included when filtering
// attribute, operator and value are included when requesting unique object or simpel filtering
// 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}`)
const ret: any = {
Resources: [],
totalResults: null,
}
const method = 'GET'
const body = null
let path
// mandatory if-else logic - start
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
path = 'getUser' // special handling
} 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
throw new Error(`${action} error: not supporting simpel filtering: ${getObj.rawFilter}`)
}
} 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
if (getObj.startIndex && getObj.startIndex > 1) { // paging
path = helper.nextLinkPaging(baseEntity, 'users', getObj.startIndex)
if (!path) return ret
} else {
getObj.count = (!getObj.count || getObj.count > 999) ? 999 : getObj.count
path = `/users?$top=${getObj.count}` // paging not supported using filter (Entra ID default page=100, max=999)
}
}
// mandatory if-else logic - end
if (!path) throw new Error(`${action} error: mandatory if-else logic not fully implemented`)
try {
let response: any
if (path === 'getUser') { // special
response = { body: { value: [] } }
const userObj: any = await getUser(baseEntity, getObj.value, attributes, ctx)
if (userObj) response.body.value.push(userObj)
} else response = await helper.doRequest(baseEntity, method, path, body, ctx)
if (!response.body.value) {
throw new Error(`invalid response: ${JSON.stringify(response)}`)
}
for (let i = 0; i < response.body.value.length; ++i) { // map to corresponding inbound
const [scimObj] = scimgateway.endpointMapper('inbound', response.body.value[i], config.map.user) // endpoint => SCIM/CustomSCIM attribute standard
if (scimObj && typeof scimObj === 'object' && Object.keys(scimObj).length > 0) ret.Resources.push(scimObj)
}
if (getObj.count === response.body.value.length) ret.totalResults = 99999999 // to ensure we get a new paging request - don't know the total numbers of users - metadata directoryObject collections are not countable
else ret.totalResults = getObj.startIndex ? getObj.startIndex - 1 + response.body.value.length : response.body.value.length
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)}`)
const addonObj: Record<string, any> = {}
if (userObj.servicePlan) {
addonObj.servicePlan = userObj.servicePlan
delete userObj.servicePlan
}
if (userObj.manager) {
addonObj.manager = userObj.manager
delete userObj.manager
}
const method = 'POST'
const path = '/users'
const [body] = scimgateway.endpointMapper('outbound', userObj, config.map.user)
try {
await helper.doRequest(baseEntity, method, path, body, ctx)
if (Object.keys(addonObj).length > 0) {
await scimgateway.modifyUser(baseEntity, userObj.userName, addonObj, ctx) // manager, servicePlan
return null
} else return (null)
} catch (err: any) {
const newErr = new Error(`${action} error: ${err.message}`)
if (err.message.includes('userPrincipalName already exists')) newErr.name += '#409' // customErrCode
throw newErr
}
}
// =================================================
// deleteUser
// =================================================
scimgateway.deleteUser = async (baseEntity, id, ctx) => {
const action = 'deleteUser'
scimgateway.logDebug(baseEntity, `handling ${action} id=${id}`)
const method = 'DELETE'
const path = `/Users/${id}`
const body = null
try {
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)}`)
if (attrObj.servicePlan) delete attrObj.servicePlan // use license management through groups
const [parsedAttrObj] = 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.prototype.hasOwnProperty.call(parsedAttrObj, 'manager')) {
objManager.manager = parsedAttrObj.manager
delete parsedAttrObj.manager
}
const profile = () => { // patch
return new Promise((resolve, reject) => {
(async () => {
if (JSON.stringify(parsedAttrObj) === '{}') return resolve(null)
for (const key in parsedAttrObj) { // if object the modified AAD 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}?$select=${key}`
try {
const res = await helper.doRequest(baseEntity, method, path, null, ctx)
if (res && 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) {
parsedAttrObj[key] = fullKeyObj
}
}
} catch (err) {
return reject(err)
}
}
}
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.prototype.hasOwnProperty.call(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()]) // license() deprecated - use license management through groups
.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}`)
const ret: any = {
Resources: [],
totalResults: null,
}
if (attributes.length < 1) attributes = ['id', 'displayName', 'members.value']
if (!attributes.includes('id')) attributes.push('id')
let includeMembers = false
if (attributes.includes('members.value') || attributes.includes('members')) {
includeMembers = true
}
const [attrs] = scimgateway.endpointMapper('outbound', attributes, config.map.group)
const method = 'GET'
const body = null
let path
// 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 (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>}] }]
// not using below expand because Entra ID returns only a maximum of 20 items for the expanded relationship
// path = `/users/${getObj.value}/memberOf/microsoft.graph.group?$select=id,displayName&$expand=members($select=id,displayName)`
path = `/users/${getObj.value}/memberOf/microsoft.graph.group?$select=id,displayName`
} else {
// optional - simpel filtering
throw new Error(`${action} error: not supporting simpel filtering: ${getObj.rawFilter}`)
}
} 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
// Entra paging not supported because of select filter (Entra default page=100, max=999)
// TODO: use a query that supports paging to fix current 999 limit of groups
getObj.count = 999
if (includeMembers) path = `/groups?$top=${getObj.count}&$select=${attrs.join()}&$expand=members($select=id,displayName)`
else path = `/groups?$top=${getObj.count}&$select=${attrs.join()}`
}
// mandatory if-else logic - end
if (!path) throw new Error(`${action} error: mandatory if-else logic not fully implemented`)
try {
let response = await helper.doRequest(baseEntity, method, path, body, ctx)
if (!response.body) {
throw new Error(`invalid response: ${JSON.stringify(response)}`)
}
if (!response.body.value) {
if (typeof response.body === 'object' && !Array.isArray(response.body)) response = { body: { value: [response.body] } }
else response.body.value = []
}
for (let i = 0; i < response.body.value.length; ++i) {
let members: any
if (response.body.value[i].members) {
members = response.body.value[i].members.map((el: Record<string, any>) => {
return {
value: el.id,
display: el.displayName,
}
})
delete response.body.value[i].members
} else if (getObj.operator === 'eq' && getObj.attribute === 'members.value') { // Not using expand-members. Only includes current user as member, but should have requested all...
members = [{
value: getObj.value,
}]
}
const [scimObj] = scimgateway.endpointMapper('inbound', response.body.value[i], config.map.group) // endpoint => SCIM/CustomSCIM attribute standard
if (scimObj && typeof scimObj === 'object' && Object.keys(scimObj).length > 0) {
if (members) scimObj.members = members
ret.Resources.push(scimObj)
}
}
// Entra paging not supported because of select filter
getObj.startIndex = 1
ret.totalResults = getObj.startIndex ? getObj.startIndex - 1 + response.body.value.length : response.body.value.length
return (ret)
} catch (err: any) {
throw new Error(`${action} error: ${err.message}`)
}
}
// =================================================
// createGroup
// =================================================
scimgateway.createGroup = async (baseEntity, groupObj, ctx) => {
const action = 'createGroup'
scimgateway.logDebug(baseEntity, `handling ${action} groupObj=${JSON.stringify(groupObj)}`)
const body: any = { displayName: groupObj.displayName }
body.mailNickName = groupObj.displayName
body.mailEnabled = false
body.securityEnabled = true
const method = 'POST'
const path = '/Groups'
try {
const res = await scimgateway.getGroups(baseEntity, { attribute: 'displayName', operator: 'eq', value: groupObj.displayName }, ['id', 'displayName'], ctx)
if (res && res.Resources && res.Resources.length > 0) {
throw new Error(`group ${groupObj.displayName} already exist`)
}
await helper.doRequest(baseEntity, method, path, body, ctx)
return null
} catch (err: any) {
const newErr = new Error(`${action} error: ${err.message}`)
if (err.message.includes('already exist')) newErr.name += '#409' // customErrCode
throw newErr
}
}
// =================================================
// deleteGroup
// =================================================
scimgateway.deleteGroup = async (baseEntity, id) => {
const action = 'deleteGroup'
scimgateway.logDebug(baseEntity, `handling ${action} id=${id}`)
throw new Error(`${action} error: ${action} is not supported`)
}
// =================================================
// modifyGroup
// =================================================
scimgateway.modifyGroup = async (baseEntity, id, attrObj, ctx) => {
const action = 'modifyGroup'
scimgateway.logDebug(baseEntity, `handling ${action} id=${id} attrObj=${JSON.stringify(attrObj)}`)
if (!attrObj.members) {
throw new Error(`${action} error: only supports modification of members`)
}
if (!Array.isArray(attrObj.members)) {
throw new Error(`${action} error: ${JSON.stringify(attrObj)} - correct syntax is { "members": [...] }`)
}
const arrGrpAdd: Record<string, any>[] = []
const arrGrpDel: Record<string, any>[] = []
attrObj.members.forEach(function (el) {
if (el.operation && el.operation === 'delete') { // delete member from group e.g {"members":[{"operation":"delete","value":"bjensen"}]}
arrGrpDel.push(el.value)
} else if (el.value) { // add member to group {"members":[{value":"bjensen"}]}
arrGrpAdd.push(el.value)
}
})
const addGrps = () => { // add groups
return new Promise((resolve, reject) => {
(async () => {
if (arrGrpAdd.length < 1) return resolve(null)
const method = 'POST'
const path = `/groups/${id}/members/$ref`
const graphUrl = helper.getGraphUrl()
for (let i = 0, len = arrGrpAdd.length; i < len; i++) {
const body = { '@odata.id': `${graphUrl}/directoryObjects/${arrGrpAdd[i]}` }
try {
await helper.doRequest(baseEntity, method, path, body, ctx)
if (i === len - 1) resolve(null) // loop completed
} catch (err) {
return reject(err)
}
}
})()
})
}
const removeGrps = () => { // remove groups
return new Promise((resolve, reject) => {
(async () => {
if (arrGrpDel.length < 1) return resolve(null)
const method = 'DELETE'
const body = null
for (let i = 0, len = arrGrpDel.length; i < len; i++) {
const path = `/groups/${id}/members/${arrGrpDel[i]}/$ref`
try {
await helper.doRequest(baseEntity, method, path, body, ctx)
if (i === len - 1) resolve(null) // loop completed
} catch (err) {
return reject(err)
}
}
})()
})
}
return Promise.all([addGrps(), removeGrps()])
.then((res) => { return res })
.catch((err) => { throw new Error(`${action} error: ${err.message}`) })
}
// =================================================
// getServicePlans
// =================================================
scimgateway.getServicePlans = async (baseEntity, getObj, attributes, ctx) => {
//
// "getObj" = { attribute: <>, operator: <>, value: <>, rawFilter: <>, startIndex: <>, count: <> }
// rawFilter is always included when filtering - attribute, operator and value are included when requesting unique object or simpel filtering
// See comments in the "mandatory if-else logic - start"
//
// "attributes" contains a list of attributes to be returned - if blank, all supported attributes should be returned
// Should normally return all supported user attributes having id and servicePlanName as mandatory
// id and servicePlanName are most often considered as "the same" having value = <servicePlanName>
// Note, the value of returned 'id' will be used as 'id' in modifyServicePlan and deleteServicePlan
// scimgateway will automatically filter response according to the attributes list
//
const action = 'getServicePlans'
scimgateway.logDebug(baseEntity, `handling ${action} getObj=${getObj ? JSON.stringify(getObj) : ''} attributes=${attributes}`)
const ret: any = {
Resources: [],
totalResults: null,
}
const method = 'GET'
const body = null
let path
// mandatory if-else logic - start
if (getObj.operator) {
if (getObj.operator === 'eq' && ['id', 'servicePlanName'].includes(getObj.attribute)) {
// mandatory - unique filtering - single unique user to be returned - correspond to getUser() in versions < 4.x.x
if (attributes.length === 1 && attributes[0] === 'servicePlanName') {
ret.Resources = [{ servicePlanName: getObj.value }]
return ret
}
path = 'getServicePlan' // special handling
} else if (getObj.operator === 'eq' && getObj.attribute === 'servicePlan.value') {
// optional - only used when servicePlans are member of users, not default behavior - correspond to getGroupUsers() in versions < 4.x.x
throw new Error(`${action} error: servicePlans member of user filtering not supported: ${getObj.rawFilter}`)
} else {
// optional - simpel filtering
throw new Error(`${action} error: simpel filtering not supported: ${getObj.rawFilter}`)
}
} else if (getObj.rawFilter) {
// optional - advanced filtering having and/or/not - use getObj.rawFilter
throw new Error(`${action} error: advanced filtering not supported: ${getObj.rawFilter}`)
} else {
// mandatory - no filtering (!getObj.operator && !getObj.rawFilter) - all serviePlans to be returned - correspond to exploreServicePlans() in versions < 4.x.x
path = '/subscribedSkus' // paging not supported
}
if (!path) throw new Error(`${action} error: mandatory if-else logic not fully implemented`)
try {
let response
if (path === 'getServicePlan') { // special
response = { body: { value: [] } }
path = '/subscribedSkus'
const res = await helper.doRequest(baseEntity, method, path, body, ctx)
if (!res.body.value) {
throw new Error('got empty response on REST request')
}
const [arrOutbound] = (scimgateway.endpointMapper('outbound', attributes, config.map.licenseDetails))
const [arrInbound] = (scimgateway.endpointMapper('inbound', attributes, config.map.licenseDetails))
const arr = getObj.value.split('::') // servicePlaneName
const skuPartNumber = arr[0]
const plan = arr[1]
const planObj: Record<string, any> = {}
for (let i = 0; i < res.body.value.length; i++) {
if (res.body.value[i].skuPartNumber !== skuPartNumber) continue
for (let index = 0; index < res.body.value[i].servicePlans.length; index++) {
if (res.body.value[i].servicePlans[index].servicePlanName === plan) {
planObj.servicePlanName = `${skuPartNumber}::${res.body.value[i].servicePlans[index].servicePlanName}`
planObj.id = res.body.value[i].servicePlans[index].servicePlanId
for (let j = 0; j < arrInbound.length; j++) { // skuPartNumber, skuId, servicePlanName, servicePlanId
if (arrInbound[j] !== 'servicePlanName' && arrInbound[j] !== 'id') planObj[arrInbound[j]] = res.body.value[i][arrOutbound[j]]
}
i = res.body.value.length
break
}
}
}
if (planObj) ret.Resources.push(planObj)
} else {
response = await helper.doRequest(baseEntity, method, path, body, ctx)
if (!response.body.value) {
throw new Error('got empty response on REST request')
}
for (let i = 0; i < response.body.value.length; i++) {
const skuPartNumber = response.body.value[i].skuPartNumber
for (let index = 0; index < response.body.value[i].servicePlans.length; index++) {
if (response.body.value[i].servicePlans[index].servicePlanName && response.body.value[i].servicePlans[index].provisioningStatus === 'Success') {
const scimPlan = {
servicePlanName: `${skuPartNumber}::${response.body.value[i].servicePlans[index].servicePlanName}`,
id: response.body.value[i].servicePlans[index].servicePlanId,
}
ret.Resources.push(scimPlan)
}
}
}
}
ret.totalResults = ret.Resources.length // no paging
return ret
} catch (err: any) {
throw new Error(`${action} error: ${err.message}`)
}
}
// =================================================
// getUser
// addOn helper for plugin-azure-ad
// =================================================
const getUser = async (baseEntity: string, uid: string, attributes: string[], ctx?: undefined | Record<string, any>) => { // uid = id, userName (upn) or externalId (upn)
if (attributes.length < 1) {
attributes = userAttributes
}
const user = () => {
return new Promise((resolve, reject) => {
(async () => {
const method = 'GET'
const path = `/users/${querystring.escape(uid)}?$expand=manager($select=userPrincipalName)` // beta returns all attributes or use: ?$select=${attrs.join()}
const body = null
try {
const response = await helper.doRequest(baseEntity, method, path, body, ctx)
const userObj = response.body
if (!userObj) {
const err = new Error('Got empty response when retrieving data for ' + uid)
return reject(err)
}
let managerId
if (userObj.manager && userObj.manager.userPrincipalName) managerId = userObj.manager.userPrincipalName
delete userObj.manager
if (managerId) userObj.manager = managerId
resolve(userObj)
} catch (err) {
return reject(err)
}
})()
})
}
const license = () => {
return new Promise((resolve, reject) => {
(async () => {
if (!attributes.includes('servicePlan.value')) return resolve(null) // licenses not requested
const method = 'GET'
const path = `/users/${querystring.escape(uid)}/licenseDetails`
const body = null
const retObj: Record<string, any> = {}
retObj.servicePlan = []
try {
const response = await helper.doRequest(baseEntity, method, path, body, ctx)
if (!response.body.value) {
const err = new Error('No content for license information ' + uid)
return reject(err)
} else {
if (response.body.value.length < 1) return resolve(null) // User with no licenses
for (let i = 0; i < response.body.value.length; i++) {
const skuPartNumber = response.body.value[i].skuPartNumber
for (let index = 0; index < response.body.value[i].servicePlans.length; index++) {
if (response.body.value[i].servicePlans[index].provisioningStatus === 'Success'
|| response.body.value[i].servicePlans[index].provisioningStatus === 'PendingInput') {
const servicePlan = { value: `${skuPartNumber}::${response.body.value[i].servicePlans[index].servicePlanName}` }
retObj.servicePlan.push(servicePlan)
}
}
}
}
resolve(retObj)
} catch (err: any) {
let statusCode
try { statusCode = JSON.parse(err.message).statusCode } catch (e) {}
if (statusCode === 404) return resolve(null) // user have no plans
return reject(err)
}
})()
})
}
return Promise.all([user(), license()])
.then((results) => {
let retObj = {}
for (const i in results) { // merge async.parallell results to one
retObj = Object.assign(retObj, results[i])
}
return retObj
})
.catch((err) => {
if (err.message.includes('empty response')) return null // no user found
else throw (err)
})
}
//
// Cleanup on exit
//
process.on('SIGTERM', () => { // kill
})
process.on('SIGINT', () => { // Ctrl+C
})