scimgateway 6.1.16 → 6.1.18

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
@@ -801,6 +801,7 @@ There are two options: run SCIM Gateway in a single image, or use Docker Compose
801
801
  bun install scimgateway
802
802
  bun pm trust scimgateway
803
803
  cp ./config/docker/* .
804
+ cp ./config/docker/.dockerignore .
804
805
  ```
805
806
 
806
807
  **Dockerfile** <== Main dockerfile
@@ -1051,7 +1052,7 @@ For testing purposes we could get an Azure free account
1051
1052
  Note: Entra ID has a role hierarchy, and running SCIM Gateway as a `User Administrator` has some limitations when administering users who have administrative roles. For full administrative access to all users, SCIM Gateway must have the `Global Administrator` role (`62e90394-69f5-4237-9190-012177145e10`).
1052
1053
 
1053
1054
  Also note: The `plugin-entra-id.json` configuration file includes `map.user.signInActivity`. Using the `signInActivity` attribute requires an Entra ID Premium license and the API permission `AuditLog.Read.All`.
1054
- **Remove this mapping configuration if these conditions are not met**, otherwise provisioning will fail and errors such as `Authentication_RequestFromNonPremiumTenantOrB2CTenant` may occur.
1055
+ **Remove this mapping configuration if these conditions are not met or override by configuring endpoint.entity.[baseEntity].skipSignInActivity = true**, otherwise provisioning will fail and errors such as `Authentication_RequestFromNonPremiumTenantOrB2CTenant` may occur.
1055
1056
 
1056
1057
  ### SCIM Gateway configuration
1057
1058
 
@@ -1301,14 +1302,29 @@ In code editor (e.g., Visual Studio Code), method details and documentation are
1301
1302
 
1302
1303
  MIT © [Jarle Elshaug](https://www.elshaug.xyz)
1303
1304
 
1305
+ ## Change log
1306
+
1307
+ ### v6.1.18
1308
+
1309
+ [Fixed]
1310
+
1311
+ - createUser and modifyUser returns full user object. Some endpoints like Entra ID hasn’t caught up yet due to internal sync and changes are not reflected. This update ensure returned user object contains what have been modified.
1312
+
1313
+ ### v6.1.17
1314
+
1315
+ [Fixed]
1316
+
1317
+ - plugin-entra-id:
1318
+
1319
+ - Fixed an issue where `filter=userName eq "user_upn"` was broken in v6.1.11 when using the updated configuration file that includes `map.user.signInActivity`.
1320
+ - Added new configuration option `endpoint.entity.[baseEntity].skipSignInActivity = true` to exclude the `signInActivity` attribute. This attribute requires a Microsoft Entra ID Premium license and the `AuditLog.Read.All` API permission.
1321
+
1304
1322
  ### v6.1.16
1305
1323
 
1306
1324
  [Improved]
1307
1325
 
1308
1326
  - plugin-entra-id: `GET /Entitlements` using derivedIncludes, fully flattened (recursive expansion of previous includes).
1309
1327
 
1310
- ## Change log
1311
-
1312
1328
  ### v6.1.15
1313
1329
 
1314
1330
  [Fixed]
@@ -131,6 +131,7 @@
131
131
  "endpoint": {
132
132
  "entity": {
133
133
  "undefined": {
134
+ "skipSignInActivity": false,
134
135
  "connection": {
135
136
  "baseUrls": [],
136
137
  "auth": {
@@ -19,6 +19,19 @@
19
19
  "O365_w/o_Teams_Bundle_M5":{"displayName":"Office 365 without Teams Bundle M5","category":"Microsoft 365","licenseCategory":"Paid","isBillable":true,"priceUSD":28,"includes":["ENTERPRISEPACK","EXCHANGESTANDARD","SHAREPOINTSTANDARD"],"derivedIncludes":["EXCHANGEENTERPRISE","SHAREPOINTENTERPRISE","MCOSTANDARD","ONEDRIVESTANDARD","INTUNE_A","AAD_PREMIUM","EXCHANGESTANDARD","SHAREPOINTSTANDARD"]},
20
20
  "POWER_BI_PREMIUM_PER_USER":{"displayName":"Power BI Premium Per User","category":"Power Platform","licenseCategory":"Paid","isBillable":true,"priceUSD":20,"includes":["POWER_BI_PRO"],"derivedIncludes":["POWER_BI_PRO"]},
21
21
  "RMS_S_PREMIUM":{"displayName":"Azure Information Protection Premium P2","category":"Security","licenseCategory":"Paid","isBillable":true,"priceUSD":9,"includes":["RMS_S_ENTERPRISE"],"derivedIncludes":["RMS_S_ENTERPRISE"]},
22
+ "O365_BUSINESS_ESSENTIALS":{"displayName":"Microsoft 365 Business Basic","category":"Microsoft 365","licenseCategory":"Paid","isBillable":true,"priceUSD":6,"includes":["EXCHANGESTANDARD","SHAREPOINTSTANDARD","MCOSTANDARD","ONEDRIVESTANDARD"],"derivedIncludes":["EXCHANGESTANDARD","SHAREPOINTSTANDARD","MCOSTANDARD","ONEDRIVESTANDARD"]},
23
+ "M365_BUSINESS_BASIC":{"displayName":"Microsoft 365 Business Basic","category":"Microsoft 365","licenseCategory":"Paid","isBillable":true,"priceUSD":6,"includes":["EXCHANGESTANDARD","SHAREPOINTSTANDARD","MCOSTANDARD","ONEDRIVESTANDARD"],"derivedIncludes":["EXCHANGESTANDARD","SHAREPOINTSTANDARD","MCOSTANDARD","ONEDRIVESTANDARD"]},
24
+ "M365_BUSINESS_STANDARD":{"displayName":"Microsoft 365 Business Standard","category":"Microsoft 365","licenseCategory":"Paid","isBillable":true,"priceUSD":12,"includes":["EXCHANGESTANDARD","SHAREPOINTSTANDARD","MCOSTANDARD","ONEDRIVESTANDARD"],"derivedIncludes":["EXCHANGESTANDARD","SHAREPOINTSTANDARD","MCOSTANDARD","ONEDRIVESTANDARD"]},
25
+ "M365_BUSINESS_PREMIUM":{"displayName":"Microsoft 365 Business Premium","category":"Microsoft 365","licenseCategory":"Paid","isBillable":true,"priceUSD":22,"includes":["EXCHANGESTANDARD","SHAREPOINTSTANDARD","MCOSTANDARD","ONEDRIVESTANDARD","INTUNE_A","AAD_PREMIUM"],"derivedIncludes":["EXCHANGESTANDARD","SHAREPOINTSTANDARD","MCOSTANDARD","ONEDRIVESTANDARD","INTUNE_A","AAD_PREMIUM"]},
26
+ "ENTRA_ID_GOVERNANCE":{"displayName":"Microsoft Entra ID Governance","category":"Entra","licenseCategory":"Paid","isBillable":true,"priceUSD":7,"includes":["AAD_PREMIUM_P2"],"derivedIncludes":["AAD_PREMIUM_P2","AAD_PREMIUM"]},
27
+ "Microsoft_365_F1":{"displayName":"Microsoft 365 F1","category":"Microsoft 365","licenseCategory":"Paid","isBillable":true,"priceUSD":2.25,"includes":["EXCHANGESTANDARD","SHAREPOINTSTANDARD","MCOSTANDARD","ONEDRIVESTANDARD"],"derivedIncludes":["EXCHANGESTANDARD","SHAREPOINTSTANDARD","MCOSTANDARD","ONEDRIVESTANDARD"]},
28
+ "ENTRA_IDENTITY_PROTECTION":{"displayName":"Microsoft Entra ID Protection","category":"Entra","licenseCategory":"Paid","isBillable":true,"priceUSD":6,"includes":["AAD_PREMIUM_P2"],"derivedIncludes":["AAD_PREMIUM_P2","AAD_PREMIUM"]},
29
+ "ENTRA_IDENTITY_GOVERNANCE_STEPUP":{"displayName":"Microsoft Entra ID Governance Step-Up","category":"Entra","licenseCategory":"Paid","isBillable":true,"priceUSD":4,"includes":["AAD_PREMIUM_P1"],"derivedIncludes":["AAD_PREMIUM_P1"]},
30
+ "ENTRA_PERMISSIONS_MANAGEMENT":{"displayName":"Microsoft Entra Permissions Management","category":"Entra","licenseCategory":"Paid","isBillable":true,"priceUSD":10,"includes":[]},
31
+ "AAD_PREMIUM_P1":{"displayName":"Microsoft Entra ID P1","category":"Entra","licenseCategory":"Paid","isBillable":true,"priceUSD":6,"includes":[]},
32
+ "AAD_B2C":{"displayName":"Azure AD B2C","category":"Entra","licenseCategory":"Consumption","isBillable":true,"priceUSD":0,"includes":[]},
33
+ "AAD_BASIC_EDU":{"displayName":"Azure Active Directory Basic (Education)","category":"Entra","licenseCategory":"Free","isBillable":false,"priceUSD":0,"includes":[]},
34
+ "M365_DEFENDER_IDENTITY":{"displayName":"Microsoft 365 Defender for Identity","category":"Security","licenseCategory":"Paid","isBillable":true,"priceUSD":6,"includes":[]},
22
35
  "Microsoft_Teams_EEA_New": { "displayName": "Microsoft Teams EEA New", "category": "Collaboration", "licenseCategory": "Paid", "isBillable": true, "priceUSD": 4.00, "includes": [] },
23
36
  "POWERAPPS_DEV": { "displayName": "Power Apps Developer Plan", "category": "Power Platform", "licenseCategory": "Free", "isBillable": false, "priceUSD": 0.00, "includes": [] },
24
37
  "EXCHANGESTANDARD":{"displayName":"Exchange Online Plan 1","category":"Exchange","licenseCategory":"Paid","isBillable":true,"priceUSD":4,"includes":[]},
@@ -158,6 +158,13 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
158
158
  }
159
159
  } else selectAttributes = userSelectAttributes
160
160
 
161
+ if (config.entity[baseEntity]?.skipSignInActivity === true) { // remove signInActivity that requires Entra ID Premium license
162
+ const index = selectAttributes.indexOf('signInActivity')
163
+ if (index > -1) {
164
+ selectAttributes.splice(index, 1)
165
+ }
166
+ }
167
+
161
168
  const method = 'GET'
162
169
  const body = null
163
170
  let path
@@ -170,7 +177,7 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
170
177
 
171
178
  // mandatory if-else logic - start
172
179
  if (getObj.operator) {
173
- if (getObj.operator === 'eq' && ['id', 'userName', 'externalId'].includes(getObj.attribute)) {
180
+ if (getObj.operator === 'eq' && ['id'].includes(getObj.attribute)) { // userName/externalId using simpel filtering because direct lookup by upn do not allow select attribute signInActivity
174
181
  // mandatory - unique filtering - single unique user to be returned - correspond to getUser() in versions < 4.x.x
175
182
  path = `/users/${getObj.value}?$select=${selectAttributes.join(',')}`
176
183
  } else if (getObj.operator === 'eq' && getObj.attribute === 'group.value') {
@@ -815,7 +822,7 @@ scimgateway.getEntitlements = async (baseEntity, getObj, attributes, ctx) => {
815
822
  throw new Error(`${action} error: advanced filtering not supported: ${getObj.rawFilter}`)
816
823
  } else {
817
824
  // mandatory - no filtering
818
- path = `/subscribedSkus`
825
+ path = '/subscribedSkus'
819
826
  }
820
827
 
821
828
  if (!path) throw new Error(`${action} error: mandatory if-else logic not fully implemented`)
@@ -848,7 +855,8 @@ scimgateway.getEntitlements = async (baseEntity, getObj, attributes, ctx) => {
848
855
  licenseInfo.derivedIncludes = licenseMapping[skuPartNumber].derivedIncludes
849
856
  }
850
857
  ret.Resources.push({
851
- type: skuPartNumber, value: response.body.value[i].skuId, display: displayName, licenseInfo })
858
+ type: skuPartNumber, value: response.body.value[i].skuId, display: displayName, licenseInfo,
859
+ })
852
860
  }
853
861
 
854
862
  if (searchAttr && ret.Resources.length > 0) {
@@ -281,116 +281,10 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
281
281
  const res = users.find({ id: id })
282
282
  if (res.length === 0) throw new Error(`${action} error: user id=${id} - user does not exist`)
283
283
  if (res.length > 1) throw new Error(`${action} error: user id=${id} - user is not unique, more than one have been found`)
284
- const userObj: any = res[0]
285
-
286
- for (const key in attrObj) {
287
- if (Array.isArray(attrObj[key])) { // standard, not using type (e.g roles/groups) or skipTypeConvert=true
288
- const delArr = attrObj[key].filter(el => el.operation === 'delete')
289
- const addArr = attrObj[key].filter(el => (!el.operation || el.operation !== 'delete'))
290
- if (!userObj[key] || !Array.isArray(userObj[key])) userObj[key] = []
291
- // delete
292
- userObj[key] = userObj[key].filter((el: Record<string, any>) => {
293
- const index = delArr.findIndex((e) => {
294
- let elExist = false
295
- for (const k in e) {
296
- if (k === 'primary' || k === 'operation') continue
297
- if (e[k] !== el[k]) {
298
- elExist = false
299
- break
300
- }
301
- elExist = true
302
- }
303
- return elExist
304
- })
305
- if (index >= 0) return false
306
- else return true
307
- })
308
- // add
309
- addArr.forEach((el) => {
310
- if (Object.prototype.hasOwnProperty.call(el, 'primary')) {
311
- if (el.primary === true || (typeof el.primary === 'string' && el.primary.toLowerCase() === 'true')) {
312
- const index = userObj[key].findIndex((e: Record<string, any>) => e.primary === el.primary)
313
- if (index >= 0) {
314
- if (key === 'roles') userObj[key].splice(index, 1) // roles, delete existing role having primary attribute true (new role with primary will be added)
315
- else userObj[key][index].primary = undefined // remove primary attribute, only one primary
316
- }
317
- }
318
- }
319
- const index = userObj[key].findIndex((e: Record<string, any>, _index: number) => { // avoid adding existing
320
- if (el.value && el.value === e.value && el.type === e.type) {
321
- for (const k in el) {
322
- e[k] = el[k]
323
- }
324
- return true
325
- }
326
- let elExist = false
327
- for (const k in el) {
328
- if (el[k] !== e[k]) {
329
- if (k === 'primary') continue
330
- elExist = false
331
- break
332
- }
333
- elExist = true
334
- }
335
- return elExist
336
- })
337
- if (index < 0) userObj[key].push(el)
338
- })
339
- } else if (scimgateway.isMultiValueTypes(key)) { // "type converted object" logic and original blank type having type "undefined"
340
- if (!attrObj[key]) delete userObj[key] // blank or null
341
- for (const el in attrObj[key]) {
342
- attrObj[key][el].type = el
343
- if (attrObj[key][el].operation && attrObj[key][el].operation === 'delete') { // delete multivalue
344
- let type: any = el
345
- if (type === 'undefined') type = undefined
346
- userObj[key] = userObj[key].filter((e: Record<string, any>) => e.type !== type)
347
- if (userObj[key].length < 1) delete userObj[key]
348
- } else { // modify/create multivalue
349
- if (!userObj[key]) userObj[key] = []
350
- if (attrObj[key][el].primary) { // remove any existing primary attribute, should only have one primary set
351
- const primVal = attrObj[key][el].primary
352
- if (primVal === true || (typeof primVal === 'string' && primVal.toLowerCase() === 'true')) {
353
- const index = userObj[key].findIndex((e: Record<string, any>) => e.primary === primVal)
354
- if (index >= 0) {
355
- userObj[key][index].primary = undefined
356
- }
357
- }
358
- }
359
- const found = userObj[key].find((e: Record<string, any>, i: any) => {
360
- if (e.type === el || (!e.type && el === 'undefined')) {
361
- for (const k in attrObj[key][el]) {
362
- userObj[key][i][k] = attrObj[key][el][k]
363
- if (k === 'type' && attrObj[key][el][k] === 'undefined') delete userObj[key][i][k] // don't store with type "undefined"
364
- }
365
- return true
366
- } else return false
367
- })
368
- if (attrObj[key][el].type && attrObj[key][el].type === 'undefined') delete attrObj[key][el].type // don't store with type "undefined"
369
- if (!found) userObj[key].push(attrObj[key][el]) // create
370
- }
371
- }
372
- } else {
373
- // None multi value attribute, blank will be deleted
374
- if (typeof (attrObj[key]) === 'object' && attrObj[key] !== null) {
375
- // name.familyName=Bianchi
376
- if (!userObj[key]) userObj[key] = {} // e.g name object does not exist
377
- for (const sub in attrObj[key]) {
378
- if (!userObj[key]) userObj[key] = {}
379
- if (Object.prototype.hasOwnProperty.call(attrObj[key][sub], 'value')
380
- && attrObj[key][sub].value === '') delete userObj[key][sub] // object having blank value attribute e.g. {"manager": {"value": "",...}}
381
- else if (attrObj[key][sub] === '') delete userObj[key][sub]
382
- else {
383
- if (!userObj[key]) userObj[key] = {} // may have been deleted by length check below
384
- userObj[key][sub] = attrObj[key][sub]
385
- }
386
- if (Object.keys(userObj[key]).length < 1) delete userObj[key]
387
- }
388
- } else {
389
- if (attrObj[key] === '') delete userObj[key]
390
- else userObj[key] = attrObj[key]
391
- }
392
- }
393
- }
284
+
285
+ let userObj: any = res[0]
286
+ userObj = scimgateway.patchObj(userObj, attrObj) // merge
287
+
394
288
  await users.update(userObj) // needed for persistence
395
289
  return null
396
290
  }
@@ -354,121 +354,7 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
354
354
  }
355
355
 
356
356
  let userObj = decodeDotDate(res[0])
357
-
358
- for (const key in attrObj) {
359
- if (Array.isArray(attrObj[key])) { // standard, not using type (e.g roles/groups) or skipTypeConvert=true
360
- const delArr = attrObj[key].filter(el => el.operation === 'delete')
361
- const addArr = attrObj[key].filter(el => (!el.operation || el.operation !== 'delete'))
362
- if (!userObj[key] || !Array.isArray(userObj[key])) userObj[key] = []
363
- // delete
364
- userObj[key] = userObj[key].filter((el: Record<string, any>) => {
365
- const index = delArr.findIndex((e) => {
366
- let elExist = false
367
- for (const k in e) {
368
- if (k === 'primary' || k === 'operation') continue
369
- if (e[k] !== el[k]) {
370
- elExist = false
371
- break
372
- }
373
- elExist = true
374
- }
375
- return elExist
376
- })
377
- if (index >= 0) return false
378
- else return true
379
- })
380
- // add
381
- addArr.forEach((el) => {
382
- if (Object.prototype.hasOwnProperty.call(el, 'primary')) {
383
- if (el.primary === true || (typeof el.primary === 'string' && el.primary.toLowerCase() === 'true')) {
384
- const index = userObj[key].findIndex((e: Record<string, any>) => e.primary === el.primary)
385
- if (index >= 0) {
386
- if (key === 'roles') userObj[key].splice(index, 1) // roles, delete existing role having primary attribute true (new role with primary will be added)
387
- else userObj[key][index].primary = undefined // remove primary attribute, only one primary
388
- }
389
- }
390
- }
391
- const index = userObj[key].findIndex((e: Record<string, any>, _index: number) => { // avoid adding existing
392
- if (el.value && el.value === e.value && el.type === e.type) {
393
- for (const k in el) {
394
- e[k] = el[k]
395
- }
396
- return true
397
- }
398
- let elExist = false
399
- for (const k in el) {
400
- if (el[k] !== e[k]) {
401
- if (k === 'primary') continue
402
- elExist = false
403
- break
404
- }
405
- elExist = true
406
- }
407
- return elExist
408
- })
409
- if (index < 0) userObj[key].push(el)
410
- })
411
- } else if (scimgateway.isMultiValueTypes(key)) { // "type converted object" logic and original blank type having type "undefined"
412
- if (!attrObj[key]) delete userObj[key] // blank or null
413
- for (const el in attrObj[key]) {
414
- attrObj[key][el].type = el
415
- if (attrObj[key][el].operation && attrObj[key][el].operation === 'delete') { // delete multivalue
416
- let type: any = el
417
- if (type === 'undefined') type = undefined
418
- userObj[key] = userObj[key].filter((e: Record<string, any>) => e.type !== type)
419
- if (userObj[key].length < 1) delete userObj[key]
420
- } else { // modify/create multivalue
421
- if (!userObj[key]) userObj[key] = []
422
- if (attrObj[key][el].primary) { // remove any existing primary attribute, should only have one primary set
423
- const primVal = attrObj[key][el].primary
424
- if (primVal === true || (typeof primVal === 'string' && primVal.toLowerCase() === 'true')) {
425
- const index = userObj[key].findIndex((e: Record<string, any>) => e.primary === primVal)
426
- if (index >= 0) {
427
- userObj[key][index].primary = undefined
428
- }
429
- }
430
- }
431
- const found = userObj[key].find((e: Record<string, any>, i: any) => {
432
- if (e.type === el || (!e.type && el === 'undefined')) {
433
- for (const k in attrObj[key][el]) {
434
- userObj[key][i][k] = attrObj[key][el][k]
435
- if (k === 'type' && attrObj[key][el][k] === 'undefined') delete userObj[key][i][k] // don't store with type "undefined"
436
- }
437
- return true
438
- } else return false
439
- })
440
- if (attrObj[key][el].type && attrObj[key][el].type === 'undefined') delete attrObj[key][el].type // don't store with type "undefined"
441
- if (!found) userObj[key].push(attrObj[key][el]) // create
442
- }
443
- }
444
- } else {
445
- // None multi value attribute
446
- if (typeof (attrObj[key]) !== 'object' || attrObj[key] === null) {
447
- if (attrObj[key] === '' || attrObj[key] === null) delete userObj[key]
448
- else userObj[key] = attrObj[key]
449
- } else {
450
- // None multi value attribute, blank will be deleted
451
- if (typeof (attrObj[key]) === 'object' && attrObj[key] !== null) {
452
- // name.familyName=Bianchi
453
- if (!userObj[key]) userObj[key] = {} // e.g name object does not exist
454
- for (const sub in attrObj[key]) {
455
- if (!userObj[key]) userObj[key] = {}
456
- if (Object.prototype.hasOwnProperty.call(attrObj[key][sub], 'value')
457
- && attrObj[key][sub].value === '') delete userObj[key][sub] // object having blank value attribute e.g. {"manager": {"value": "",...}}
458
- else if (attrObj[key][sub] === '') delete userObj[key][sub]
459
- else {
460
- if (!userObj[key]) userObj[key] = {} // may have been deleted by length check below
461
- userObj[key][sub] = attrObj[key][sub]
462
- }
463
- if (Object.keys(userObj[key]).length < 1) delete userObj[key]
464
- }
465
- } else {
466
- if (attrObj[key] === '') delete userObj[key]
467
- else userObj[key] = attrObj[key]
468
- }
469
- }
470
- }
471
- }
357
+ userObj = scimgateway.patchObj(userObj, attrObj) // merge
472
358
 
473
359
  if (!userObj.meta) {
474
360
  const now = Date.now()
@@ -250,6 +250,9 @@ export class ScimGateway {
250
250
  /** getEntitlements returns endpoint supported entitlements - e.g., plugin-entra-id returns available Entra tenant licenses as entitlements */
251
251
  getEntitlements!: (baseEntity: string, getObj: Record<string, any>, attributes: Array<string>, ctx?: undefined | Record<string, any>) => any
252
252
 
253
+ /** getRoles returns endpoint supported roles - e.g., plugin-entra-id returns Entra permanent and eligible roles */
254
+ getRoles!: (baseEntity: string, getObj: Record<string, any>, attributes: Array<string>, ctx?: undefined | Record<string, any>) => any
255
+
253
256
  /**
254
257
  * postApi method is defined at the plugin and should handle incoming `"POST /api"` for creating an object and should be used according to your needs
255
258
  * @param baseEntity used for multi tenant or multi endpoint support, either "undefined" or set by request url e.g., http://localhost:8880/loki2/Users gives baseEntity=loki2
@@ -488,12 +491,16 @@ export class ScimGateway {
488
491
  description: 'Entitlement',
489
492
  getMethod: 'getEntitlements',
490
493
  }
494
+ handler.Roles = handler.roles = {
495
+ description: 'Role',
496
+ getMethod: 'getRoles',
497
+ }
491
498
  handler.AppRoles = handler.approles = { // scim-stream
492
499
  description: 'AppRole',
493
500
  getMethod: 'getAppRoles',
494
501
  }
495
502
  /** handlers supported url paths */
496
- const handlers = ['users', 'groups', 'bulk', 'entitlements', 'approles', 'api', 'schemas', 'resourcetypes', 'serviceproviderconfig', 'serviceproviderconfigs', 'oauth', '.well-known', 'logger']
503
+ const handlers = ['users', 'groups', 'bulk', 'entitlements', 'roles', 'approles', 'api', 'schemas', 'resourcetypes', 'serviceproviderconfig', 'serviceproviderconfigs', 'oauth', '.well-known', 'logger']
497
504
 
498
505
  try {
499
506
  if (!fs.existsSync(configDir + '/wsdls')) fs.mkdirSync(configDir + '/wsdls')
@@ -889,12 +896,45 @@ export class ScimGateway {
889
896
  if (parts.length === 1) {
890
897
  const org = scimResource.attributes.find((r: any) => r.name === item.mapTo)
891
898
  let attr: any
892
- if (org) attr = structuredClone(org) // reusing original SCIM definition
893
- else {
899
+ if (org) { // reusing original SCIM definition
900
+ attr = structuredClone(org)
901
+ if (item.subAttributes && Array.isArray(item.subAttributes) && attr?.subAttributes && Array.isArray(attr.subAttributes)) {
902
+ // any configuration subAttributes takes precidence
903
+ for (const el of item.subAttributes) {
904
+ if (typeof el !== 'object' || !el.name) continue
905
+ const existingSub = attr.subAttributes.find((sa: any) => sa.name === el.name)
906
+ if (existingSub) {
907
+ if (el.description) existingSub.description = el.description
908
+ if (el.mutability) existingSub.mutability = el.mutability
909
+ if (el.canonicalValues) existingSub.canonicalValues = el.canonicalValues
910
+ } else {
911
+ const newSub: Record<string, any> = {
912
+ name: el.name,
913
+ type: el.type ?? 'string',
914
+ multiValued: el.mulitvalue ?? false,
915
+ description: el.description ?? '',
916
+ required: false,
917
+ caseExact: false,
918
+ mutability: el.mutability ?? 'readWrite',
919
+ returned: 'default',
920
+ uniqueness: 'none',
921
+ }
922
+ if (el.canonicalValues) newSub.canonicalValues = el.canonicalValues
923
+ if (isV1) {
924
+ newSub.readOnly = newSub.mutability === 'readOnly'
925
+ delete newSub.mutability
926
+ delete newSub.returned
927
+ delete newSub.uniqueness
928
+ }
929
+ attr.subAttributes.push(newSub)
930
+ }
931
+ }
932
+ }
933
+ } else {
894
934
  attr = {
895
935
  name: item.mapTo,
896
936
  type: (item.type === 'boolean') ? item.type : 'string',
897
- multiValued: item.type === 'array' ? true : false,
937
+ multiValued: (item.type === 'array' || item.type === 'complexArray') ? true : false,
898
938
  description: item.description ?? '',
899
939
  required: (item.mapTo === 'userName') ? true : false,
900
940
  caseExact: false,
@@ -902,10 +942,33 @@ export class ScimGateway {
902
942
  returned: 'default',
903
943
  uniqueness: (item.mapTo === 'userName') ? 'server' : 'none',
904
944
  }
905
- if (item.type === 'complexObject') {
945
+ if (item.type === 'complexObject' || item.type === 'complexArray') {
906
946
  attr.type = 'complex'
907
947
  attr.multiValued = false
908
948
  attr.subAttributes = []
949
+ if (item.subAttributes && Array.isArray(item.subAttributes)) {
950
+ for (const el of item.subAttributes) {
951
+ if (typeof el !== 'object' || el === null || !el.name) continue
952
+ const obj: Record<string, any> = {
953
+ name: el.name,
954
+ type: el.type ?? 'string',
955
+ multiValued: el.mulitvalue ?? false,
956
+ description: el.description ?? '',
957
+ required: false,
958
+ caseExact: false,
959
+ mutability: el.mutability ?? 'readWrite',
960
+ returned: 'default',
961
+ uniqueness: 'none',
962
+ }
963
+ if (isV1) {
964
+ obj.readOnly = false
965
+ delete obj.mutability
966
+ delete obj.returned
967
+ delete obj.uniqueness
968
+ }
969
+ attr.subAttributes.push(obj)
970
+ }
971
+ }
909
972
  }
910
973
  }
911
974
  if (item['x-agent-schema']) {
@@ -1343,10 +1406,10 @@ export class ScimGateway {
1343
1406
  const baseEntity = ctx.routeObj.baseEntity
1344
1407
  const id = decodeURIComponent(path.basename(ctx.routeObj.id ?? '', '.json')) // supports <id>.json
1345
1408
 
1346
- if (!id || handle.getMethod === 'getEntitlements') {
1409
+ if (!id || handle.getMethod === 'getEntitlements' || handle.getMethod === 'getRoles') {
1347
1410
  let err: Error
1348
1411
  if (!id) err = new Error('missing id')
1349
- else err = new Error(`GET /${handle.description}/${id} is not supported. Instead use filter query on users e.g., /users?filter=entitlements[value eq "xxx"]`)
1412
+ else err = new Error(`GET /${handle.description}/${id} is not supported. Instead use filter query on users e.g., /users?filter=entitlements[value eq "xxx"] or /users?filter=roles[value eq "xxx"]`)
1350
1413
  const [e, statusCode] = utilsScim.jsonErr(this.config.scimgateway.scim.version, pluginName, 500, err)
1351
1414
  ctx.response.status = statusCode
1352
1415
  ctx.response.body = JSON.stringify(e)
@@ -1449,6 +1512,7 @@ export class ScimGateway {
1449
1512
  // getUsers
1450
1513
  // getGroups
1451
1514
  // getEntitlements
1515
+ // getRoles
1452
1516
  // ==========================================
1453
1517
  const getHandler = async (ctx: Context) => {
1454
1518
  const handle = handler[ctx.routeObj.handle]
@@ -1493,7 +1557,7 @@ export class ScimGateway {
1493
1557
  }
1494
1558
 
1495
1559
  let err
1496
- if (handle.getMethod === 'getEntitlements') {
1560
+ if (handle.getMethod === 'getEntitlements' || handle.getMethod === 'getRoles') {
1497
1561
  if (typeof (this as any)[handle.getMethod] !== 'function') err = new Error(`plugin method ${handle.getMethod}() not implemented`)
1498
1562
  }
1499
1563
  if (!err && getObj.attribute) {
@@ -1844,6 +1908,7 @@ export class ScimGateway {
1844
1908
  logger.warn(`${gwName} ${handle.createMethod} succeeded, but corresponding ${handle.getMethod} ${obj?.value} failed with error: ${err.message}`, { baseEntity: ctx?.routeObj?.baseEntity })
1845
1909
  }
1846
1910
  if (res?.Resources && Array.isArray(res.Resources) && res.Resources.length === 1) {
1911
+ utils.extendObj(res.Resources[0], jsonBody) // we might have endpoint like Entra ID that hasn’t caught up yet due to internal sync - ensure returned object reflects changes by doing a merge with patch payload (convertedScim)
1847
1912
  jsonBody = res.Resources[0]
1848
1913
  }
1849
1914
  delete jsonBody.password
@@ -2119,7 +2184,15 @@ export class ScimGateway {
2119
2184
  const ob = { attribute: 'id', operator: 'eq', value: id }
2120
2185
  logger.debug(`${gwName} calling ${handle.getMethod}`, { baseEntity: ctx?.routeObj?.baseEntity })
2121
2186
  res = await (this as any)[handle.getMethod](baseEntity, ob, [], ctx.passThrough)
2187
+
2188
+ if (res?.Resources && Array.isArray(res.Resources) && res.Resources.length === 1) {
2189
+ // we might have endpoint like Entra ID that hasn’t caught up yet due to internal sync
2190
+ // ensure returned object reflects changes by doing a merge with patch payload (convertedScim)
2191
+ res.Resources[0] = this.patchObj(res.Resources[0], finalScimdata) // merge
2192
+ if (res.Resources[0].password) delete res.Resources[0].password
2193
+ }
2122
2194
  }
2195
+
2123
2196
  return response(res)
2124
2197
  } catch (err: any) {
2125
2198
  // check if error caused by: add existing member or remove none existing member => should not be an error
@@ -3430,6 +3503,7 @@ export class ScimGateway {
3430
3503
  case 'GET users':
3431
3504
  case 'GET groups':
3432
3505
  case 'GET entitlements':
3506
+ case 'GET roles':
3433
3507
  if (ctx.routeObj.id) await getHandlerId(ctx)
3434
3508
  else await getHandler(ctx)
3435
3509
  return await onAfterHandle(ctx)
@@ -3952,6 +4026,17 @@ export class ScimGateway {
3952
4026
  return null
3953
4027
  }
3954
4028
 
4029
+ /**
4030
+ * patchObj returns object updated with the modify user PATCH convertedScim which is sent to plugin
4031
+ * @param userObj "user object"
4032
+ * @param attrObj "the attrObj (convertedSCIM PATCH payload) sent to plugin"
4033
+ * @returns "user object updated according to PATCH payload - attrObj"
4034
+ **/
4035
+ patchObj(userObj: Record<string, any>, attrObj: Record<string, any>): any {
4036
+ const isMultiValueTypes = (attr: string) => this.isMultiValueTypes(attr)
4037
+ return utilsScim.patchObj(userObj, attrObj, isMultiValueTypes)
4038
+ }
4039
+
3955
4040
  /**
3956
4041
  * endpointMapper maps inbound SCIM and outbound endpoint attributes both ways
3957
4042
  * @param direction 'outbound' (to the endpoint) or 'inbound' (SCIM response)
package/lib/utils-scim.ts CHANGED
@@ -376,6 +376,126 @@ export function convertedScim20(obj: any, multiValueTypes: string[]): any {
376
376
  return convertedScim(scimdata, multiValueTypes)
377
377
  }
378
378
 
379
+ /**
380
+ * patchObj updates userObj with the PATCH convertedScim body sent to plugin
381
+ */
382
+ export function patchObj(userObj: Record<string, any>, attrObj: Record<string, any>, isMultiValueTypes: (attribute: string) => boolean): any {
383
+ if (typeof userObj !== 'object') return userObj
384
+ if (typeof attrObj !== 'object') return userObj
385
+
386
+ for (const key in attrObj) {
387
+ if (Array.isArray(attrObj[key])) { // standard, not using type (e.g roles/groups) or skipTypeConvert=true
388
+ const delArr = attrObj[key].filter(el => el.operation === 'delete')
389
+ const addArr = attrObj[key].filter(el => (!el.operation || el.operation !== 'delete'))
390
+ if (!userObj[key] || !Array.isArray(userObj[key])) userObj[key] = []
391
+ // delete
392
+ userObj[key] = userObj[key].filter((el: Record<string, any>) => {
393
+ const index = delArr.findIndex((e) => {
394
+ let elExist = false
395
+ for (const k in e) {
396
+ if (k === 'primary' || k === 'operation') continue
397
+ if (e[k] !== el[k]) {
398
+ elExist = false
399
+ break
400
+ }
401
+ elExist = true
402
+ }
403
+ return elExist
404
+ })
405
+ if (index >= 0) return false
406
+ else return true
407
+ })
408
+ // add
409
+ addArr.forEach((el) => {
410
+ if (Object.prototype.hasOwnProperty.call(el, 'primary')) {
411
+ if (el.primary === true || (typeof el.primary === 'string' && el.primary.toLowerCase() === 'true')) {
412
+ const index = userObj[key].findIndex((e: Record<string, any>) => e.primary === el.primary)
413
+ if (index >= 0) {
414
+ if (key === 'roles') userObj[key].splice(index, 1) // roles, delete existing role having primary attribute true (new role with primary will be added)
415
+ else userObj[key][index].primary = undefined // remove primary attribute, only one primary
416
+ }
417
+ }
418
+ }
419
+ const index = userObj[key].findIndex((e: Record<string, any>, _index: number) => { // avoid adding existing
420
+ if (el.value && el.value === e.value && el.type === e.type) {
421
+ for (const k in el) {
422
+ e[k] = el[k]
423
+ }
424
+ return true
425
+ }
426
+ let elExist = false
427
+ for (const k in el) {
428
+ if (el[k] !== e[k]) {
429
+ if (k === 'primary') continue
430
+ elExist = false
431
+ break
432
+ }
433
+ elExist = true
434
+ }
435
+ return elExist
436
+ })
437
+ if (index < 0) userObj[key].push(el)
438
+ })
439
+ } else if (isMultiValueTypes(key)) { // "type converted object" logic and original blank type having type "undefined"
440
+ if (!attrObj[key]) delete userObj[key] // blank or null
441
+ for (const el in attrObj[key]) {
442
+ attrObj[key][el].type = el
443
+ if (attrObj[key][el].operation && attrObj[key][el].operation === 'delete') { // delete multivalue
444
+ let type: any = el
445
+ if (type === 'undefined') type = undefined
446
+ if (Array.isArray(userObj[key])) {
447
+ userObj[key] = userObj[key].filter((e: Record<string, any>) => e.type !== type)
448
+ if (userObj[key].length < 1) delete userObj[key]
449
+ }
450
+ } else { // modify/create multivalue
451
+ if (!userObj[key]) userObj[key] = []
452
+ if (attrObj[key][el].primary) { // remove any existing primary attribute, should only have one primary set
453
+ const primVal = attrObj[key][el].primary
454
+ if (primVal === true || (typeof primVal === 'string' && primVal.toLowerCase() === 'true')) {
455
+ const index = userObj[key].findIndex((e: Record<string, any>) => e.primary === primVal)
456
+ if (index >= 0) {
457
+ userObj[key][index].primary = undefined
458
+ }
459
+ }
460
+ }
461
+ const found = userObj[key].find((e: Record<string, any>, i: any) => {
462
+ if (e.type === el || (!e.type && el === 'undefined')) {
463
+ for (const k in attrObj[key][el]) {
464
+ userObj[key][i][k] = attrObj[key][el][k]
465
+ if (k === 'type' && attrObj[key][el][k] === 'undefined') delete userObj[key][i][k] // don't store with type "undefined"
466
+ }
467
+ return true
468
+ } else return false
469
+ })
470
+ if (attrObj[key][el].type && attrObj[key][el].type === 'undefined') delete attrObj[key][el].type // don't store with type "undefined"
471
+ if (!found) userObj[key].push(attrObj[key][el]) // create
472
+ }
473
+ }
474
+ } else {
475
+ // None multi value attribute, blank will be deleted
476
+ if (typeof (attrObj[key]) === 'object' && attrObj[key] !== null) {
477
+ // name.familyName=Bianchi
478
+ if (!userObj[key]) userObj[key] = {} // e.g name object does not exist
479
+ for (const sub in attrObj[key]) {
480
+ if (!userObj[key]) userObj[key] = {}
481
+ if (Object.prototype.hasOwnProperty.call(attrObj[key][sub], 'value')
482
+ && attrObj[key][sub].value === '') delete userObj[key][sub] // object having blank value attribute e.g. {"manager": {"value": "",...}}
483
+ else if (attrObj[key][sub] === '') delete userObj[key][sub]
484
+ else {
485
+ if (!userObj[key]) userObj[key] = {} // may have been deleted by length check below
486
+ userObj[key][sub] = attrObj[key][sub]
487
+ }
488
+ if (Object.keys(userObj[key]).length < 1) delete userObj[key]
489
+ }
490
+ } else {
491
+ if (attrObj[key] === '' || attrObj[key] === null) delete userObj[key]
492
+ else userObj[key] = attrObj[key]
493
+ }
494
+ }
495
+ }
496
+ return userObj
497
+ }
498
+
379
499
  // recursiveStrMap is used by endpointMapper() for converting obj according to endpointMap type definition
380
500
  const recursiveStrMap = function (direction: string, dotMap: any, obj: any, dotPath: any) {
381
501
  for (const key in obj) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scimgateway",
3
- "version": "6.1.16",
3
+ "version": "6.1.18",
4
4
  "type": "module",
5
5
  "description": "Using SCIM protocol as a gateway for user provisioning to other endpoints",
6
6
  "author": "Jarle Elshaug <jarle.elshaug@gmail.com> (https://elshaug.xyz)",