scimgateway 4.5.10 → 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
@@ -9,7 +9,7 @@ Validated through IdP's:
9
9
 
10
10
  - Symantec/Broadcom/CA Identity Manager
11
11
  - Microsoft Entra ID
12
- - One Identity/OneLogin
12
+ - One Identity Manager/OneLogin
13
13
  - Okta
14
14
  - Omada
15
15
  - SailPoint/IdentityNow
@@ -1163,6 +1163,32 @@ 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
+
1172
+ ### v4.5.11
1173
+
1174
+ [Improved]
1175
+
1176
+ - deleteUser will try to revoke user from groups before deleting user
1177
+ - advanced or-filter (e.g., used by One Identity Manager) will be chunked and handled by scimgateway as separate calls to plugin
1178
+ - baseEntity now included in scimgateway log entries like plugin log entries
1179
+
1180
+ [Fixed]
1181
+
1182
+ - plugin-ldap, using OpenLDAP - configuration { "isOpenLdap": true } and adding an already existing group member returned 500 Error instead of 200 OK.
1183
+ - plugin-ldap, using OpenLDAP in combination with endpoint user mapping `"type":"array"` and `"typeInbound":"string"` for handling comma separated SCIM string mapping towards an endpoint array/multivalue attribute, did not return correct sort order of the comma separated string when using OpenLDAP. Mapping example:
1184
+
1185
+ "<endpointAttr>": {
1186
+ "mapTo": "<scimAttr>",
1187
+ "type": "array",
1188
+ "typeInbound": "string"
1189
+ },
1190
+
1191
+
1166
1192
  ### v4.5.10
1167
1193
 
1168
1194
  [Fixed]
@@ -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
  ]
package/lib/plugin-api.js CHANGED
@@ -57,7 +57,7 @@ scimgateway.authPassThroughAllowed = false // true enables auth passThrough (no
57
57
  //
58
58
  scimgateway.postApi = async (baseEntity, apiObj, ctx) => {
59
59
  const action = 'postApi'
60
- scimgateway.logger.debug(`${pluginName} handling "${action}" apiObj=${JSON.stringify(apiObj)}`)
60
+ scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" apiObj=${JSON.stringify(apiObj)}`)
61
61
 
62
62
  if ((typeof (apiObj) !== 'object') || (Object.keys(apiObj).length === 0)) {
63
63
  throw new Error('unsupported POST syntax')
@@ -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
@@ -109,6 +111,7 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
109
111
  //
110
112
  const action = 'getUsers'
111
113
  scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" getObj=${getObj ? JSON.stringify(getObj) : ''} attributes=${attributes}`)
114
+ if (!config.entity[baseEntity]) throw new Error(`unsupported baseEntity: ${baseEntity}`)
112
115
 
113
116
  const result = {
114
117
  Resources: [],
@@ -465,7 +468,41 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
465
468
  }
466
469
 
467
470
  try {
471
+ const newDN = checkIfNewDN(baseEntity, base, 'user', attrObj, endpointObj)
468
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
+ }
469
506
  return null
470
507
  } catch (err) {
471
508
  throw new Error(`${action} error: ${err.message}`)
@@ -490,6 +527,7 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
490
527
  //
491
528
  const action = 'getGroups'
492
529
  scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" getObj=${getObj ? JSON.stringify(getObj) : ''} attributes=${attributes}`)
530
+ if (!config.entity[baseEntity]) throw new Error(`unsupported baseEntity: ${baseEntity}`)
493
531
 
494
532
  const result = {
495
533
  Resources: [],
@@ -738,6 +776,8 @@ scimgateway.modifyGroup = async (baseEntity, id, attrObj, ctx) => {
738
776
  try {
739
777
  delete attrObj.members
740
778
  const [endpointObj] = scimgateway.endpointMapper('outbound', attrObj, config.map.group)
779
+ const newDN = checkIfNewDN(baseEntity, base, 'group', attrObj, endpointObj)
780
+
741
781
  if (Object.keys(endpointObj).length > 0) {
742
782
  const ldapOptions = {
743
783
  operation: 'replace',
@@ -759,6 +799,12 @@ scimgateway.modifyGroup = async (baseEntity, id, attrObj, ctx) => {
759
799
  }
760
800
  await doRequest(baseEntity, method, base, ldapOptions, ctx)
761
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
+ }
762
808
  return null
763
809
  } catch (err) {
764
810
  throw new Error(`${action} error: ${err.message}`)
@@ -1042,7 +1088,7 @@ const getMemberOfGroups = async (baseEntity, id, ctx) => {
1042
1088
  // using Active Directory (none OpenLDAP), DN should not be escaped, but DN retrieved from AD is character escaped
1043
1089
  //
1044
1090
  const ldapEscDn = (isOpenLdap, str) => {
1045
- if (!str) return str
1091
+ if (typeof str !== 'string' || str.length < 1) return str
1046
1092
 
1047
1093
  if (!isOpenLdap && str.indexOf('\\') > 0) {
1048
1094
  const conv = str.replace(/\\([0-9A-Fa-f]{2})/g, (_, hex) => {
@@ -1198,6 +1244,10 @@ const berDecodeDn = (dn) => {
1198
1244
  }
1199
1245
  }
1200
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, '\\,')
1201
1251
  a.splice(0, 1) // remove element 0 from array
1202
1252
  return `${arr[0]}${decoded},${a.join(',')}` // OU=users,OU=abc
1203
1253
  }
@@ -1222,6 +1272,51 @@ const getNamingAttribute = (baseEntity, type) => {
1222
1272
  return [arr[0].attribute, arr[0].mapTo]
1223
1273
  }
1224
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
+
1225
1320
  //
1226
1321
  // getCtxAuth returns username/secret from ctx header when using Auth PassThrough
1227
1322
  //
@@ -1290,6 +1385,7 @@ const getServiceClient = async (baseEntity, ctx) => {
1290
1385
  // }
1291
1386
  //
1292
1387
  const doRequest = async (baseEntity, method, base, options, ctx) => {
1388
+ if (!config.entity[baseEntity]) throw new Error(`unsupported baseEntity: ${baseEntity}`)
1293
1389
  let result = null
1294
1390
  let client = null
1295
1391
  base = ldapEscDn(config.entity[baseEntity].ldap.isOpenLdap, base)
@@ -1386,10 +1482,11 @@ const doRequest = async (baseEntity, method, base, options, ctx) => {
1386
1482
  if (Array.isArray(options.modification[key])) {
1387
1483
  mod.values = options.modification[key]
1388
1484
  if (mod.values.length > 1) { // delete before replace to keep inbound order
1389
- changes.push({
1485
+ const multiValueObj = {
1390
1486
  operation: 'delete',
1391
1487
  modification: { type: key, values: [] }
1392
- })
1488
+ }
1489
+ client.modify(dn, multiValueObj, () => {})
1393
1490
  }
1394
1491
  } else {
1395
1492
  if (typeof options.modification[key] === 'string') mod.values = [options.modification[key]]
@@ -1403,8 +1500,9 @@ const doRequest = async (baseEntity, method, base, options, ctx) => {
1403
1500
  }
1404
1501
  client.modify(dn, changes, (err) => {
1405
1502
  if (err) {
1406
- if (options.operation && options.operation === 'add' && options.modification && options.modification.member) {
1407
- if (err.message.includes('ENTRY_EXISTS')) return resolve() // add already existing group to user
1503
+ if (options.operation && options.operation === 'add') {
1504
+ const msg = err.message.toLowerCase()
1505
+ if (msg.includes('exists')) return resolve() // "ENTRY_EXISTS" / "Value Exists" - add already existing group to user
1408
1506
  }
1409
1507
  return reject(err)
1410
1508
  }
@@ -1413,6 +1511,22 @@ const doRequest = async (baseEntity, method, base, options, ctx) => {
1413
1511
  })
1414
1512
  break
1415
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
+
1416
1530
  case 'add':
1417
1531
  result = await new Promise((resolve, reject) => {
1418
1532
  client.add(base, options, (err) => {
@@ -1473,12 +1587,23 @@ if (!config?.map?.user) {
1473
1587
  scimgateway.logger.error('configuration map.user is missing')
1474
1588
  throw new Error(`using exception to exit ${pluginName}, please ignore message...`)
1475
1589
  } else {
1590
+ let isOpenLdapFound = false
1591
+ for (const key in config.entity) {
1592
+ if (config.entity[key]?.ldap?.isOpenLdap === true) {
1593
+ isOpenLdapFound = true
1594
+ break
1595
+ }
1596
+ }
1476
1597
  let idFound = false
1477
1598
  let userNameFound = false
1478
1599
  for (const key in config.map.user) {
1479
1600
  if (config.map.user[key].mapTo === 'id') idFound = true
1480
1601
  else if (['userName', 'externalId'].includes(config.map.user[key].mapTo)) userNameFound = true
1481
- if (idFound && userNameFound) break
1602
+ if (config.map.user[key]?.type === 'array' && config.map.user[key]?.typeInbound === 'string') {
1603
+ if (!Object.prototype.hasOwnProperty.call(config.map.user[key], 'typeOutboundReverse')) {
1604
+ config.map.user[key].typeOutboundReverse = !isOpenLdapFound
1605
+ }
1606
+ }
1482
1607
  }
1483
1608
  if (!idFound || !userNameFound) {
1484
1609
  scimgateway.logger.error('configuration map.user missing mapTo definition for mandatory id/userName')
@@ -614,12 +614,12 @@ scimgateway.modifyGroup = async (baseEntity, id, attrObj, ctx) => {
614
614
  const stripLoki = (obj) => { // remove loki meta data and insert scim
615
615
  const retObj = JSON.parse(JSON.stringify(obj)) // new object - don't modify loki source
616
616
  if (retObj.meta) {
617
- if (retObj.meta.created) retObj.meta.created = new Date(retObj.meta.created).toISOString()
618
617
  delete retObj.meta.lastModified // test users loaded
618
+ if (retObj.meta.created) retObj.meta.created = new Date(retObj.meta.created).toISOString()
619
619
  if (retObj.meta.updated) {
620
620
  retObj.meta.lastModified = new Date(retObj.meta.updated).toISOString()
621
621
  delete retObj.meta.updated
622
- }
622
+ } else retObj.meta.lastModified = retObj.meta.created
623
623
  if (retObj.meta.revision !== undefined) {
624
624
  retObj.meta.version = `W/"${retObj.meta.revision}"`
625
625
  delete retObj.meta.revision