UNPKG

scimgateway

Version:

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

883 lines (807 loc) 35.7 kB
// ================================================================================= // File: plugin-mongodb.js // // Authors: Jarle Elshaug // Filipe Ribeiro (KEEP SOLUTIONS) // Miguel Ferreira (KEEP SOLUTIONS) // // Purpose: SCIM Gateway becomes a standalone SCIM endpoint // - Same as plugin-loki but using MongoDB // - Demonstrate userprovisioning towards local/remote MongoDB document-oriented database // - configuration "endpoint.entity" gives multi tenant or multi endpoint flexibilty through baseEntity in URL // - { "persistence": false } deletes any existing users/groups and loads predefined test users/groups // - baseUrl is mongodb connection uri without "username:password" // syntax: mongodb://host1[:port1][,...hostN[:portN]][/[defaultauthdb][?options]] // e.g: mongodb://localhost:27017/db?tls=true&tlsInsecure=true // // Supported attributes: // // GlobalUser Template Scim Endpoint // ------------------------------------------------------ // All attributes are supported, note multivalue "type" must be unique // // ================================================================================= import { MongoClient } from 'mongodb' // 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 scimgateway = new ScimGateway() const config = scimgateway.getConfig() scimgateway.authPassThroughAllowed = false // end - mandatory plugin initialization const validFilterOperators = ['eq', 'ne', 'aeq', 'dteq', 'gt', 'gte', 'lt', 'lte', 'between', 'jgt', 'jgte', 'jlt', 'jlte', 'jbetween', 'regex', 'in', 'nin', 'keyin', 'nkeyin', 'definedin', 'undefinedin', 'contains', 'containsAny', 'type', 'finite', 'size', 'len', 'exists'] async function loadHandler(baseEntity: string, ctx: undefined | Record<string, any>) { const action = 'loadHander' const clientIdentifier = getClientIdentifier(ctx) if (config.entity[baseEntity].isLoaded) { // loadHandler only once if (!clientIdentifier) return clientIdentifier // not using Auth PassThrough if (config.entity[baseEntity][clientIdentifier]) return clientIdentifier // authenticated throw new Error('{"error":"Access denied","statusCode":401}') // string: "statusCode":401 ensure gateway returns 401 } if (!config.entity[baseEntity].baseUrl) { // mongodb://host1[:port1][,...hostN[:portN]][/[defaultauthdb][?options]] - e.g: mongodb://localhost:27017/db?tls=true&tlsInsecure=true throw new Error(`${action} error: configuration entity.${baseEntity}.baseUrl is missing`) } const arr = config.entity[baseEntity].baseUrl.split('//') if (arr.length !== 2 || arr[0] !== 'mongodb:') throw new Error('error: configuration baseUrls is not using expected format mongodb://hostname:port') let username let password if (ctx?.request?.header?.authorization) { // Auth PassThrough const [user, secret] = getCtxAuth(ctx) if (user) username = user else username = config.entity[baseEntity].username // bearer token, using username from configuration password = secret } else { username = config.entity[baseEntity].username password = scimgateway.getSecret(`endpoint.entity.${baseEntity}.password`) } const dbConn = `${arr[0]}//${encodeURIComponent(username)}:${encodeURIComponent(password)}@${arr[1]}` // percent encoded username/password const client = new MongoClient(dbConn, { serverSelectionTimeoutMS: 5000 }) const dbName = config.entity[baseEntity].database ? config.entity[baseEntity].database : 'scim' let db let users let groups try { await client.connect() db = client.db(dbName) const clientIdentifier = getClientIdentifier(ctx) if (!config.entity[baseEntity][clientIdentifier]) config.entity[baseEntity][clientIdentifier] = {} config.entity[baseEntity][clientIdentifier].client = client config.entity[baseEntity].db = db if (await isMongoCollection(baseEntity, 'users')) users = await db.collection('users') else { users = await db.collection('users') users.createIndex({ id: 1 }, { unique: true }) } if (await isMongoCollection(baseEntity, 'groups')) groups = await db.collection('groups') else { groups = await db.collection('groups') groups.createIndex({ id: 1 }, { unique: true }) } } catch (error: any) { if (clientIdentifier && error.message.includes('Authentication')) { throw new Error('{"error":"Access denied","statusCode":401}') // string: "statusCode":401 ensure gateway returns 401 } throw new Error(`${action} error: failed to connect to database '${client.options.dbName}' - ${error.message}`) } if (config.entity[baseEntity].persistence === false && process.env.NODE_ENV !== 'production') { await dropMongoCollection(baseEntity, 'users') await dropMongoCollection(baseEntity, 'groups') try { users = await db.collection('users') users.createIndex({ id: 1 }, { unique: true }) groups = await db.collection('groups') groups.createIndex({ id: 1 }, { unique: true }) } catch (error: any) { throw new Error(`${action} error: failed to get collections for database '${client.options.dbName}' - ${error.message}`) } for (let record of scimgateway.getTestModeUsers()) { try { record = encodeDotDate(record) const now = Date.now() record.meta = { created: now, version: 0, } await users.insertOne(record) } catch (error: any) { throw new Error(`${action} error: failed to insert user for database '${client.options.dbName}' - ${error.message}`) } } for (let record of scimgateway.getTestModeGroups()) { try { record = encodeDotDate(record) const now = Date.now() record.meta = { created: now, version: 0, } await groups.insertOne(record) } catch (error: any) { throw new Error(`${action} error: failed to insert group for database '${client.options.dbName}' - ${error.message}`) } } } if (!config.entity[baseEntity][clientIdentifier]) config.entity[baseEntity][clientIdentifier] = {} config.entity[baseEntity][clientIdentifier].collection = {} config.entity[baseEntity][clientIdentifier].collection.users = users config.entity[baseEntity][clientIdentifier].collection.groups = groups config.entity[baseEntity].isLoaded = true return clientIdentifier } // ================================================= // getUsers // ================================================= scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => { const action = 'getUsers' scimgateway.logDebug(baseEntity, `handling ${action} getObj=${getObj ? JSON.stringify(getObj) : ''} attributes=${attributes}`) const clientIdentifier = await loadHandler(baseEntity, ctx) // includes Auth PassThrough logic and loaded only once if (getObj.operator) { // convert to plugin supported syntax switch (getObj.operator) { case 'co': getObj.operator = '$regex' getObj.value = new RegExp(`.*${getObj.value}.*`) break case 'ge': getObj.operator = '$gte' break case 'le': getObj.operator = '$lte' break case 'sw': getObj.operator = '$regex' getObj.value = new RegExp(`^${getObj.value}.*`) break case 'ew': getObj.operator = '$regex' getObj.value = new RegExp(`.*${getObj.value}$`) break default: if (!validFilterOperators.includes(getObj.operator)) { const err = new Error(`${action} error: filter operator '${getObj.operator}' is not valid, valid operators for this endpoint are: ${validFilterOperators}` + ',co,ge,le,sw,ew') err.name = 'invalidFilter' // maps to scimType error handling throw err } getObj.operator = '$' + getObj.operator } } const users = config.entity[baseEntity][clientIdentifier].collection.users let findObj: any // mandatory if-else logic - start if (getObj.operator) { // note, using prefix '$' 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 findObj = {} if (getObj.attribute === 'id') findObj[getObj.attribute] = getObj.value else findObj[getObj.attribute] = new RegExp(`^${getObj.value}$`, 'i') // case insensitive } 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 findObj = { groups: { value: getObj.value } } } else { // optional - simpel filtering const dt = Date.parse(getObj.value) if (!isNaN(dt)) { // date string to timestamp getObj.value = dt } findObj = {} findObj[getObj.attribute] = {} findObj[getObj.attribute][getObj.operator] = getObj.value } } 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 findObj = {} } // mandatory if-else logic - end if (!findObj) throw new Error(`${action} error: mandatory if-else logic not fully implemented`) if (!getObj.startIndex) getObj.startIndex = 1 if (!getObj.count) getObj.count = 200 const ret = { Resources: [], totalResults: null, } try { const projection = attributes.length > 0 ? getProjectionFromAttributes(attributes) : { _id: 0 } const usersArr: Record<string, any>[] = await users.find(findObj, { projection: projection }).sort({ _id: 1 }).skip(getObj.startIndex - 1).limit(getObj.count).toArray() const totalResults = await users.countDocuments(findObj, { projection: projection }) const arr = usersArr.map((obj) => { const o = decodeDotDate(obj) if (o.meta && o.meta.version !== undefined) { o.meta.version = `W/"${o.meta.version}"` } return o }) // virtual attribute groups automatically handled by scimgateway Array.prototype.push.apply(ret.Resources, arr) ret.totalResults = totalResults 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 clientIdentifier = await loadHandler(baseEntity, ctx) // includes Auth PassThrough logic and loaded only once if (userObj.password) delete userObj.password // exclude password db not ecrypted for (const key in userObj) { if (!Array.isArray(userObj[key]) && scimgateway.isMultiValueTypes(key)) { // true if attribute is "type converted object" => convert to standard array const arr: string[] = [] for (const el in userObj[key]) { userObj[key][el].type = el if (el === 'undefined') delete userObj[key][el].type // type "undefined" reverted back to original blank arr.push(userObj[key][el]) // create } userObj[key] = arr } } if (userObj.userName) userObj.id = userObj.userName // id set to userName or externalId else if (userObj.externalId) userObj.id = userObj.externalId else throw new Error(`${action} error: missing mandatory userName or externalId`) if (!userObj.meta) { const now = Date.now() userObj.meta = { version: 0, created: now, lastModified: now, } } userObj = encodeDotDate(userObj) try { const users = config.entity[baseEntity][clientIdentifier].collection.users await users.insertOne(userObj) return null } catch (err: any) { const newErr = new Error(`${action} error: ${err.message}`) if (err.message && err.message.includes('duplicate key')) { newErr.name += '#409' // customErrorCode } throw newErr } } // ================================================= // deleteUser // ================================================= scimgateway.deleteUser = async (baseEntity, id, ctx) => { const action = 'deleteUser' scimgateway.logDebug(baseEntity, `handling ${action} id=${id}`) const clientIdentifier = await loadHandler(baseEntity, ctx) // includes Auth PassThrough logic and loaded only once const users = config.entity[baseEntity][clientIdentifier].collection.users try { /* const now = Date.now() const userObj = { id: id, meta: { lastModified: now }, deleted: 1 } await users.replaceOne({ id: id }, userObj) // allowing none unique id, then do not use: users.createIndex({ id: 1 }, { unique: true }) */ await users.deleteOne({ id: id }) return null } catch (err: any) { throw new Error(`${action} error: failed for user id=${id} - ${err.message}`) } } // ================================================= // modifyUser // ================================================= scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => { const action = 'modifyUser' scimgateway.logDebug(baseEntity, `handling ${action} id=${id} attrObj=${JSON.stringify(attrObj)}`) const clientIdentifier = await loadHandler(baseEntity, ctx) // includes Auth PassThrough logic and loaded only once if (attrObj.password) delete attrObj.password // exclude password db not ecrypted let res try { const users = config.entity[baseEntity][clientIdentifier].collection.users res = await users.find({ id }, { projection: { _id: 0 } }).toArray() if (res.length === 0) throw new Error('user does not exist') if (res.length > 1) throw new Error('user is not unique, more than one have been found') } catch (error: any) { throw new Error(`${action} error: could not find user with id=${id} - ${error.message}`) } let userObj = decodeDotDate(res[0]) for (const key in attrObj) { if (Array.isArray(attrObj[key])) { // standard, not using type (e.g roles/groups) or skipTypeConvert=true const delArr = attrObj[key].filter(el => el.operation === 'delete') const addArr = attrObj[key].filter(el => (!el.operation || el.operation !== 'delete')) if (!userObj[key] || !Array.isArray(userObj[key])) userObj[key] = [] // delete userObj[key] = userObj[key].filter((el: Record<string, any>) => { const index = delArr.findIndex((e) => { let elExist = false for (const k in el) { if (k === 'primary') continue if (el[k] !== e[k]) { elExist = false break } elExist = true } return elExist }) if (index >= 0) return false else return true }) // add addArr.forEach((el) => { if (Object.prototype.hasOwnProperty.call(el, 'primary')) { if (el.primary === true || (typeof el.primary === 'string' && el.primary.toLowerCase() === 'true')) { const index = userObj[key].findIndex((e: Record<string, any>) => e.primary === el.primary) if (index >= 0) { if (key === 'roles') userObj[key].splice(index, 1) // roles, delete existing role having primary attribute true (new role with primary will be added) else userObj[key][index].primary = undefined // remove primary attribute, only one primary } } } const index = userObj[key].findIndex((e: Record<string, any>) => { // avoid adding existing let elExist = false for (const k in el) { if (k === 'primary') continue if (el[k] !== e[k]) { elExist = false break } elExist = true } return elExist }) if (index < 0) userObj[key].push(el) }) } else if (scimgateway.isMultiValueTypes(key)) { // "type converted object" logic and original blank type having type "undefined" if (!attrObj[key]) delete userObj[key] // blank or null for (const el in attrObj[key]) { attrObj[key][el].type = el if (attrObj[key][el].operation && attrObj[key][el].operation === 'delete') { // delete multivalue let type: any = el if (type === 'undefined') type = undefined userObj[key] = userObj[key].filter((e: Record<string, any>) => e.type !== type) if (userObj[key].length < 1) delete userObj[key] } else { // modify/create multivalue if (!userObj[key]) userObj[key] = [] if (attrObj[key][el].primary) { // remove any existing primary attribute, should only have one primary set const primVal = attrObj[key][el].primary if (primVal === true || (typeof primVal === 'string' && primVal.toLowerCase() === 'true')) { const index = userObj[key].findIndex((e: Record<string, any>) => e.primary === primVal) if (index >= 0) { userObj[key][index].primary = undefined } } } const found = userObj[key].find((e: Record<string, any>, i: any) => { if (e.type === el || (!e.type && el === 'undefined')) { for (const k in attrObj[key][el]) { userObj[key][i][k] = attrObj[key][el][k] if (k === 'type' && attrObj[key][el][k] === 'undefined') delete userObj[key][i][k] // don't store with type "undefined" } return true } else return false }) if (attrObj[key][el].type && attrObj[key][el].type === 'undefined') delete attrObj[key][el].type // don't store with type "undefined" if (!found) userObj[key].push(attrObj[key][el]) // create } } } else { // None multi value attribute if (typeof (attrObj[key]) !== 'object' || attrObj[key] === null) { if (attrObj[key] === '' || attrObj[key] === null) delete userObj[key] else userObj[key] = attrObj[key] } else { // None multi value attribute, blank will be deleted if (typeof (attrObj[key]) === 'object' && attrObj[key] !== null) { // name.familyName=Bianchi if (!userObj[key]) userObj[key] = {} // e.g name object does not exist for (const sub in attrObj[key]) { if (!userObj[key]) userObj[key] = {} if (Object.prototype.hasOwnProperty.call(attrObj[key][sub], 'value') && attrObj[key][sub].value === '') delete userObj[key][sub] // object having blank value attribute e.g. {"manager": {"value": "",...}} else if (attrObj[key][sub] === '') delete userObj[key][sub] else { if (!userObj[key]) userObj[key] = {} // may have been deleted by length check below userObj[key][sub] = attrObj[key][sub] } if (Object.keys(userObj[key]).length < 1) delete userObj[key] } } else { if (attrObj[key] === '') delete userObj[key] else userObj[key] = attrObj[key] } } } } if (!userObj.meta) { const now = Date.now() userObj.meta = { version: 0, created: now, lastModified: now, } } else { const now = Date.now() userObj.meta.lastModified = now userObj.meta.version += 1 } userObj = encodeDotDate(userObj) try { const users = config.entity[baseEntity][clientIdentifier].collection.users await users.replaceOne({ id: id }, userObj) return null } catch (err: any) { throw new Error(`${action} error: failed for user id=${id} - ${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 clientIdentifier = await loadHandler(baseEntity, ctx) // includes Auth PassThrough logic and loaded only once if (getObj.operator) { // convert to plugin supported syntax switch (getObj.operator) { case 'co': getObj.operator = '$regex' getObj.value = new RegExp(`.*${getObj.value}.*`) break case 'ge': getObj.operator = '$gte' break case 'le': getObj.operator = '$lte' break case 'sw': getObj.operator = '$regex' getObj.value = new RegExp(`^${getObj.value}.*`) break case 'ew': getObj.operator = '$regex' getObj.value = new RegExp(`.*${getObj.value}$`) break default: if (!validFilterOperators.includes(getObj.operator)) { const err = new Error(`${action} error: filter operator '${getObj.operator}' is not valid, valid operators for this endpoint are: ${validFilterOperators}` + ',co,ge,le,sw,ew') err.name = 'invalidFilter' // maps to scimType error handling throw err } getObj.operator = '$' + getObj.operator } } let findObj: any // mandatory if-else logic - start if (getObj.operator) { // note, loki using prefix '$' if (getObj.operator === '$eq' && ['id', 'displayName', 'externalId'].includes(getObj.attribute)) { // mandatory - unique filtering - single unique group to be returned - correspond to getGroup() in versions < 4.x.x findObj = {} if (getObj.attribute === 'id') findObj[getObj.attribute] = getObj.value else findObj[getObj.attribute] = new RegExp(`^${getObj.value}$`, 'i') // case insensitive } 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>}] }] findObj = { members: { $elemMatch: { value: getObj.value } } } } else { // optional - simpel filtering const dt = Date.parse(getObj.value) if (!isNaN(dt)) { // date string to timestamp getObj.value = dt } findObj = {} findObj[getObj.attribute] = {} findObj[getObj.attribute][getObj.operator] = getObj.value } } 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 exploreUsers() in versions < 4.x.x findObj = {} } // mandatory if-else logic - end if (!findObj) throw new Error(`${action} error: mandatory if-else logic not fully implemented`) if (!getObj.startIndex) getObj.startIndex = 1 if (!getObj.count) getObj.count = 200 const ret = { Resources: [], totalResults: null, } try { const projection = attributes.length > 0 ? getProjectionFromAttributes(attributes) : { _id: 0 } const groups = config.entity[baseEntity][clientIdentifier].collection.groups const groupsArr: Record<string, any>[] = await groups.find(findObj, { projection: projection }).sort({ _id: 1 }).skip(getObj.startIndex - 1).limit(getObj.count).toArray() const totalResults = await groups.countDocuments(findObj, { projection: projection }) const arr = groupsArr.map((obj) => { return decodeDotDate(obj) }) Array.prototype.push.apply(ret.Resources, arr) ret.totalResults = totalResults 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 clientIdentifier = await loadHandler(baseEntity, ctx) // includes Auth PassThrough logic and loaded only once if (!groupObj.meta) { const now = Date.now() groupObj.meta = { version: 0, created: now, lastModified: now, } } if (groupObj.externalId) groupObj.id = groupObj.externalId // for loki-plugin (scim endpoint) id is mandatory and set to displayName else groupObj.id = groupObj.displayName groupObj = encodeDotDate(groupObj) if (groupObj.members) { const noneExistingUsers: any = [] await Promise.all(groupObj.members.map(async (el: any) => { if (el.value) { const getObj = { attribute: 'id', operator: 'eq', value: el.value } const usrs = await scimgateway.getUsers(baseEntity, getObj, ['id', 'displayName'], ctx) // check if user exist if (!usrs || !usrs.Resources || usrs.Resources.length !== 1 || usrs.Resources[0].id !== el.value) { noneExistingUsers.push(el.value) } else if (usrs.Resources[0].displayName) { el.display = usrs.Resources[0].displayName } } })) if (noneExistingUsers.length > 0) { throw new Error(`following user(s) does not exist and can't be member of group: ${noneExistingUsers.join(', ')}`) } } try { const groups = config.entity[baseEntity][clientIdentifier].collection.groups await groups.insertOne(groupObj) return null } catch (err: any) { const newErr = new Error(`${action} error: ${err.message}`) if (err.message && err.message.includes('duplicate key')) { newErr.name += '#409' // customErrorCode } throw newErr } } // ================================================= // deleteGroup // ================================================= scimgateway.deleteGroup = async (baseEntity, id, ctx) => { const action = 'deleteGroup' scimgateway.logDebug(baseEntity, `handling ${action} id=${id}`) const clientIdentifier = await loadHandler(baseEntity, ctx) // includes Auth PassThrough logic and loaded only once const groups = config.entity[baseEntity][clientIdentifier].collection.groups try { /* const now = Date.now() const groupObj = { id: id, meta: { lastModified: now }, deleted: 1 } await groups.replaceOne({ id: id }, groupObj) // allowing none unique id, then do not use: groups.createIndex({ id: 1 }, { unique: true }) */ await groups.deleteOne({ id: id }) return null } catch (err: any) { throw new Error(`${action} error: failed for id=${id} - ${err.message}`) } } // ================================================= // modifyGroup // ================================================= scimgateway.modifyGroup = async (baseEntity, id, attrObj, ctx) => { const action = 'modifyGroup' scimgateway.logDebug(baseEntity, `handling ${action} id=${id} attrObj=${JSON.stringify(attrObj)}`) const clientIdentifier = await loadHandler(baseEntity, ctx) // includes Auth PassThrough logic and loaded only once const users = config.entity[baseEntity][clientIdentifier].collection.users const groups = config.entity[baseEntity][clientIdentifier].collection.groups let res let isModified = false try { res = await groups.find({ id: id }, { projection: { _id: 0 } }).toArray() if (res.length === 0) throw new Error('group does not exist') if (res.length > 1) throw new Error('group is not unique, more than one have been found') } catch (err: any) { throw new Error(`${action} error: group id=${id} - ${err.message}`) } let groupObj: any = decodeDotDate(res[0]) if (!groupObj.members) groupObj.members = [] const usersNotExist: string[] = [] if (attrObj.members) { if (!Array.isArray(attrObj.members)) { throw new Error(`${action} error: ${JSON.stringify(attrObj)} - correct syntax is { "members": [...] }`) } for (const el of attrObj.members) { if (el.operation && el.operation === 'delete') { // delete member from group if (!el.value) { // members=[{"operation":"delete"}] => no value, delete all members await groups.updateOne({ id: groupObj.id }, { $set: { members: [] } }) scimgateway.logDebug(baseEntity, `handling ${action} id=${id} deleted all members`) isModified = true } else { await groups.updateMany({ id: groupObj.id }, { $pull: { members: { value: el.value } } }) scimgateway.logDebug(baseEntity, `handling ${action} id=${id} deleted from group: ${el.value}`) isModified = true } } else { // Add member to group if (el.value) { let usrs: any = [] try { usrs = await users.find({ id: el.value }, { projection: { _id: 0 } }).toArray() // check if user exist } catch (err: any) { throw new Error(`${action} error: failed to find group id=${id} - ${err.message}`) } if (usrs.length === 1 && usrs[0].id === el.value) { if (!groupObj.members.some((element: Record<string, any>) => element.value === el.value)) { await groups.updateMany({ id: groupObj.id }, { $push: { members: { display: usrs[0].displayName || el.value, value: el.value } } }) scimgateway.logDebug(baseEntity, `handling ${action} id=${id} added member to group: ${el.value}`) isModified = true } } else usersNotExist.push(el.value) } } } } delete attrObj.members if (Object.keys(attrObj).length > 0) { // displayName/externalId await groups.updateOne({ id: groupObj.id }, { $set: attrObj }) isModified = true } if (!groupObj.meta) { const now = Date.now() groupObj.meta = { version: 0, created: now, lastModified: now, } } else { const now = Date.now() groupObj.meta.lastModified = now groupObj.meta.version += 1 } groupObj = encodeDotDate(groupObj) try { if (isModified) await groups.updateOne({ id: groupObj.id }, { $set: { meta: groupObj.meta } }) if (usersNotExist.length > 0) throw new Error(`includes none existing users: ${usersNotExist.toString()}`) return null } catch (err: any) { throw new Error(`${action} error: failed for id=${groupObj.id} - ${err.message}`) } } // ================================================= // helpers // ================================================= const getClientIdentifier = (ctx: undefined | Record<string, any>) => { if (!ctx?.request?.header?.authorization) return 'undefined' const [user, secret] = getCtxAuth(ctx) return `${encodeURIComponent(user)}_${encodeURIComponent(secret)}` // user_password or undefined_password } // // getCtxAuth returns username/secret from ctx header when using Auth PassThrough // const getCtxAuth = (ctx: undefined | Record<string, any>) => { if (!ctx?.request?.header?.authorization) return [] const [authType, authToken] = (ctx.request.header.authorization || '').split(' ') // [0] = 'Basic' or 'Bearer' let username, password if (authType === 'Basic') [username, password] = (Buffer.from(authToken, 'base64').toString() || '').split(':') if (username) return [username, password] // basic auth else return [undefined, authToken] // bearer auth } const decodeDotDate = (obj: Record<string, any>) => { // replace dot with unicode const retObj = JSON.parse(JSON.stringify(obj)) // new object - don't modify source Object.keys(retObj).forEach(function (key) { if (key.includes('·')) { retObj[key.replace(/·/g, '.')] = retObj[key] delete retObj[key] } }) if (retObj.meta) { // date string to timestamp if (retObj.meta.created) retObj.meta.created = new Date(retObj.meta.created).toISOString() if (retObj.meta.lastModified) retObj.meta.lastModified = new Date(retObj.meta.lastModified).toISOString() } return retObj } const encodeDotDate = (obj: Record<string, any>) => { const retObj = JSON.parse(JSON.stringify(obj)) // new object - don't modify source if (retObj._id) delete retObj._id Object.keys(retObj).forEach(function (key) { // replace dot with unicode if (key.includes('.')) { retObj[key.replace(/\./g, '·')] = retObj[key] delete retObj[key] } }) if (retObj.meta) { // date string to timestamp if (retObj.meta.created) { const dt = Date.parse(retObj.meta.created) if (!isNaN(dt)) { retObj.meta.created = dt } } if (retObj.meta.lastModified) { const dt = Date.parse(retObj.meta.lastModified) if (!isNaN(dt)) { retObj.meta.lastModified = dt } } } return retObj } function getProjectionFromAttributes(attributes: Array<string>) { const projection: any = {} attributes.forEach((attr) => { projection[attr] = 1 }) return projection } async function isMongoCollection(baseEntity: string, collection: string) { try { if (!config.entity[baseEntity].db.listCollections) return false const colls = await config.entity[baseEntity].db.listCollections({ name: collection }).toArray() if (colls.length === 1) return true return false } catch (error: any) { throw new Error(`Failed to check collection '${collection}' - ${error.message}`) } } async function dropMongoCollection(baseEntity: string, collection: string) { try { if (await isMongoCollection(baseEntity, collection)) { await config.entity[baseEntity].db.dropCollection(collection) } } catch (error: any) { throw new Error(`Failed to drop collection '${collection}' - ${error.message}`) } } // // Cleanup on exit // process.on('SIGTERM', async () => { // kill for (const baseEntity in config.entity) { for (const key in config.entity[baseEntity]) { if (config.entity[baseEntity][key].client && config.entity[baseEntity][key].client.topology) { await config.entity[baseEntity][key].client.close() } } } }) process.on('SIGINT', async () => { // Ctrl+C for (const baseEntity in config.entity) { for (const key in config.entity[baseEntity]) { if (config.entity[baseEntity][key].client && config.entity[baseEntity][key].client.topology) { await config.entity[baseEntity][key].client.close() } } } }) // connect MongoDb and load users/groups if (!config.entity) throw new Error('error: configuration entity is missing') if (!scimgateway.authPassThroughAllowed) { // not using Auth PassThrough, loading db handler at startup using username/password from config for (const baseEntity in config.entity) { try { await loadHandler(baseEntity, undefined) } catch (err: any) { scimgateway.logError(baseEntity, err.message) } } }