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 +27 -1
- package/config/plugin-ldap.json +3 -2
- package/lib/plugin-api.js +1 -1
- package/lib/plugin-ldap.js +131 -6
- package/lib/plugin-loki.js +2 -2
- package/lib/scim-stream.js +2 -1
- package/lib/scimgateway.js +216 -179
- package/package.json +1 -1
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]
|
package/config/plugin-ldap.json
CHANGED
|
@@ -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": "
|
|
151
|
+
"attribute": "cn",
|
|
151
152
|
"mapTo": "userName"
|
|
152
153
|
}
|
|
153
154
|
],
|
|
154
155
|
"group": [
|
|
155
156
|
{
|
|
156
|
-
"attribute": "
|
|
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')
|
package/lib/plugin-ldap.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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'
|
|
1407
|
-
|
|
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 (
|
|
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')
|
package/lib/plugin-loki.js
CHANGED
|
@@ -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
|