UNPKG

@keepsolutions/scimgateway

Version:

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

494 lines (441 loc) 21.1 kB
// ================================================================================= // File: plugin-loki.js // // Authors: Jarle Elshaug // Jeffrey Gilbert (visualjeff) // // Purpose: SCIM endpoint locally at the ScimGateway // - Demonstrate userprovisioning towards a document-oriented database // - Using LokiJS (http://lokijs.org) for a fast, in-memory document-oriented database with persistence // - Two predefined test users loaded when using in-memory only (no persistence) // - Supporting explore, create, delete, modify and list users (including groups) // // Supported attributes: // // GlobalUser Template Scim Endpoint // ------------------------------------------------------ // All attributes are supported, note multivalue "type" must be unique // // NOTE: Default configuration file setting {"persistence": false} gives an inMemory adapter for testing purposes // having two predifiend users loaded. Using {"persistence": true} gives an persistence file store located in // config directory with name according to configuration setting {"dbname": "loki.db"} and no no testusers loaded. // // LokiJS are well suited for handling large dataloads // // ================================================================================= 'use strict' const Loki = require('lokijs') // 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`) const validScimAttr = [] // empty array - all attrbutes are supported by endpoint let config = require(configFile).endpoint config = scimgateway.processExtConfig(pluginName, config) // add any external config process.env and process.file // mandatory plugin initialization - end // let endpointPasswordExample = scimgateway.getPassword('endpoint.password', configFile); // example how to encrypt configfile having "endpoint.password" var users var groups let dbname = (config.dbname ? config.dbname : 'loki.db') dbname = path.join(`${configDir}`, `${dbname}`) const db = new Loki(dbname, { env: 'NODEJS', autoload: config.persistence === true, autoloadCallback: loadHandler, autosave: config.persistence === true, autosaveInterval: 10000, // 10 seconds adapter: (config.persistence === true) ? new Loki.LokiFsAdapter() : new Loki.LokiMemoryAdapter() }) function loadHandler () { users = db.getCollection('users') if (users === null) { // if database do not exist it will be empty so intitialize here users = db.addCollection('users', { unique: ['id', 'userName'] }) } groups = db.getCollection('groups') if (groups === null) { groups = db.addCollection('groups', { unique: ['displayName'] }) } if (db.options.autoload === false) { // not using persistence (physical database) => load testusers scimgateway.testmodeusers.forEach(record => { if (record.meta) delete record.meta users.insert(record) }) scimgateway.testmodegroups.forEach(record => { groups.insert(record) }) } } if (db.options.autoload === false) loadHandler() // ================================================= // 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 } const usersArr = users.chain().data() if (!startIndex && !count) { // client request without paging startIndex = 1 count = usersArr.length if (count > 500) count = 500 } const arr = usersArr.map(obj => { return stripLoki(obj) }) // includes all user attributes but groups - user attribute groups automatically handled by scimgateway const usersDelta = arr.slice(startIndex - 1, startIndex - 1 + count) Array.prototype.push.apply(ret.Resources, usersDelta) ret.totalResults = usersDelta.length return ret // all explored users } // ================================================= // 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 } const groupsArr = groups.chain().data() if (!startIndex && !count) { // client request without paging startIndex = 1 count = groupsArr.length } const arr = groupsArr.map(obj => { return stripLoki(obj) }) // includes all groups attributes (also members) const groupsDelta = arr.slice(startIndex - 1, startIndex - 1 + count) Array.prototype.push.apply(ret.Resources, groupsDelta) ret.totalResults = groupsDelta.length return ret // all explored groups } // ================================================= // 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}`) const findObj = {} findObj[getObj.filter] = getObj.identifier // { userName: 'bjensen } / { externalId: 'bjensen } / { id: 'bjensen } / { 'emails.value': 'jsmith@example.com'} / { 'phoneNumbers.value': '555-555-5555'} const res = users.find(findObj) if (res.length !== 1) return null // no user, or more than one user found return stripLoki(res[0]) // includes all user attributes but groups - user attribute groups automatically handled by scimgateway } // ================================================= // createUser // ================================================= scimgateway.createUser = async (baseEntity, userObj) => { const action = 'createUser' scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" userObj=${JSON.stringify(userObj)}`) const notValid = scimgateway.notValidAttributes(userObj, validScimAttr) // We should check for unsupported endpoint attributes if (notValid) { const err = new Error(`unsupported scim attributes: ${notValid} ` + `(supporting only these attributes: ${validScimAttr.toString()})`) throw err } if (userObj.password) delete userObj.password // exclude password db not ecrypted for (var key in userObj) { if (!Array.isArray(userObj[key]) && scimgateway.isMultiValueTypes(key)) { // true if attribute is "type converted object" => convert to standard array const arr = [] for (var 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 } } userObj.id = userObj.userName // for loki-plugin (scim endpoint) id is mandatory and set to userName try { users.insert(userObj) } catch (err) { if (err.message && err.message.startsWith('Duplicate key')) { err.name = 'DuplicateKeyError' // gives scimgateway statuscode 409 instead of default 500 } throw err } return null } // ================================================= // deleteUser // ================================================= scimgateway.deleteUser = async (baseEntity, id) => { const action = 'deleteUser' scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" id=${id}`) const res = users.find({ id: id }) if (res.length !== 1) throw new Error(`Failed to delete user with id=${id}`) const userObj = res[0] users.remove(userObj) return null } // ================================================= // modifyUser // ================================================= scimgateway.modifyUser = async (baseEntity, id, attrObj) => { const action = 'modifyUser' scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" id=${id} attrObj=${JSON.stringify(attrObj)}`) const notValid = scimgateway.notValidAttributes(attrObj, validScimAttr) // We should check for unsupported endpoint attributes if (notValid) { const err = new Error(`unsupported scim attributes: ${notValid} ` + `(supporting only these attributes: ${validScimAttr.toString()})` ) throw err } if (attrObj.password) delete attrObj.password // exclude password db not ecrypted const res = users.find({ id: id }) if (res.length !== 1) throw new Error(`Could not find user with id=${id}`) const userObj = res[0] for (var key in attrObj) { if (Array.isArray(attrObj[key])) { // standard, not using type (e.g groups) attrObj[key].forEach(el => { if (el.operation === 'delete') { userObj[key] = userObj[key].filter(e => e.value !== el.value) if (userObj[key].length < 1) delete userObj[key] } else { // add if (!userObj[key]) userObj[key] = [] let exists if (el.value) exists = userObj[key].find(e => e.value && e.value === el.value) if (!exists) 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 (var el in attrObj[key]) { attrObj[key][el].type = el if (attrObj[key][el].operation && attrObj[key][el].operation === 'delete') { // delete multivalue let type = el if (type === 'undefined') type = undefined userObj[key] = userObj[key].filter(e => e.type !== type) if (userObj[key].length < 1) delete userObj[key] } else { // modify/create multivalue if (!userObj[key]) userObj[key] = [] var found = userObj[key].find((e, i) => { 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 { // name.familyName=Bianchi if (!userObj[key]) userObj[key] = {} // e.g name object does not exist for (var sub in attrObj[key]) { // attributes to be cleard located in meta.attributes eg: {"meta":{"attributes":["name.familyName","profileUrl","title"]} if (sub === 'attributes' && Array.isArray(attrObj[key][sub])) { attrObj[key][sub].forEach(element => { var arrSub = element.split('.') if (arrSub.length === 2) userObj[arrSub[0]][arrSub[1]] = '' // e.g. name.familyName else userObj[element] = '' }) } else { 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] } } } } } users.update(userObj) // needed for persistence return null } // ================================================= // 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}`) const findObj = {} findObj[getObj.filter] = getObj.identifier // { displayName: 'GroupA' } const res = groups.find(findObj) if (res.length !== 1) return null // no group found return stripLoki(res[0]) // includes all group attributes (also members) } // ================================================= // 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 = [] groups.data.forEach(el => { if (el.members) { const userFound = el.members.find(element => element.value === id) if (userFound) { let arrAttr = [] if (attributes) arrAttr = attributes.split(',') const userGroup = {} arrAttr.forEach(attr => { if (el[attr]) userGroup[attr] = el[attr] // id, displayName, members.value }) userGroup.members = [{ value: id }] // only includes current user (not all members) arrRet.push(userGroup) // { id: <id-group>> , displayName: <displayName-group>, members [{value: <id-user>}] } } } }) return arrRet } // ================================================= // getGroupUsers // ================================================= scimgateway.getGroupUsers = async (baseEntity, id, attributes) => { // 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 = [] users.data.forEach((user) => { if (user.groups) { user.groups.forEach((group) => { if (group.value === id) { arrRet.push( // {userName: "bjensen", groups: [{value: <group id>}]} - value only includes current group id { userName: user.userName, groups: [{ value: id }] } ) } }) } }) return arrRet } // ================================================= // createGroup // ================================================= scimgateway.createGroup = async (baseEntity, groupObj) => { const action = 'createGroup' scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" groupObj=${JSON.stringify(groupObj)}`) groupObj.id = groupObj.displayName // for loki-plugin (scim endpoint) id is mandatory and set to displayName groups.insert(groupObj) return null } // ================================================= // deleteGroup // ================================================= scimgateway.deleteGroup = async (baseEntity, id) => { const action = 'deleteGroup' scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" id=${id}`) const res = groups.find({ id: id }) if (res.length !== 1) throw new Error(`Failed to delete group with id=${id}`) const groupObj = res[0] groups.remove(groupObj) return null } // ================================================= // 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 res = groups.find({ id: id }) if (res.length !== 1) throw new Error(`Failed to find group with id=${id}`) const groupObj = res[0] if (!groupObj.members) groupObj.members = [] const usersNotExist = [] await attrObj.members.forEach(async el => { if (el.operation && el.operation === 'delete') { // delete member from group if (!el.value) groupObj.members = [] // members=[{"operation":"delete"}] => no value, delete all members else groupObj.members = groupObj.members.filter(element => element.value !== el.value) } else { // Add member to group if (el.value) { // check if user exist const usrObj = { filter: 'id', identifier: el.value } const usr = await scimgateway.getUser(baseEntity, usrObj, 'id') if (!usr) { usersNotExist.push(el.value) return } } var newMember = { display: el.value, value: el.value } let exists if (el.value) exists = groupObj.members.find(e => (el.value && e.value === el.value)) if (!exists) groupObj.members.push(newMember) } }) groups.update(groupObj) if (usersNotExist.length > 0) throw new Error(`can't use ${action} including none existing user(s): ${usersNotExist.toString()}`) return null } // ================================================= // helpers // ================================================= const stripLoki = (obj) => { // remove loki meta data and insert scim const retObj = JSON.parse(JSON.stringify(obj)) // new object - don't modify loki source if (retObj.meta) { if (retObj.meta.created) retObj.meta.created = new Date(retObj.meta.created).toISOString() delete retObj.meta.lastModified // test users loaded if (retObj.meta.updated) { retObj.meta.lastModified = new Date(retObj.meta.updated).toISOString() delete retObj.meta.updated } if (retObj.meta.revision !== undefined) { retObj.meta.version = retObj.meta.revision delete retObj.meta.revision } } delete retObj.$loki return retObj } // // Cleanup on exit // process.on('SIGTERM', () => { // kill db.close() }) process.on('SIGINT', () => { // Ctrl+C db.close() })