scimgateway 4.5.11 → 4.5.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1163,6 +1163,12 @@ MIT © [Jarle Elshaug](https://www.elshaug.xyz)
1163
1163
 
1164
1164
  ## Change log
1165
1165
 
1166
+ ### v4.5.12
1167
+
1168
+ [Improved]
1169
+
1170
+ - plugin-ldap, new configuration { allowModifyDN: true } allows DN being changed based on modified mapping or namingAttribute
1171
+
1166
1172
  ### v4.5.11
1167
1173
 
1168
1174
  [Improved]
@@ -144,16 +144,17 @@
144
144
  "groupBase": "OU=Groups,DC=test,DC=com",
145
145
  "userFilter": null,
146
146
  "groupFilter": null,
147
+ "allowModifyDN": false,
147
148
  "namingAttribute": {
148
149
  "user": [
149
150
  {
150
- "attribute": "CN",
151
+ "attribute": "cn",
151
152
  "mapTo": "userName"
152
153
  }
153
154
  ],
154
155
  "group": [
155
156
  {
156
- "attribute": "CN",
157
+ "attribute": "cn",
157
158
  "mapTo": "displayName"
158
159
  }
159
160
  ]
@@ -35,6 +35,8 @@
35
35
  // be used for national characters and special characters in DN.
36
36
  // For Active Directory, default isOpenLdap=false should be used
37
37
  //
38
+ // Configuration allowModifyDN=true allows DN being changed based on modified mapping or namingAttribute
39
+ //
38
40
  // Attributes according to map definition in the configuration file plugin-ldap.json:
39
41
  //
40
42
  // GlobalUser Template Scim Endpoint
@@ -466,7 +468,41 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
466
468
  }
467
469
 
468
470
  try {
471
+ const newDN = checkIfNewDN(baseEntity, base, 'user', attrObj, endpointObj)
469
472
  await doRequest(baseEntity, method, base, ldapOptions, ctx)
473
+ if (newDN && config.entity[baseEntity].ldap.allowModifyDN) {
474
+ // modify DN
475
+ await doRequest(baseEntity, 'modifyDN', base, { modification: { newDN } }, ctx)
476
+ // clean up zoombie group members and use the new user DN incase not handled by ldap server
477
+ const [memberAttr] = scimgateway.endpointMapper('outbound', 'members.value', config.map.group)
478
+ if (memberAttr) {
479
+ const grp = { add: {}, remove: {} }
480
+ grp.add[memberAttr] = []
481
+ grp.remove[memberAttr] = []
482
+ let r
483
+ try {
484
+ const ob = { attribute: 'members.value', operator: 'eq', value: base } // base is old DN
485
+ const attributes = ['id', 'displayName']
486
+ r = await scimgateway.getGroups(baseEntity, ob, attributes, ctx)
487
+ } catch (err) { } // ignore errors incase method not implemented
488
+ if (r && r.Resources && Array.isArray(r.Resources) && r.Resources.length > 0) {
489
+ for (let i = 0; i < r.Resources.length; i++) {
490
+ if (!r.Resources[i].id) continue
491
+ const grpId = decodeURIComponent(r.Resources[i].id)
492
+ grp.remove[memberAttr] = [base]
493
+ grp.add[memberAttr] = [newDN]
494
+ await Promise.all([
495
+ doRequest(baseEntity, method, grpId, { operation: 'add', modification: grp.add }, ctx),
496
+ doRequest(baseEntity, method, grpId, { operation: 'delete', modification: grp.remove }, ctx)
497
+ ])
498
+ }
499
+ }
500
+ // return full user object to avoid scimgateway doing same getUser() using original id/dn that now will fail
501
+ const getObj = { attribute: 'id', operator: 'eq', value: newDN }
502
+ const res = await scimgateway.getUsers(baseEntity, getObj, [], ctx)
503
+ return res
504
+ }
505
+ }
470
506
  return null
471
507
  } catch (err) {
472
508
  throw new Error(`${action} error: ${err.message}`)
@@ -740,6 +776,8 @@ scimgateway.modifyGroup = async (baseEntity, id, attrObj, ctx) => {
740
776
  try {
741
777
  delete attrObj.members
742
778
  const [endpointObj] = scimgateway.endpointMapper('outbound', attrObj, config.map.group)
779
+ const newDN = checkIfNewDN(baseEntity, base, 'group', attrObj, endpointObj)
780
+
743
781
  if (Object.keys(endpointObj).length > 0) {
744
782
  const ldapOptions = {
745
783
  operation: 'replace',
@@ -761,6 +799,12 @@ scimgateway.modifyGroup = async (baseEntity, id, attrObj, ctx) => {
761
799
  }
762
800
  await doRequest(baseEntity, method, base, ldapOptions, ctx)
763
801
  }
802
+ if (newDN && config.entity[baseEntity].ldap.allowModifyDN) {
803
+ await doRequest(baseEntity, 'modifyDN', base, { modification: { newDN } }, ctx)
804
+ const getObj = { attribute: 'id', operator: 'eq', value: newDN }
805
+ const res = await scimgateway.getGroups(baseEntity, getObj, [], ctx)
806
+ return res // return full group object to avoid scimgateway doing same getUser() using original id/dn that now will fail
807
+ }
764
808
  return null
765
809
  } catch (err) {
766
810
  throw new Error(`${action} error: ${err.message}`)
@@ -1044,7 +1088,7 @@ const getMemberOfGroups = async (baseEntity, id, ctx) => {
1044
1088
  // using Active Directory (none OpenLDAP), DN should not be escaped, but DN retrieved from AD is character escaped
1045
1089
  //
1046
1090
  const ldapEscDn = (isOpenLdap, str) => {
1047
- if (!str) return str
1091
+ if (typeof str !== 'string' || str.length < 1) return str
1048
1092
 
1049
1093
  if (!isOpenLdap && str.indexOf('\\') > 0) {
1050
1094
  const conv = str.replace(/\\([0-9A-Fa-f]{2})/g, (_, hex) => {
@@ -1200,6 +1244,10 @@ const berDecodeDn = (dn) => {
1200
1244
  }
1201
1245
  }
1202
1246
  if (decoded.length > 0) {
1247
+ // convert any extended ascii to utf8
1248
+ const isoBytes = Uint8Array.from(decoded, c => c.charCodeAt(0))
1249
+ decoded = new TextDecoder('utf-8').decode(isoBytes)
1250
+ decoded = decoded.replace(/,/g, '\\,')
1203
1251
  a.splice(0, 1) // remove element 0 from array
1204
1252
  return `${arr[0]}${decoded},${a.join(',')}` // OU=users,OU=abc
1205
1253
  }
@@ -1224,6 +1272,51 @@ const getNamingAttribute = (baseEntity, type) => {
1224
1272
  return [arr[0].attribute, arr[0].mapTo]
1225
1273
  }
1226
1274
 
1275
+ const checkIfNewDN = (baseEntity, base, type, obj, endpointObj) => {
1276
+ if (typeof obj !== 'object' || Object.keys(obj).length < 1) return ''
1277
+ if (typeof endpointObj !== 'object' || Object.keys(endpointObj).length < 1) return ''
1278
+
1279
+ const namingAttr = base.split('=')[0] // cn
1280
+ let scimAttr = ''
1281
+ if (endpointObj[namingAttr]) { // naming attribute can't be modified, have to use modifyDN()
1282
+ delete endpointObj[namingAttr] // modifying original ldapOptions
1283
+ if (config.map[type] && config.map[type][namingAttr]) {
1284
+ scimAttr = config.map[type][namingAttr].mapTo
1285
+ }
1286
+ if (!config.entity[baseEntity].ldap.allowModifyDN) {
1287
+ throw new Error(`changing ldap Naming Attribute ${namingAttr}/${scimAttr} requires configuration ldap.allowModifyDN=true`)
1288
+ }
1289
+ }
1290
+ if (!scimAttr) { // check if namingAttr is defined as namingAttribute configuration having linked scimAttr
1291
+ const [nAttr, sAttr] = getNamingAttribute(baseEntity, type) // ['cn', 'userName']
1292
+ if (namingAttr === nAttr) scimAttr = sAttr
1293
+ }
1294
+ if (!scimAttr) return ''
1295
+ // find and return the new DN
1296
+ let newNamingValue
1297
+ const arr = scimAttr.split('.')
1298
+ if (arr.length < 2) {
1299
+ if (obj[scimAttr]) newNamingValue = obj[scimAttr]
1300
+ } else {
1301
+ if (obj[arr[0]] && obj[arr[0]][arr[1]]) newNamingValue = obj[arr[0]][arr[1]]
1302
+ }
1303
+ if (!newNamingValue) return ''
1304
+ const re = '^([a-zA-Z]+=)(.*?)(?=,[a-zA-Z]+=|$)(.*)$'
1305
+ const rePattern = new RegExp(re, 'i')
1306
+ const a = base.match(rePattern)
1307
+ /*
1308
+ a[1] 'CN='
1309
+ a[2] '<value>'
1310
+ a[3] '<rest> e.g.,: ,OU=mycompany,OU=com'
1311
+ */
1312
+ if (a.length !== 4) return ''
1313
+ if (a[1].toLowerCase() !== namingAttr.toLowerCase() + '=') return ''
1314
+ if (a[2] === newNamingValue) return ''
1315
+ let newDN = a[1] + newNamingValue + a[3]
1316
+ newDN = ldapEscDn(config.entity[baseEntity].ldap.isOpenLdap, newDN)
1317
+ return newDN
1318
+ }
1319
+
1227
1320
  //
1228
1321
  // getCtxAuth returns username/secret from ctx header when using Auth PassThrough
1229
1322
  //
@@ -1418,6 +1511,22 @@ const doRequest = async (baseEntity, method, base, options, ctx) => {
1418
1511
  })
1419
1512
  break
1420
1513
 
1514
+ case 'modifyDN':
1515
+ result = await new Promise((resolve, reject) => {
1516
+ let dn = base
1517
+ if (Object.prototype.toString.call(dn) === '[object LdapDn]') dn = base.toString() // needed for client.modifyDN...
1518
+ let newDN = options?.modification?.newDN
1519
+ if (!newDN) return reject(new Error('modifyDN() missing newDN'))
1520
+ if (Object.prototype.toString.call(newDN) === '[object LdapDn]') newDN = newDN.toString()
1521
+ client.modifyDN(dn, newDN, (err) => {
1522
+ if (err) {
1523
+ return reject(err)
1524
+ }
1525
+ resolve()
1526
+ })
1527
+ })
1528
+ break
1529
+
1421
1530
  case 'add':
1422
1531
  result = await new Promise((resolve, reject) => {
1423
1532
  client.add(base, options, (err) => {
@@ -274,7 +274,7 @@ const ScimGateway = function () {
274
274
  if (!fs.existsSync(configDir + '/wsdls')) fs.mkdirSync(configDir + '/wsdls')
275
275
  if (!fs.existsSync(configDir + '/certs')) fs.mkdirSync(configDir + '/certs')
276
276
  if (!fs.existsSync(configDir + '/schemas')) fs.mkdirSync(configDir + '/schemas')
277
- } catch (err) {}
277
+ } catch (err) { }
278
278
 
279
279
  let isScimv2 = false
280
280
  if (config.scim.version === '2.0' || config.scim.version === 2) {
@@ -1488,6 +1488,7 @@ const ScimGateway = function () {
1488
1488
  }
1489
1489
  }
1490
1490
  try {
1491
+ let res
1491
1492
  if (config.stream.publisher.enabled) {
1492
1493
  let streamObj = {
1493
1494
  handle: handle.modifyMethod,
@@ -1508,14 +1509,14 @@ const ScimGateway = function () {
1508
1509
  }
1509
1510
  }
1510
1511
  logger.debug(`${gwName}[${pluginName}][${ctx?.params?.baseEntity}] publishing "${handle.modifyMethod}" to SCIM Stream and awaiting result`)
1511
- await this.publish(streamObj)
1512
+ res = await this.publish(streamObj)
1512
1513
  } else {
1513
1514
  if (Array.isArray(scimdata.members) && scimdata.members.length === 0 && handle.modifyMethod === 'modifyGroup') {
1514
1515
  ctx.request.body = scimdata
1515
- await replaceUsrGrp(ctx, config.scim.usePutSoftSync)
1516
+ res = await replaceUsrGrp(ctx, config.scim.usePutSoftSync)
1516
1517
  } else {
1517
1518
  logger.debug(`${gwName}[${pluginName}][${ctx?.params?.baseEntity}] calling "${handle.modifyMethod}" and awaiting result`)
1518
- await this[handle.modifyMethod](ctx.params.baseEntity, id, scimdata, ctx.passThrough)
1519
+ res = await this[handle.modifyMethod](ctx.params.baseEntity, id, scimdata, ctx.passThrough)
1519
1520
  }
1520
1521
  }
1521
1522
 
@@ -1545,28 +1546,27 @@ const ScimGateway = function () {
1545
1546
  }
1546
1547
  }
1547
1548
 
1548
- // include full object in response
1549
- // TODO: include groups
1550
- if (handle.getMethod !== handler.users.getMethod && handle.getMethod !== handler.groups.getMethod && !config.stream.publisher.enabled) { // getUsers or getGroups not implemented
1551
- ctx.status = 204
1552
- return
1553
- }
1554
- let res
1555
- const ob = { attribute: 'id', operator: 'eq', value: id }
1556
- const attributes = ctx.query.attributes ? ctx.query.attributes.split(',').map(item => item.trim()) : []
1557
- if (config.stream.publisher.enabled) {
1558
- const streamObj = {
1559
- handle: handle.getMethod,
1560
- baseEntity: ctx.params.baseEntity,
1561
- obj: ob,
1562
- attributes,
1563
- ctxPassThrough: ctx.passThrough
1549
+ if (!res) { // include full object in response, TODO: include groups
1550
+ if (handle.getMethod !== handler.users.getMethod && handle.getMethod !== handler.groups.getMethod && !config.stream.publisher.enabled) { // getUsers or getGroups not implemented
1551
+ ctx.status = 204
1552
+ return
1553
+ }
1554
+ const ob = { attribute: 'id', operator: 'eq', value: id }
1555
+ const attributes = ctx.query.attributes ? ctx.query.attributes.split(',').map(item => item.trim()) : []
1556
+ if (config.stream.publisher.enabled) {
1557
+ const streamObj = {
1558
+ handle: handle.getMethod,
1559
+ baseEntity: ctx.params.baseEntity,
1560
+ obj: ob,
1561
+ attributes,
1562
+ ctxPassThrough: ctx.passThrough
1563
+ }
1564
+ logger.debug(`${gwName}[${pluginName}][${ctx?.params?.baseEntity}] publishing "${handle.getMethod}" to SCIM Stream and awaiting result`)
1565
+ res = await this.publish(streamObj)
1566
+ } else {
1567
+ logger.debug(`${gwName}[${pluginName}][${ctx?.params?.baseEntity}] calling "${handle.getMethod}" and awaiting result`)
1568
+ res = await this[handle.getMethod](ctx.params.baseEntity, ob, attributes, ctx.passThrough)
1564
1569
  }
1565
- logger.debug(`${gwName}[${pluginName}][${ctx?.params?.baseEntity}] publishing "${handle.getMethod}" to SCIM Stream and awaiting result`)
1566
- res = await this.publish(streamObj)
1567
- } else {
1568
- logger.debug(`${gwName}[${pluginName}][${ctx?.params?.baseEntity}] calling "${handle.getMethod}" and awaiting result`)
1569
- res = await this[handle.getMethod](ctx.params.baseEntity, ob, attributes, ctx.passThrough)
1570
1570
  }
1571
1571
 
1572
1572
  scimdata = {
@@ -1686,7 +1686,7 @@ const ScimGateway = function () {
1686
1686
  let res
1687
1687
  try {
1688
1688
  res = await this[handler.groups.getMethod](ctx.params.baseEntity, { attribute: 'members.value', operator: 'eq', value: decodeURIComponent(id) }, ['id', 'displayName'], ctx.passThrough)
1689
- } catch (err) {} // method may be implemented but throwing error like groups not supported/implemented
1689
+ } catch (err) { } // method may be implemented but throwing error like groups not supported/implemented
1690
1690
  currentGroups = []
1691
1691
  if (res && res.Resources && Array.isArray(res.Resources) && res.Resources.length > 0) {
1692
1692
  for (let i = 0; i < res.Resources.length; i++) {
@@ -1763,7 +1763,7 @@ const ScimGateway = function () {
1763
1763
  this.replaceUsrGrp = replaceUsrGrp
1764
1764
 
1765
1765
  router.put([`/(|scim/)(!${undefined}|Users|Groups|servicePlans)/:id`,
1766
- `/:baseEntity/(|scim/)(!${undefined}|Users|Groups|servicePlans)/:id`], async (ctx) => {
1766
+ `/:baseEntity/(|scim/)(!${undefined}|Users|Groups|servicePlans)/:id`], async (ctx) => {
1767
1767
  const originalUrl = ctx.request.originalUrl
1768
1768
  if (config.stream.publisher.enabled) {
1769
1769
  const streamObj = {
@@ -2085,7 +2085,7 @@ const ScimGateway = function () {
2085
2085
  const attributes = ['id', 'displayName']
2086
2086
  logger.debug(`${gwName}[${pluginName}][${baseEntity}] calling "${handler.groups.getMethod}" and awaiting result - groups to be included`)
2087
2087
  res = await this[handler.groups.getMethod](baseEntity, ob, attributes, ctxPassThrough)
2088
- } catch (err) {} // ignore errors
2088
+ } catch (err) { } // ignore errors
2089
2089
  if (res && res.Resources && Array.isArray(res.Resources) && res.Resources.length > 0) {
2090
2090
  for (let i = 0; i < res.Resources.length; i++) {
2091
2091
  if (!res.Resources[i].id) continue
@@ -2121,7 +2121,7 @@ const ScimGateway = function () {
2121
2121
  if (config.localhostonly === true) {
2122
2122
  logger.info(`${gwName}[${pluginName}] denying other clients than localhost (127.0.0.1)`)
2123
2123
  if (config.certificate && config.certificate.key && config.certificate.cert) {
2124
- // SSL
2124
+ // SSL
2125
2125
  let keyFile = path.join(configDir, '/certs/', config.certificate.key)
2126
2126
  if (config.certificate.key.startsWith('/') || config.certificate.key.includes('\\')) {
2127
2127
  keyFile = config.certificate.key
@@ -2136,7 +2136,7 @@ const ScimGateway = function () {
2136
2136
  }, app.callback()).listen(config.port, 'localhost')
2137
2137
  logger.info(`${gwName}[${pluginName}] now listening SCIM ${config.scim.version} on TLS port ${config.port}...${config.stream.subscriber.enabled ? '' : '\n'}`)
2138
2138
  } else if (config.certificate && config.certificate.pfx && config.certificate.pfx.bundle) {
2139
- // SSL using PFX / PKCS#12
2139
+ // SSL using PFX / PKCS#12
2140
2140
  let pfxFile = path.join(configDir, '/certs/', config.certificate.pfx.bundle)
2141
2141
  if (config.certificate.pfx.bundle.startsWith('/') || config.certificate.pfx.bundle.includes('\\')) {
2142
2142
  pfxFile = config.certificate.pfx.bundle
@@ -2147,14 +2147,14 @@ const ScimGateway = function () {
2147
2147
  }, app.callback()).listen(config.port, 'localhost')
2148
2148
  logger.info(`${gwName}[${pluginName}] now listening SCIM ${config.scim.version} on TLS port ${config.port}...${config.stream.subscriber.enabled ? '' : '\n'}`)
2149
2149
  } else {
2150
- // none SSL
2150
+ // none SSL
2151
2151
  server = http.createServer(app.callback()).listen(config.port, 'localhost')
2152
2152
  logger.info(`${gwName}[${pluginName}] now listening SCIM ${config.scim.version} on port ${config.port}...${config.stream.subscriber.enabled ? '' : '\n'}`)
2153
2153
  }
2154
2154
  } else {
2155
2155
  logger.info(`${gwName}[${pluginName}] accepting requests from all clients`)
2156
2156
  if (config.certificate && config.certificate.key && config.certificate.cert) {
2157
- // SSL self signed cert e.g: openssl req -nodes -newkey rsa:2048 -x509 -sha256 -days 3650 -keyout key.pem -out cert.pem -subj "/O=NodeJS/OU=Testing/CN=<FQDN>"
2157
+ // SSL self signed cert e.g: openssl req -nodes -newkey rsa:2048 -x509 -sha256 -days 3650 -keyout key.pem -out cert.pem -subj "/O=NodeJS/OU=Testing/CN=<FQDN>"
2158
2158
  let keyFile = path.join(configDir, '/certs/', config.certificate.key)
2159
2159
  if (config.certificate.key.startsWith('/') || config.certificate.key.includes('\\')) {
2160
2160
  keyFile = config.certificate.key
@@ -2177,7 +2177,7 @@ const ScimGateway = function () {
2177
2177
  }, app.callback()).listen(config.port)
2178
2178
  logger.info(`${gwName}[${pluginName}] now listening SCIM ${config.scim.version} on TLS port ${config.port}...${config.stream.subscriber.enabled ? '' : '\n'}`)
2179
2179
  } else if (config.certificate && config.certificate.pfx && config.certificate.pfx.bundle) {
2180
- // SSL using PFX / PKCS#12
2180
+ // SSL using PFX / PKCS#12
2181
2181
  let pfxFile = path.join(configDir, '/certs/', config.certificate.pfx.bundle)
2182
2182
  if (config.certificate.pfx.bundle.startsWith('/') || config.certificate.pfx.bundle.includes('\\')) {
2183
2183
  pfxFile = config.certificate.pfx.bundle
@@ -2188,7 +2188,7 @@ const ScimGateway = function () {
2188
2188
  }, app.callback()).listen(config.port)
2189
2189
  logger.info(`${gwName}[${pluginName}] now listening SCIM ${config.scim.version} on TLS port ${config.port}...${config.stream.subscriber.enabled ? '' : '\n'}`)
2190
2190
  } else {
2191
- // none SSL
2191
+ // none SSL
2192
2192
  server = http.createServer(app.callback()).listen(config.port)
2193
2193
  logger.info(`${gwName}[${pluginName}] now listening SCIM ${config.scim.version} on port ${config.port}...${config.stream.subscriber.enabled ? '' : '\n'}`)
2194
2194
  }
@@ -2707,7 +2707,7 @@ ScimGateway.prototype.endpointMapper = function endpointMapper (direction, parse
2707
2707
  if (complexObj[attr]) {
2708
2708
  found = true
2709
2709
  resArr.push(keyNotDot)
2710
- // don't break - check for multiple complex definitions
2710
+ // don't break - check for multiple complex definitions
2711
2711
  }
2712
2712
  }
2713
2713
  }
@@ -3191,7 +3191,7 @@ ScimGateway.prototype.convertedScim20 = function convertedScim20 (obj) {
3191
3191
  } else if (typeof el.value === 'string' && el.value.substring(0, 1) === '{') { // "value": [{"value":"{\"id\":\"c20e145e-5459-4a6c-a074-b942bbd4cfe1\",\"value\":\"admin\",\"displayName\":\"Administrator\"}"}}]
3192
3192
  try {
3193
3193
  element.value[i] = JSON.parse(el.value)
3194
- } catch (err) {}
3194
+ } catch (err) { }
3195
3195
  }
3196
3196
  }
3197
3197
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scimgateway",
3
- "version": "4.5.11",
3
+ "version": "4.5.12",
4
4
  "description": "Using SCIM protocol as a gateway for user provisioning to other endpoints",
5
5
  "author": "Jarle Elshaug <jarle.elshaug@gmail.com> (https://elshaug.xyz)",
6
6
  "homepage": "https://elshaug.xyz",