UNPKG

@keepsolutions/scimgateway

Version:

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

1,072 lines (988 loc) 55.9 kB
// ===================================================================================================================== // File: plugin-azure-ad.js // // Author: Jarle Elshaug // // Purpose: Azure AD provisioning including licenses e.g. O365 // // Prereq: Azure AD configuration: // Application key defined (clientsecret) // plugin-azure-ad.json configured with corresponding clientid and clientsecret // Application permission "Windows Azure Active Directory" - all "Application Permissions" // Application must be member of "User Account Administrator" (powershell import-Module MSOnline) // // Notes: For CA Provisioning - Use ConnectorXpress, import metafile // "node_modules\scimgateway\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 // License servicePlan.value servicePlan // Groups groups - virtual readOnly N/A // // /Group SCIM (custom) Endpoint (AAD) // -------------------------------------------------------------------------------------------- // Name displayName displayName // Id id id // Members members members // // /servicePlan SCIM (custom) Endpoint (AAD) // -------------------------------------------------------------------------------------------- // Service Plan Name servicePlanName servicePlanName // SKU ID skuId skuId // SKU Part Number skuPartNumber skuPartNumber // // ===================================================================================================================== 'use strict' const http = require('http') const https = require('https') const HttpsProxyAgent = require('https-proxy-agent') const URL = require('url').URL const querystring = require('querystring') // mandatory plugin initialization - start const path = require('path') let ScimGateway = null try { ScimGateway = require('scimgateway') } catch (err) { ScimGateway = require('./scimgateway') } const scimgateway = new ScimGateway() const pluginName = path.basename(__filename, '.js') const configDir = path.join(__dirname, '..', 'config') const configFile = path.join(`${configDir}`, `${pluginName}.json`) let config = require(configFile).endpoint config = scimgateway.processExtConfig(pluginName, config) // add any external config process.env and process.file // mandatory plugin initialization - end const graphv1 = 'https://graph.microsoft.com/v1.0' const _serviceClient = {} const lock = new scimgateway.Lock() // ================================================= // exploreUsers // ================================================= scimgateway.exploreUsers = async (baseEntity, attributes, startIndex, count) => { const action = 'exploreUsers' scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" attributes=${attributes} startIndex=${startIndex} count=${count}`) const ret = { // itemsPerPage will be set by scimgateway Resources: [], totalResults: null } let arrAttr = [] if (attributes) arrAttr = attributes.split(',') await getServiceClient(baseEntity) // because need to make sure having _serviceClient with Azure paging - @odata.nextLink if (!_serviceClient[baseEntity].nextLink.users.skiptoken && startIndex && startIndex > 1) return (ret) // break endless fake-totalresult paging loop if (_serviceClient[baseEntity].nextLink.users.skiptoken && startIndex && startIndex < 2) _serviceClient[baseEntity].nextLink.users.skiptoken = null // should not occure const method = 'GET' let path = null if (_serviceClient[baseEntity].nextLink.users.skiptoken) { // paging path = `/users?${_serviceClient[baseEntity].nextLink.users.skiptoken}` } else { path = `/users?$top=${(!count || count > 999) ? 999 : count}` // paging not supported using filter (Azure default page=100, max=999) } const body = null try { const response = await doRequest(baseEntity, method, path, body) if (!response.body.value) { const err = new Error(`${action}: Got empty response on request`) throw (err) } for (let i = 0; i < response.body.value.length; ++i) { // only limited set of attributes - for all attributes to be included see getUser const [scimUser] = scimgateway.endpointMapper('inbound', response.body.value[i], scimgateway.endpointMap.microsoftGraphUser) // endpoint => SCIM/CustomSCIM attribute standard if (scimUser && typeof scimUser === 'object' && Object.keys(scimUser).length > 0) { if (!attributes) ret.Resources.push(scimUser) else { // include only attributes specified const user = {} for (let i = 0; i < arrAttr.length; i++) { const key = arrAttr[i].split('.')[0] // title => title, name.familyName => name if (scimUser[key]) user[key] = scimUser[key] } ret.Resources.push(user) } } } if (response.body['@odata.nextLink']) _serviceClient[baseEntity].nextLink.users.skiptoken = response.body['@odata.nextLink'].split('?')[1] // paging keep search query else _serviceClient[baseEntity].nextLink.users.skiptoken = null if (!startIndex && !count) ret.totalResults = response.body.value.length // client request without paging else ret.totalResults = 99999999 // faking to ensure we get a new paging request - don't know the total numbers of users - metadata directoryObject collections are not countable return (ret) } catch (err) { const newErr = err throw newErr } } // ================================================= // exploreGroups // ================================================= scimgateway.exploreGroups = async (baseEntity, attributes, startIndex, count) => { const action = 'exploreGroups' scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" attributes=${attributes} startIndex=${startIndex} count=${count}`) const ret = { // itemsPerPage will be set by scimgateway Resources: [], totalResults: null } let includeMembers = false if (!attributes || attributes.includes('members')) includeMembers = true await getServiceClient(baseEntity) // because need to make sure having _serviceClient with Azure paging - @odata.nextLink if (!_serviceClient[baseEntity].nextLink.groups.skiptoken && startIndex && startIndex > 1) return (ret) // break endless fake-totalresult paging loop if (_serviceClient[baseEntity].nextLink.groups.skiptoken && startIndex && startIndex < 2) _serviceClient[baseEntity].nextLink.groups.skiptoken = null // should not occure const method = 'GET' let path = null if (_serviceClient[baseEntity].nextLink.groups.skiptoken) { // paging path = `/groups?${_serviceClient[baseEntity].nextLink.groups.skiptoken}` } else { path = `/groups?$top=${(!count || count > 999) ? 999 : count}` // paging not supported using filter (Azure default page=100, max=999) } const body = null try { const response = await doRequest(baseEntity, method, path, body) if (!response.body.value) { const err = new Error(`${action}: Got empty response on request`) throw (err) } for (let i = 0; i < response.body.value.length; ++i) { const [scimGroup] = scimgateway.endpointMapper('inbound', response.body.value[i], scimgateway.endpointMap.microsoftGraphGroup) // endpoint => SCIM/CustomSCIM attribute standard if (scimGroup && typeof scimGroup === 'object' && Object.keys(scimGroup).length > 0) { if (includeMembers) { const method = 'GET' const path = `/groups/${scimGroup.id}/members?$select=id,userPrincipalName` const body = null const response = await doRequest(baseEntity, method, path, body) if (!response.body.value && !Array.isArray(response.body.value)) { const err = new Error(`${action}: Got empty response on REST request`) throw (err) } else { // include group members scimGroup.members = [] response.body.value.forEach(function (el) { scimGroup.members.push({ value: el.id, display: el.userPrincipalName }) }) } } ret.Resources.push(scimGroup) } } if (response.body['@odata.nextLink']) _serviceClient[baseEntity].nextLink.groups.skiptoken = response.body['@odata.nextLink'].split('?')[1] // paging keep search query else _serviceClient[baseEntity].nextLink.groups.skiptoken = null if (!startIndex && !count) ret.totalResults = response.body.value.length // client request without paging else ret.totalResults = 99999999 // faking to ensure we get a new paging request - don't know the total numbers of groups - metadata directoryObject collections are not countable return (ret) // all explored groups in page of result } catch (err) { const newErr = err throw newErr } } // ================================================= // getUser // ================================================= scimgateway.getUser = async (baseEntity, getObj, attributes) => { // getObj = { filter: <filterAttribute>, identifier: <identifier> } // e.g: getObj = { filter: 'userName', identifier: 'bjensen'} // filter: userName and id must be supported // (they are most often considered as "the same" where identifier = UserID ) // Note, the value of id attribute returned will be used by modifyUser and deleteUser // attributes: if not blank, attributes listed should be returned // Should normally return all supported user attributes having id and userName as mandatory // SCIM Gateway will automatically filter response according to the attributes list const action = 'getUser' scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" ${getObj.filter}=${getObj.identifier} attributes=${attributes}`) if (getObj.filter === 'manager.managerId') { scimgateway.logger.debug(`${pluginName}[${baseEntity}] ignoring returning all users having ${getObj.filter}=${getObj.identifier} - AAD will do manager attribute cleanup on deletion of manager`) const arr = [] return arr } if (getObj.filter !== 'userName' && getObj.filter !== 'externalId' && getObj.filter !== 'id') { throw new Error(`plugin do not support handling "${action}" ${getObj.filter}`) } if (!attributes) attributes = 'attributes=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' const user = () => { return new Promise(async (resolve, reject) => { const [parsedAttr] = scimgateway.endpointMapper('outbound', attributes, scimgateway.endpointMap.microsoftGraphUser) // SCIM/CustomSCIM => endpoint attribute standard const method = 'GET' const path = `/users/${querystring.escape(getObj.identifier)}?$select=${parsedAttr}` // #EXT# need url encoding e.g myaccount_outlook.com#EXT#@mycompany.onmicrosoft.com const body = null try { const response = await doRequest(baseEntity, method, path, body) const userObj = response.body if (!userObj) { const err = new Error('Got empty response when retrieving data for ' + getObj.identifier) return reject(err) } resolve(userObj) } catch (err) { return reject(err) } }) } const manager = () => { return new Promise(async (resolve, reject) => { if (attributes.indexOf('manager.managerId') < 0) return resolve(null) // request without manager const method = 'GET' const path = `/users/${querystring.escape(getObj.identifier)}/manager?$select=id` const body = null try { const response = await doRequest(baseEntity, method, path, body) if (!response.body.id) { const err = new Error('Manager id not found when retrieving manager for ' + getObj.identifier) return reject(err) } else resolve({ manager: response.body.id }) } catch (err) { let statusCode try { statusCode = JSON.parse(err.message).statusCode } catch (e) {} if (statusCode === 404) return resolve(null) // no manager attribute set on Azure user object (doReqest not logging 404 as error) return reject(err) } }) } const license = () => { return new Promise(async (resolve, reject) => { if (attributes.indexOf('servicePlan.value') < 0) return resolve(null) // licenses not requested const method = 'GET' const path = `/users/${querystring.escape(getObj.identifier)}/licenseDetails` const body = null const retObj = { servicePlan: [] } try { const response = await doRequest(baseEntity, method, path, body) if (!response.body.value) { const err = new Error('No content for license information ' + getObj.identifier) 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) { 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(), manager(), license()]) .then((results) => { let retObj = {} for (const i in results) { // merge async.parallell results to one retObj = Object.assign(retObj, results[i]) } const [obj] = scimgateway.endpointMapper('inbound', retObj, scimgateway.endpointMap.microsoftGraphUser) // endpoint => SCIM/CustomSCIM attribute standard return obj }) .catch((err) => { if (err.message.includes('empty response')) return (null) // no user found else throw (err) }) } // ================================================= // createUser // ================================================= scimgateway.createUser = async (baseEntity, userObj) => { const action = 'createUser' scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" userObj=${JSON.stringify(userObj)}`) const attrObj = {} if (userObj.servicePlan) { attrObj.servicePlan = userObj.servicePlan // will be included in a modifyuser delete userObj.servicePlan } const method = 'POST' const path = '/users' const [body] = scimgateway.endpointMapper('outbound', userObj, scimgateway.endpointMap.microsoftGraphUser) try { await doRequest(baseEntity, method, path, body) if (attrObj.servicePlan) { await scimgateway.modifyUser(baseEntity, userObj.userName, attrObj) return null } else return (null) } catch (err) { const newErr = new Error(err.message) if (newErr.message.includes('userPrincipalName already exists')) newErr.name = 'DuplicateKeyError' // gives scimgateway statuscode 409 instead of default 500 throw (newErr) } } // ================================================= // deleteUser // ================================================= scimgateway.deleteUser = async (baseEntity, id) => { const action = 'deleteUser' scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" id=${id}`) const method = 'DELETE' const path = `/Users/${id}` const body = null try { await doRequest(baseEntity, method, path, body) return (null) } catch (err) { const newErr = err throw newErr } } // ================================================= // modifyUser // ================================================= scimgateway.modifyUser = async (baseEntity, id, attrObj) => { const action = 'modifyUser' scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" id=${id} attrObj=${JSON.stringify(attrObj)}`) const arrLicAdd = [] const arrLicDel = [] if (Array.isArray(attrObj.servicePlan)) { attrObj.servicePlan.forEach(function (el) { if (el.operation && el.operation === 'delete') { // delete license { servicePlan: [ { operation: 'delete', value: 'O365_BUSINESS::OFFICE_BUSINESS' } ] } arrLicDel.push(el.value) } else if (el.value) { // add license { servicePlan: [ { value: 'O365_BUSINESS::OFFICE_BUSINESS' } ] } arrLicAdd.push(el.value) } }) delete attrObj.servicePlan } const [parsedAttrObj] = scimgateway.endpointMapper('outbound', attrObj, scimgateway.endpointMap.microsoftGraphUser) // SCIM/CustomSCIM => endpoint attribute standard if (parsedAttrObj instanceof Error) throw (parsedAttrObj) // error object const objManager = {} if (parsedAttrObj.manager) { // new manager objManager.manager = JSON.parse(JSON.stringify(parsedAttrObj.manager)) delete parsedAttrObj.manager } else if (parsedAttrObj.manager === null) { // delete manager objManager.manager = null delete parsedAttrObj.manager } const profile = () => { // patch return new Promise(async (resolve, reject) => { if (JSON.stringify(parsedAttrObj) === '{}') return resolve(null) const method = 'PATCH' const path = `/users/${id}` try { await doRequest(baseEntity, method, path, parsedAttrObj) resolve(null) } catch (err) { return reject(err) } }) } const manager = () => { return new Promise(async (resolve, reject) => { let method = null let path = null let body = null if (objManager.manager) { // new manager method = 'PUT' path = `/users/${id}/manager/$ref` body = { '@odata.id': `${graphv1}/users/${objManager.manager}` } } else if (objManager.manager === null) { // delete manager method = 'DELETE' path = `/users/${id}/manager/$ref` body = null } else return resolve(null) try { await doRequest(baseEntity, method, path, body) resolve(null) } catch (err) { return reject(err) } }) } const license = () => { return new Promise(async (resolve, reject) => { if (arrLicAdd.length < 1 && arrLicDel.length < 1) return resolve(null) // no licenses to update // currentLic let method = 'GET' let path = `/users/${querystring.escape(id)}/licenseDetails` const currentLic = {} try { // build currentLic let response try { response = await doRequest(baseEntity, method, path, null) } catch (err) { let statusCode try { statusCode = JSON.parse(err.message).statusCode } catch (e) {} if (statusCode === 404) return resolve(null) // no licenseDetails throw err } if (!response.body.value) { const err = new Error('No content for license information for user with id ' + id) return reject(err) } if (response.body.value.length > 0) { for (let i = 0; i < response.body.value.length; i++) { // currentLic = {skuId: [servicePlanId]} if (!currentLic[response.body.value[i].skuId]) currentLic[response.body.value[i].skuId] = [] 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' || response.body.value[i].servicePlans[index].provisioningStatus === 'PendingInput')) { currentLic[response.body.value[i].skuId].push(response.body.value[i].servicePlans[index].servicePlanId) } } } } // availableLic method = 'GET' path = '/subscribedSkus' const availableLic = {} const addLic = {} const removeLic = {} response = await doRequest(baseEntity, method, path, null) if (!response.body.value) { const err = new Error(`${action}: Got empty response on REST request`) return reject(err) } for (let i = 0; i < response.body.value.length; i++) { // availableLic = {skuId: [servicePlanId]} if (!availableLic[response.body.value[i].skuId]) availableLic[response.body.value[i].skuId] = [] 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' || response.body.value[i].servicePlans[index].provisioningStatus === 'PendingInput')) { availableLic[response.body.value[i].skuId].push(response.body.value[i].servicePlans[index].servicePlanId) } } // addLic/removeLic based on arrAdd/arrRemove for (let j = 0; j < arrLicAdd.length; j++) { // add licenses const arrAdd = arrLicAdd[j].split('::') if (arrAdd.length !== 2) { const err = new Error(`${action}: License/ServicePart name must be on format skuPartNumber::servicePlanName `) return reject(err) } if (response.body.value[i].skuPartNumber === arrAdd[0]) { // addLic = {skuId: [servicePlanId]} const add = response.body.value[i].servicePlans.find(function (el) { return (el.servicePlanName === arrAdd[1]) }) if (add) { if (!addLic[response.body.value[i].skuId]) addLic[response.body.value[i].skuId] = [] addLic[response.body.value[i].skuId].push(add.servicePlanId) } } } for (let j = 0; j < arrLicDel.length; j++) { // delete licenses const arrDel = arrLicDel[j].split('::') if (arrDel.length !== 2) { const err = new Error(`${action}: License/ServicePart name must be on format skuPartNumber::servicePlanName `) return reject(err) } if (response.body.value[i].skuPartNumber === arrDel[0]) { const del = response.body.value[i].servicePlans.find(function (el) { return (el.servicePlanName === arrDel[1]) }) if (del) { if (!removeLic[response.body.value[i].skuId]) removeLic[response.body.value[i].skuId] = [] removeLic[response.body.value[i].skuId].push(del.servicePlanId) } } } } // disabledPlan = availableLic - currentLic const disabledPlans = {} for (const key in currentLic) { disabledPlans[key] = availableLic[key] for (let j = 0; j < currentLic[key].length; j++) { for (let k = 0; k < disabledPlans[key].length; k++) { if (disabledPlans[key][k] === currentLic[key][j]) disabledPlans[key].splice(k, 1) // delete } } } // merge disablePlan with addLic/removeLic for (const key in addLic) { if (!disabledPlans[key]) disabledPlans[key] = availableLic[key] // disable all for (let j = 0; j < addLic[key].length; j++) { for (let k = 0; k < disabledPlans[key].length; k++) { if (disabledPlans[key][k] === addLic[key][j]) disabledPlans[key].splice(k, 1) // delete } } } for (const key in removeLic) { for (let j = 0; j < removeLic[key].length; j++) { disabledPlans[key].push(removeLic[key][j]) } } // prepare for update const lic = { addLicenses: [], removeLicenses: [] } for (const key in disabledPlans) { if (addLic[key] || removeLic[key]) lic.addLicenses.push({ skuId: key, disabledPlans: disabledPlans[key] }) } // Update with added/removed licenses method = 'POST' path = `/users/${id}/assignLicense` const body = lic await doRequest(baseEntity, method, path, body) resolve(null) } catch (err) { return reject(err) } }) } return Promise.all([profile(), manager(), license()]) .then((result) => { return (null) }) .catch((err) => { throw (err) }) } // ================================================= // getGroup // ================================================= scimgateway.getGroup = async (baseEntity, getObj, attributes) => { // getObj = { filter: <filterAttribute>, identifier: <identifier> } // e.g: getObj = { filter: 'displayName', identifier: 'GroupA' } // filter: displayName and id must be supported // (they are most often considered as "the same" where identifier = GroupName) // Note, the value of id attribute returned will be used by deleteGroup, getGroupMembers and modifyGroup // attributes: if not blank, attributes listed should be returned // Should normally return all supported group attributes having id, displayName and members as mandatory // members may be skipped if attributes is not blank and do not contain members or members.value const action = 'getGroup' scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" ${getObj.filter}=${getObj.identifier} attributes=${attributes}`) if (getObj.filter !== 'displayName' && getObj.filter !== 'id') { throw new Error(`plugin do not support handling "${action}" ${getObj.filter}`) } try { let includeMembers = false if (!attributes || attributes.includes('members')) includeMembers = true const [parsedAttr] = scimgateway.endpointMapper('outbound', attributes, scimgateway.endpointMap.microsoftGraphGroup) // SCIM/CustomSCIM => endpoint attribute standard let path if (getObj.filter === 'id') { if (parsedAttr) path = `/groups/${getObj.identifier}?$select=${parsedAttr}` else path = `/groups/${getObj.identifier}` } else path = `/groups?$filter=${getObj.filter} eq '${getObj.identifier}'&$select=${parsedAttr},id` const method = 'GET' const body = null const response = await doRequest(baseEntity, method, path, body) let retObj if (!response.body) throw new Error(`${action}: Got empty or invalid response on REST request`) if (response.body.value) { if (!Array.isArray(response.body.value) || response.body.value.length !== 1) { throw new Error(`${action}: Got empty or invalid response on REST request`) } const obj = response.body.value[0] const [ret] = scimgateway.endpointMapper('inbound', obj, scimgateway.endpointMap.microsoftGraphGroup) // endpoint => SCIM/CustomSCIM attribute Standard retObj = ret } else { const obj = response.body const [ret] = scimgateway.endpointMapper('inbound', obj, scimgateway.endpointMap.microsoftGraphGroup) retObj = ret } if (includeMembers) { let idGroup = retObj.id if (getObj.filter === 'id') idGroup = getObj.identifier const method = 'GET' let path = `/groups/${idGroup}/members?$select=id,userPrincipalName` const body = null retObj.members = [] do { const response = await doRequest(baseEntity, method, path, body) if (!response.body.value && !Array.isArray(response.body.value)) { const err = new Error(`${action}: Got empty response on REST request`) throw (err) } else { // include group members response.body.value.forEach(function (el) { retObj.members.push({ value: el.id, display: el.userPrincipalName }) }) } path = null if (response.body['@odata.nextLink']) path = `/groups/${idGroup}/members?` + response.body['@odata.nextLink'].split('?')[1] // paging keep search query } while (path) // paging } return retObj } catch (err) { if (err.message.includes('empty or invalid response')) return (null) // no group found else throw err } } // ================================================= // getGroupMembers // ================================================= scimgateway.getGroupMembers = async (baseEntity, id, attributes) => { // return all groups the user is member of having attributes included e.g: members.value,id,displayName // method used when "users member of group", if used - getUser must treat user attribute groups as virtual readOnly attribute // "users member of group" is SCIM default and this method should normally have some logic const action = 'getGroupMembers' scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" user id=${id} attributes=${attributes}`) const arrRet = [] const method = 'GET' // const path = `/users/${id}/memberOf?$select=id,displayName` // includes all e.g. microsoft.graph.group and microsoft.graph.directoryRole const path = `/users/${id}/memberOf/microsoft.graph.group?$select=id,displayName` const body = null try { const response = await doRequest(baseEntity, method, path, body) if (!response.body.value) { const err = new Error(`${action}: Got empty response on REST request`) throw (err) } response.body.value.forEach(function (el) { const userGroup = { id: el.id, // id is mandatory displayName: el.displayName, // displayName is mandatory members: [{ value: id }] // only includes current user } arrRet.push(userGroup) // { id: <id-group>> , displayName: <displayName-group>, members [{value: <id-user>}] } }) return arrRet } catch (err) { const newErr = err throw newErr } } // ================================================= // getServicePlanMembers // ================================================= scimgateway.getServicePlanMembers = async (baseEntity, id, attributes) => { // not in use const action = 'getServicePlanMembers' scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" user id=${id} attributes=${attributes}`) const arrRet = [] return arrRet } // ================================================= // getGroupUsers // ================================================= scimgateway.getGroupUsers = async (baseEntity, id, attributes) => { // not in use // return array of all users that is member of this group id having attributes included e.g: groups.value,userName // method used when "group member of users", if used - getGroup must treat group attribute members as virtual readOnly attribute const action = 'getGroupUsers' scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" id=${id} attributes=${attributes}`) const arrRet = [] return arrRet } // ================================================= // createGroup // ================================================= scimgateway.createGroup = async (baseEntity, groupObj) => { const action = 'createGroup' scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" groupObj=${JSON.stringify(groupObj)}`) const body = { displayName: groupObj.displayName } body.mailNickName = groupObj.displayName body.mailEnabled = false body.securityEnabled = true const method = 'POST' const path = '/Groups' try { await doRequest(baseEntity, method, path, body) return null } catch (err) { const newErr = err throw newErr } } // ================================================= // deleteGroup // ================================================= scimgateway.deleteGroup = async (baseEntity, id) => { const action = 'deleteGroup' scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" id=${id}`) // if supporting delete group we need some endpoint logic here const err = new Error(`Delete group is not supported by ${pluginName}`) throw (err) } // ================================================= // modifyGroup // ================================================= scimgateway.modifyGroup = async (baseEntity, id, attrObj) => { const action = 'modifyGroup' scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" id=${id} attrObj=${JSON.stringify(attrObj)}`) if (!attrObj.members) { throw new Error(`plugin handling "${action}" only supports modification of members`) } if (!Array.isArray(attrObj.members)) { throw new Error(`plugin handling "${action}" error: ${JSON.stringify(attrObj)} - correct syntax is { "members": [...] }`) } const arrGrpAdd = [] const arrGrpDel = [] 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(async (resolve, reject) => { if (arrGrpAdd.length < 1) return resolve(null) const method = 'POST' const path = `/groups/${id}/members/$ref` for (let i = 0, len = arrGrpAdd.length; i < len; i++) { const body = { '@odata.id': `${graphv1}/directoryObjects/${arrGrpAdd[i]}` } try { await doRequest(baseEntity, method, path, body) if (i === len - 1) resolve(null) // loop completed } catch (err) { return reject(err) } } }) } const removeGrps = () => { // remove groups return new Promise(async (resolve, reject) => { 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 doRequest(baseEntity, method, path, body) 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 (err) }) } // ================================================= // exploreServicePlans // ================================================= scimgateway.exploreServicePlans = async (baseEntity, attributes, startIndex, count) => { const action = 'exploreServicePlans' scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" attributes=${attributes} startIndex=${startIndex} count=${count}`) const ret = { // itemsPerPage will be set by scimgateway Resources: [], totalResults: null } const method = 'GET' const path = '/subscribedSkus' // paging not supported const body = null try { const response = await doRequest(baseEntity, method, path, body) if (!response.body.value) { const err = new Error(`${action}: Got empty response on REST request`) throw (err) } 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}` } ret.Resources.push(scimPlan) } } } ret.totalResults = response.body.value.length return ret // all explored plans } catch (err) { const newErr = err throw newErr } } // ================================================= // getServicePlan // ================================================= scimgateway.getServicePlan = async (baseEntity, getObj, attributes) => { const action = 'geServicePlan' scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" ${getObj.filter}=${getObj.identifier} attributes=${attributes}`) if (getObj.filter !== 'servicePlanName' && getObj.filter !== 'id') { throw new Error(`plugin do not support handling "${action}" ${getObj.filter}`) } if (attributes === 'servicePlanName') return { servicePlanName: getObj.identifier } const arrOutbound = (scimgateway.endpointMapper('outbound', attributes, scimgateway.endpointMap.microsoftGraphLicenseDetails)[0]).split(',') const arrInbound = (scimgateway.endpointMapper('inbound', attributes, scimgateway.endpointMap.microsoftGraphLicenseDetails)[0]).split(',') const method = 'GET' const path = '/subscribedSkus' const body = null try { const response = await doRequest(baseEntity, method, path, body) if (!response.body.value) { const err = new Error(`${action}: Got empty response on REST request`) throw (err) } const arr = getObj.identifier.split('::') // servicePlaneName const skuPartNumber = arr[0] const plan = arr[1] const ret = {} for (let i = 0; i < response.body.value.length; i++) { if (response.body.value[i].skuPartNumber !== skuPartNumber) continue for (let index = 0; index < response.body.value[i].servicePlans.length; index++) { if (response.body.value[i].servicePlans[index].servicePlanName === plan) { ret.servicePlanName = `${skuPartNumber}::${response.body.value[i].servicePlans[index].servicePlanName}` ret.id = response.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') ret[arrInbound[j]] = response.body.value[i][arrOutbound[j]] } i = response.body.value.length break } } } return ret } catch (err) { const newErr = err throw newErr } } // ================================================= // helpers // ================================================= // // getServiceClient - returns options needed for connection parameters // // path = e.g. "/xxx/yyy", then using host/port/protocol based on config baseUrls[0] // auth automatically added and failover according to baseUrls array // // path = url e.g. "http(s)://<host>:<port>/xxx/yyy", then using the url host/port/protocol // opt (options) may be needed e.g {auth: {username: "username", password: "password"} } // const getServiceClient = async (baseEntity, method, path, opt) => { const action = 'getServiceClient' let urlObj if (!path) path = '' try { urlObj = new URL(path) } catch (err) { // // path (no url) - default approach and client will be cached based on config // if (_serviceClient[baseEntity] && _serviceClient[baseEntity].accessToken) { // serviceClient already exist - Azure plugin specific scimgateway.logger.debug(`${pluginName}[${baseEntity}] ${action}: Using existing client`) // check if token refresh is needed const d = new Date() / 1000 // seconds (unix time) if (_serviceClient[baseEntity].accessToken.validTo < d + 30) { // less than 30 sec before token expiration scimgateway.logger.debug(`${pluginName}[${baseEntity}] ${action}: Accesstoken about to expire in ${_serviceClient[baseEntity].accessToken.validTo - d} seconds`) try { const accessToken = await getAccessToken(baseEntity) _serviceClient[baseEntity].accessToken = accessToken _serviceClient[baseEntity].options.headers.Authorization = ` Bearer ${accessToken.access_token}` } catch (err) { const newErr = err throw newErr } } } else { scimgateway.logger.debug(`${pluginName}[${baseEntity}] ${action}: Client have to be created`) let client = null if (config.entity && config.entity[baseEntity]) client = config.entity[baseEntity] if (!client) { const err = new Error(`Base URL have baseEntity=${baseEntity}, and configuration file ${pluginName}.json is missing required baseEntity configuration for ${baseEntity}`) throw err } // Azure plugin specific const accessToken = await getAccessToken(baseEntity) if (!config.entity[baseEntity].baseUrls) config.entity[baseEntity].baseUrls = [graphv1] // Azure plugin avoid config file and keep baseUrls logic urlObj = new URL(config.entity[baseEntity].baseUrls[0]) const param = { baseUrl: config.entity[baseEntity].baseUrls[0], accessToken: accessToken, // Azure plugin specific options: { json: true, // json-object response instead of string headers: { 'Content-Type': 'application/json', Authorization: ` Bearer ${accessToken.access_token}` }, host: urlObj.hostname, port: urlObj.port, // null if https and 443 defined in url protocol: urlObj.protocol // http: or https: // 'method' and 'path' added at the end } } // proxy if (config.entity[baseEntity].proxy && config.entity[baseEntity].proxy.host) { const agent = new HttpsProxyAgent(config.entity[baseEntity].proxy.host) param.options.agent = agent // proxy if (config.entity[baseEntity].proxy.username && config.entity[baseEntity].proxy.password) { param.options.headers['Proxy-Authorization'] = 'Basic ' + Buffer.from(`${config.entity[baseEntity].proxy.username}:${scimgateway.getPassword(`endpoint.entity.${baseEntity}.proxy.password`, configFile)}`).toString('base64') // using proxy with auth } } if (!_serviceClient[baseEntity]) _serviceClient[baseEntity] = {} _serviceClient[baseEntity] = param // serviceClient created // Azure plugin specific _serviceClient[baseEntity].nextLink = {} _serviceClient[baseEntity].nextLink.users = { skiptoken: null } // Azure users pagination _serviceClient[baseEntity].nextLink.groups = { skiptoken: null } // Azure groups pagination } const cli = scimgateway.copyObj(_serviceClient[baseEntity]) // client ready // failover support path = _serviceClient[baseEntity].baseUrl + path urlObj = new URL(path) cli.options.host = urlObj.hostname cli.options.port = urlObj.port cli.options.protocol = urlObj.protocol // adding none static cli.options.method = method cli.options.path = `${urlObj.pathname}${urlObj.search}` if (opt) cli.options = scimgateway.extendObj(cli.options, opt) // merge with argument options return cli // final client } // // url path - none config based and used as is (no cache) // scimgateway.logger.debug(`${pluginName}[${baseEntity}] ${action}: Using none config based client`) let options = { json: true, headers: { 'Content-Type': 'application/json' }, host: urlObj.hostname, port: urlObj.port, protocol: urlObj.protocol, method: method, path: urlObj.pathname } // proxy if (config.entity[baseEntity].proxy && config.entity[baseEntity].proxy.host) { const agent = new HttpsProxyAgent(config.entity[baseEntity].proxy.host) options.agent = agent // proxy if (config.entity[baseEntity].proxy.username && config.entity[baseEntity].proxy.password) { options.headers['Proxy-Authorization'] = 'Basic ' + Buffer.from(`${config.entity[baseEntity].proxy.username}:${scimgateway.getPassword(`endpoint.entity.${baseEntity}.proxy.password`, configFile)}`).toString('base64') // using proxy with auth } } // merge any argument options - support basic auth using {auth: {username: "username", password: "password"} } if (opt) { const o = scimgateway.copyObj(opt) if (o.auth) { options.headers.Authorization = 'Basic ' + Buffer.from(`${o.auth.username}:${o.auth.password}`).toString('base64') delete o.auth } options = scimgateway.extendObj(options, o) } const cli = {} cli.options = options return cli // final client } const updateServiceClient = (baseEntity, obj) => { if (_serviceClient[baseEntity]) _serviceClient[baseEntity] = scimgateway.extendObj(_serviceClient[baseEntity], obj) // merge with argument options } // // doRequest - execute REST service // const doRequest = async (baseEntity, method, path, body, opt, retryCount) => { try { const cli = await getServiceClient(baseEntity, method, path, opt) const options = cli.options const result = await new Promise((resolve, reject) => { let dataString = '' if (body) { if (options.headers['Content-Type'].toLowerCase() === 'application/x-www-form-urlencoded')