scimgateway 6.2.0 → 6.2.2

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.
@@ -14,18 +14,19 @@
14
14
  // Notes: For Symantec/Broadcom/CA Provisioning - Use ConnectorXpress, import metafile
15
15
  // "node_modules\scimgateway\config\resources\Azure - ScimGateway.xml" for creating endpoint
16
16
  //
17
- // 'GET /Roles' retrieves a list of all available roles specified by type (e.g. Permanent or Eligible) and corresponds with the users attribute roles.
18
- // 'GET /Entitlements' retrieves a list of all available entitlements specified by type (e.g. License) and corresponds with the users attribute entitlements.
17
+ // 'GET /Roles' retrieves a list of all available roles specified by type (Permanent or Eligible) and corresponds with the users attribute roles.
18
+ // 'GET /Entitlements' retrieves a list of all available entitlements specified by type (License or AccessPackage) and corresponds with the users attribute entitlements.
19
19
  //
20
20
  // Using "Custom SCIM" attributes defined in configuration endpoint.entity.map
21
21
  // Schema generated according mapping configuration.
22
22
  // Note:
23
- // - 'map.user.signInActivity' requires Entra ID Premium license and API permissions 'AuditLog.Read.All'. Remove 'signInActivity' mapping if conditions not met".
24
- // - 'map.user.roles relates to both Permanent roles and PIM Eligible roles.
23
+ // - 'map.user.signInActivity' requires Entra ID Premium license and API permissions 'AuditLog.Read.All'.
24
+ // - 'map.user.entitlements' relates to Licenses and Access Packages. Access Packages requires API permissions 'EntitlementManagement.ReadWrite.All'
25
+ // - 'map.user.roles relates to standard Permanent roles and PIM Permanent and Eligible roles.
25
26
  // PIM is included on tenant having P2 or Governance License and requires following API permissions:
26
27
  // - PIM Eligible roles requires API permissions 'RoleEligiblitySchedule.ReadWrite.All'
27
28
  // - PIM Permanent roles requires API permissions 'RoleManagement.ReadWrite.Directory'
28
- // - Remove 'roles' mapping if conditions not met
29
+ // - Remove mapping if conditions not met
29
30
  //
30
31
  // /User SCIM (custom) Endpoint (AAD)
31
32
  // --------------------------------------------------------------------------------------------
@@ -59,7 +60,7 @@
59
60
  // Proxy Addresses proxyAddresses.value proxyAddresses
60
61
  // Groups groups - virtual readOnly N/A
61
62
  // Roles roles roles (roleAssignments/roleEligibilitySchedules) - type=Permanent/Eligiable, value=id, display=role display name
62
- // Entitlements entitlements entitlements (assignedLicenses) - type=License, value=skuId and display=<user-friendly license name>
63
+ // Entitlements entitlements entitlements (assignedLicenses) - type=License, value=skuId and display=user-friendly-license-name / type=AccessPackage, value=AP-id and display=AP-displayName
63
64
  // SignInActivity signInActivity signInActivity (lastSignInDateTime, lastSuccessfulSignInDateTime and lastNonInteractiveSignInDateTime), Note: Requires Entra ID Premium license and API permissions: 'AuditLog.Read.All'. Remove this mapping if conditions not met".
64
65
  //
65
66
  // /Group SCIM (custom) Endpoint (AAD)
@@ -78,11 +79,12 @@ const scimgateway = new ScimGateway()
78
79
  const helper = new HelperRest(scimgateway)
79
80
  const config = scimgateway.getConfig()
80
81
  scimgateway.authPassThroughAllowed = false
82
+ scimgateway.pluginAndOrFilterEnabled = true
81
83
  // end - mandatory plugin initialization
82
84
 
83
85
  const newHelper = new HelperRest(scimgateway)
84
- const entitlementsByValues: Record<string, any> = {} // {skuId: {...}}
85
- const rolesByValues: Record<string, any> = {} // {skuId: {...}}
86
+ const entitlementsByValues: Record<string, any> = {}
87
+ const rolesByValues: Record<string, any> = {}
86
88
  const rolesAssignments: Record<string, any> = {}
87
89
  const lockEntitlement = new scimgateway.Lock()
88
90
  const lockRole = new scimgateway.Lock()
@@ -145,7 +147,7 @@ if (!groupAttributes.includes('members.value')) groupAttributes.push('members.va
145
147
  for (const baseEntity in config.entity) {
146
148
  try {
147
149
  permission[baseEntity] = {}
148
- const [signInResult, eligibleResult] = await Promise.allSettled([
150
+ const [signInResult, eligibleResult, permanentScheduleResult, accessPackageResult] = await Promise.allSettled([
149
151
  (async () => {
150
152
  if (!mapAttributesTo.includes('signInActivity')) throw new Error('skipping signInActivity check')
151
153
  await helper.doRequest(baseEntity, 'GET', '/users?$top=1&$select=id,signInActivity', null, null)
@@ -154,6 +156,14 @@ if (!groupAttributes.includes('members.value')) groupAttributes.push('members.va
154
156
  if (!mapAttributesTo.includes('roles')) throw new Error('skipping eligible check')
155
157
  await helper.doRequest(baseEntity, 'GET', '/roleManagement/directory/roleEligibilityScheduleInstances?$top=1', null, null)
156
158
  })(),
159
+ (async () => {
160
+ if (!mapAttributesTo.includes('roles')) throw new Error('skipping permanent schedule check')
161
+ await helper.doRequest(baseEntity, 'GET', '/roleManagement/directory/roleAssignmentScheduleInstances?$top=1', null, null)
162
+ })(),
163
+ (async () => {
164
+ if (!mapAttributesTo.includes('entitlements')) throw new Error('skipping access package check')
165
+ await helper.doRequest(baseEntity, 'GET', '/identityGovernance/entitlementManagement/accessPackages?$top=1&$select=id', null, null)
166
+ })(),
157
167
  ])
158
168
  if (signInResult.status === 'fulfilled') {
159
169
  permission[baseEntity].signInActivity = true
@@ -165,7 +175,19 @@ if (!groupAttributes.includes('members.value')) groupAttributes.push('members.va
165
175
  permission[baseEntity].eligible = true
166
176
  } else {
167
177
  permission[baseEntity].eligible = false
168
- if (mapAttributesTo.includes('roles')) scimgateway.logError(baseEntity, `PIM eligible role functionality has been deactivated because it requires either a P2 or Governance license, as well as the API permission 'RoleEligibilitySchedule.ReadWrite.All'.`)
178
+ if (mapAttributesTo.includes('roles')) scimgateway.logError(baseEntity, `PIM eligible role functionality has been deactivated because it requires either a P2 or Governance license, as well as the API permission 'RoleEligibilitySchedule.ReadWrite.All'`)
179
+ }
180
+ if (permanentScheduleResult.status === 'fulfilled') {
181
+ permission[baseEntity].permanentSchedule = true
182
+ } else {
183
+ permission[baseEntity].permanentSchedule = false
184
+ if (mapAttributesTo.includes('roles')) scimgateway.logError(baseEntity, `PIM permanent role functionality has been deactivated because it requires either a P2 or Governance license, as well as the API permission 'RoleManagement.ReadWrite.Directory'`)
185
+ }
186
+ if (accessPackageResult.status === 'fulfilled') {
187
+ permission[baseEntity].accessPackage = true
188
+ } else {
189
+ permission[baseEntity].accessPackage = false
190
+ if (mapAttributesTo.includes('roles')) scimgateway.logError(baseEntity, `IGA Access Packages functionality has been deactivated because it requires API permission 'EntitlementManagement.ReadWrite.All'`)
169
191
  }
170
192
  } catch (err) {}
171
193
  }
@@ -176,9 +198,10 @@ if (!groupAttributes.includes('members.value')) groupAttributes.push('members.va
176
198
  // =================================================
177
199
  scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
178
200
  //
179
- // "getObj" = { attribute: <>, operator: <>, value: <>, rawFilter: <>, startIndex: <>, count: <> }
201
+ // "getObj" = { attribute: <>, operator: <>, value: <>, rawFilter: <>, startIndex: <>, count: <>, and/or: <getObj> }
180
202
  // rawFilter is always included when filtering
181
203
  // attribute, operator and value are included when requesting unique object or simpel filtering
204
+ // and/or will be included and the value set to corresponding getObj if the mandatory plugin initialization have 'scimgateway.pluginAndOrFilterEnabled = true' and the request query filter includes simple and/or logic
182
205
  // See comments in the "mandatory if-else logic - start"
183
206
  //
184
207
  // "attributes" is array of attributes to be returned - if empty, all supported attributes should be returned
@@ -189,12 +212,14 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
189
212
  //
190
213
  const action = 'getUsers'
191
214
  scimgateway.logDebug(baseEntity, `handling ${action} getObj=${getObj ? JSON.stringify(getObj) : ''} attributes=${attributes} passThrough=${ctx ? 'true' : 'false'}`)
215
+
192
216
  const ret: any = {
193
217
  Resources: [],
194
218
  totalResults: null,
195
219
  }
196
-
220
+ let response: any
197
221
  let selectAttributes: string[] = []
222
+
198
223
  if (attributes.length > 0) {
199
224
  for (const attribute of attributes) {
200
225
  const [endpointAttr] = scimgateway.endpointMapper('outbound', attribute, config.map.user)
@@ -216,7 +241,7 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
216
241
 
217
242
  const method = 'GET'
218
243
  const body = null
219
- let path
244
+ let path: string = ''
220
245
  let options: Record<string, any> = {}
221
246
  let isExpandManager = true
222
247
 
@@ -232,12 +257,6 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
232
257
  } else if (getObj.operator === 'eq' && getObj.attribute === 'group.value') {
233
258
  // optional - only used when groups are member of users, not default behavior - correspond to getGroupUsers() in versions < 4.x.x
234
259
  throw new Error(`${action} error: not supporting groups member of user filtering: ${getObj.rawFilter}`)
235
- } else if (getObj.operator === 'pr' && getObj.attribute === 'entitlements') { // pr - presence of (only return objects having getObj.attribute).
236
- path = `/users?$top=${getObj.count}&$count=true&$filter=assignedLicenses/$count ne 0&$select=${selectAttributes.join(',')}` // TODO: new logic when entitlements includes more than one type
237
- isExpandManager = false
238
- } else if (getObj.operator === 'eq' && getObj.attribute === 'entitlements.type' && getObj.value?.toLowerCase() === 'license') {
239
- path = `/users?$top=${getObj.count}&$count=true&$filter=assignedLicenses/$count ne 0&$select=${selectAttributes.join(',')}`
240
- isExpandManager = false
241
260
  } else {
242
261
  // optional - simpel filtering
243
262
  if (getObj.attribute) {
@@ -248,37 +267,104 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
248
267
  if (eArr[0] == 'signInActivity' && eArr.length === 2) {
249
268
  endpointAttr = eArr.join('/') // signInActivity/lastSuccessfulSignInDateTime - filter=signInActivity.lastSuccessfulSignInDateTime lt "2025-12-04T00:00:00Z"
250
269
  }
251
- let odataFilter = operatorMap[getObj.operator](endpointAttr, getObj.value)
252
270
 
271
+ let odataFilter: string | undefined = operatorMap[getObj.operator](endpointAttr, getObj.value)
272
+
273
+ // role and entitlements filtering
253
274
  const arr = getObj.attribute.split('.')
254
- if (arr.length === 2) {
275
+ if (['roles', 'entitlements'].includes(arr[0])) {
276
+ odataFilter = undefined
277
+
278
+ let type
279
+ let obj // set to the filter object based on the "type-object" and the use of and-object
280
+ if (getObj.attribute === `${arr[0]}.type`) {
281
+ type = getObj.value
282
+ if (getObj.and) obj = getObj.and
283
+ else obj = getObj
284
+ } else if (getObj.and?.attribute === `${arr[0]}.type`) {
285
+ type = getObj.and.value
286
+ obj = getObj
287
+ } else obj = getObj // no type defined
288
+
255
289
  if (config.map.user[arr[0]] && ['complexArray', 'complexObject'].includes(config.map.user[arr[0]]?.type)) {
256
- if (arr[0] === 'entitlements') { // using entitlements for license
257
- const skuIdDefs = await getSkuIdDefs(baseEntity, {}, [], ctx)
258
- const skuIdArr = searchSkuIdDefs(skuIdDefs, getObj)
259
- if (skuIdArr.length === 0) return ret
260
- if (skuIdArr.length === 1) odataFilter = `assignedLicenses/any(x:x/skuId eq ${skuIdArr[0]})`
261
- else throw new Error(`filter error: not supporting ${getObj.rawFilter} - entitlements filter resulted in more than one skuId which is not supported, unless 'filter=entitlements.type eq "License"' is used. For guaranteed uniqueness use opearator 'eq'. Example: filter=entitlements.value eq "skuId"`)
262
- }
263
- }
290
+ if (arr[0] === 'roles') {
291
+ if (type && type !== 'Permanent' && type !== 'Eligible') throw new Error(`${action} filter error: when using roles.type, the type must be either 'Permanent' or 'Eligible`)
292
+ const o = await getUsersByRole(baseEntity, obj, (type) ? decodeURIComponent(type) as 'Permanent' | 'Eligible' : undefined, ctx)
293
+
294
+ if (!Array.isArray(o) || o.length === 0) return ret
295
+ const fnArr: { fn: () => Promise<any> }[] = []
296
+ for (const id of o) {
297
+ const userPath = `/users/${id}?$select=${selectAttributes.join(',')}`
298
+ const fn = () => helper.doRequest(baseEntity, 'GET', userPath, null, ctx?.headers ? { headers: ctx?.headers } : undefined)
299
+ fnArr.push({ fn })
300
+ }
301
+ response = { body: { value: [] } }
302
+ await fnCunckExecute(fnArr, response.body.value) // fnCunckExecute results in response.body.value and evaluated later
303
+ if (response.body.value.length === 0) return ret
304
+ } else if (arr[0] === 'entitlements') { // using entitlements for licenses and access packages
305
+ if (getObj.attribute !== 'entitlements.type' && getObj.and?.attribute !== 'entitlements.type') throw new Error(`${action} filter error: mandatory entitlements.type is missing, examples: entitlements[type eq "xxx"], entitlements[type eq "xxx" and value eq "xxx"], entitlements[type eq "xxx" and display <eq/co/sw> "xxx"]`)
306
+ if (type === 'License') {
307
+ if (obj.operator === 'eq' && obj.attribute === 'entitlements.type') { // entitlements[type eq "License"]
308
+ path = `/users?$top=${getObj.count}&$count=true&$filter=assignedLicenses/$count ne 0&$select=${selectAttributes.join(',')}`
309
+ isExpandManager = false
310
+ } else { // entitlements[type eq "License" and value eq "xxx"], entitlements[type eq "License" and display <eq/co/sw> "xxx"]
311
+ const skuIdArr = await searchEntitlementsByValues(baseEntity, obj, 'License', ctx)
312
+ if (skuIdArr.length === 0) return ret
313
+ if (skuIdArr.length === 1) odataFilter = `assignedLicenses/any(x:x/skuId eq ${skuIdArr[0]})`
314
+ else throw new Error(`${action} filter error: not supporting: ${getObj.rawFilter} - entitlements filter resulted in more than one skuId which is not supported. For guaranteed uniqueness use: filter=entitlements[type eq "License" and value eq "<skuId>"]`)
315
+ }
316
+ } else if (type === 'AccessPackage') {
317
+ let o: Record<string, any> | undefined
318
+ if (obj.operator === 'eq' && obj.attribute === 'entitlements.type') { // entitlements[type eq "AccessPackage"]
319
+ o = await getUsersByAccessPackage(baseEntity, obj, ctx?.headers ? { headers: ctx?.headers } : undefined)
320
+ } else { // entitlements[type eq "AccessPackage" and value eq "xxx"], entitlements[type eq "AccessPackage" and display <eq/co/sw> "xxx"]
321
+ const idArr = await searchEntitlementsByValues(baseEntity, obj, 'AccessPackage', ctx)
322
+ if (idArr.length === 0) return ret
323
+ else if (idArr.length > 1) throw new Error(`${action} filter error: not supporting: ${getObj.rawFilter} - entitlements filter resulted in more than one id which is not supported. For guaranteed uniqueness use: filter=entitlements[type eq "AccessPackage" and value eq "<id>"]`)
324
+ o = await getUsersByAccessPackage(baseEntity, { attribute: 'entitlements.value', operator: 'eq', value: idArr[0] }, ctx?.headers ? { headers: ctx?.headers } : undefined)
325
+ }
326
+ if (typeof o !== 'object' || o === null || Object.keys(o).length === 0) return ret
327
+ const isAttrsOk = attributes.length > 0 && attributes.length < 3 && (attributes.includes('id') || attributes.includes('displayName'))
328
+ const fnArr: { fn: () => Promise<any> }[] = []
329
+ for (const key in o) {
330
+ if (isAttrsOk) ret.Resources.push(o[key])
331
+ else {
332
+ const userPath = `/users/${key}?$select=${selectAttributes.join(',')}`
333
+ const fn = () => helper.doRequest(baseEntity, 'GET', userPath, null, ctx?.headers ? { headers: ctx?.headers } : undefined)
334
+ fnArr.push({ fn })
335
+ }
336
+ }
337
+ if (isAttrsOk) return ret
338
+ else {
339
+ response = { body: { value: [] } }
340
+ await fnCunckExecute(fnArr, response.body.value) // fnCunckExecute results in response.body.value and evaluated later
341
+ if (response.body.value.length === 0) return ret
342
+ }
343
+ } else throw new Error(`${action} error: entitlements.type must be either "License" or "AccessPackage"`)
344
+ } else throw new Error(`${action} error: not supporting filtering: ${getObj.rawFilter}`)
345
+ if (getObj.and) delete getObj.and // delete to flag done and final check will succeed
346
+ } else throw new Error(`${action} error: not supporting filtering: ${getObj.rawFilter}`)
264
347
  }
265
348
 
266
- if (!odataFilter) {
267
- const [supported] = scimgateway.endpointMapper('inbound', 'displayName,userPrincipalName,mail,proxyAddresses', config.map.user)
268
- throw new Error(`${action} error: Entra ID only supports operator '${getObj.operator}' for a limited set of attributes (e.g., SCIM attributes: ${supported}) and therefore not supporting filter: ${getObj.rawFilter}`)
269
- }
349
+ if (odataFilter !== undefined) {
350
+ if (odataFilter === '') {
351
+ const [supported] = scimgateway.endpointMapper('inbound', 'displayName,userPrincipalName,mail,proxyAddresses', config.map.user)
352
+ throw new Error(`${action} error: Entra ID only supports operator '${getObj.operator}' for a limited set of attributes (e.g., SCIM attributes: ${supported}) and therefore not supporting filter: ${getObj.rawFilter}`)
353
+ }
270
354
 
271
- // advanced queries like 'contains', '$search', and '$count' require the ConsistencyLevel header.
272
- if (!options.headers) options.headers = {}
273
- options.headers.ConsistencyLevel = 'eventual'
355
+ if (odataFilter.startsWith('$search=')) {
356
+ path = `/users?$top=${getObj.count}&$count=true&${odataFilter}&$select=${selectAttributes.join(',')}`
357
+ isExpandManager = false // using $search we cannot include $expand=manager
358
+ } else { // eq, sw, co, etc.
359
+ path = `/users?$top=${getObj.count}&$count=true&$filter=${odataFilter}&$select=${selectAttributes.join(',')}`
360
+ }
274
361
 
275
- if (odataFilter.startsWith('$search=')) {
276
- path = `/users?$top=${getObj.count}&$count=true&${odataFilter}&$select=${selectAttributes.join(',')}`
277
- isExpandManager = false // using $search we cannot include $expand=manager
278
- } else { // eq, sw, co, etc.
279
- path = `/users?$top=${getObj.count}&$count=true&$filter=${odataFilter}&$select=${selectAttributes.join(',')}`
362
+ // advanced queries like 'contains', '$search', and '$count' require the ConsistencyLevel header.
363
+ if (!options.headers) options.headers = {}
364
+ options.headers.ConsistencyLevel = 'eventual'
280
365
  }
281
366
  }
367
+
282
368
  if (getObj.operator === 'pr' || getObj.operator === 'not pr') isExpandManager = false
283
369
  }
284
370
  } else if (getObj.rawFilter) {
@@ -289,9 +375,15 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
289
375
  // mandatory - no filtering (!getObj.operator && !getObj.rawFilter) - all users to be returned - correspond to exploreUsers() in versions < 4.x.x
290
376
  path = `/users?$top=${getObj.count}&$count=true&$select=${selectAttributes.join(',')}`
291
377
  }
378
+
379
+ if (getObj.and || getObj.or) {
380
+ // plugin have enabled 'scimgateway.pluginAndOrFilterEnabled' and the query includes an additonal and/or getObj that must to be handled and combined with the initial getObj
381
+ // we could have this logic above, if not it must be defined here
382
+ throw new Error(`${action} error: logic for handling and/or filter is not implemented by plugin, not supporting: ${getObj.rawFilter}`)
383
+ }
292
384
  // mandatory if-else logic - end
293
385
 
294
- if (!path) throw new Error(`${action} error: mandatory if-else logic not fully implemented`)
386
+ if (!path && !response?.body?.value) throw new Error(`${action} error: mandatory if-else logic not fully implemented`)
295
387
 
296
388
  if (path.includes('$count=true')) { // $count=true requires ConsistencyLevel
297
389
  // note: when using $expand, the $count=true might be ignored by target endpoint and the ctx.paging.totalResults updated by doReqest() will be incremental
@@ -305,12 +397,13 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
305
397
  else ctx.paging = paging
306
398
 
307
399
  try {
308
- let response: any
309
400
  if (isExpandManager && selectAttributes.includes('manager')) {
310
401
  path += '&$expand=manager($select=userPrincipalName)'
311
402
  }
312
403
 
313
- response = await helper.doRequest(baseEntity, method, path, body, ctx, options)
404
+ if (!response?.body?.value) {
405
+ response = await helper.doRequest(baseEntity, method, path, body, ctx, options)
406
+ }
314
407
 
315
408
  if (!response.body?.value) {
316
409
  const singleUser = response.body
@@ -320,7 +413,7 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
320
413
  throw new Error(`invalid response: ${JSON.stringify(response)}`)
321
414
  }
322
415
  const fnArr: { index: number, fn: () => Promise<any> }[] = []
323
- const skuIdDefs = await getSkuIdDefs(baseEntity, {}, [], ctx)
416
+ const byValues = await getEntitlementsByValues(baseEntity, ctx)
324
417
 
325
418
  // include manager
326
419
  if (!isExpandManager && selectAttributes.includes('manager')) {
@@ -345,37 +438,56 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
345
438
 
346
439
  // attribute cleanup and mapping
347
440
  for (let i = 0; i < response.body.value.length; ++i) {
348
- if (response.body.value[i].manager?.userPrincipalName) {
349
- let managerId = response.body.value[i].manager.userPrincipalName
350
- if (managerId) response.body.value[i].manager = managerId
351
- else delete response.body.value[i].manager
352
- }
353
-
354
- if (response.body.value[i].signInActivity) {
355
- delete response.body.value[i].signInActivity.lastSignInRequestId
356
- delete response.body.value[i].signInActivity.lastNonInteractiveSignInRequestId
357
- delete response.body.value[i].signInActivity.lastSuccessfulSignInRequestId
441
+ const obj = response.body.value[i]
442
+ if (obj.manager?.userPrincipalName) {
443
+ let managerId = obj.manager.userPrincipalName
444
+ if (managerId) obj.manager = managerId
445
+ else delete obj.manager
358
446
  }
359
447
 
360
- if ((attributes.includes('roles') || attributes.length === 0) && mapAttributesTo.includes('roles')) {
361
- response.body.value[i].roles = await getUserRoles(baseEntity, response.body.value[i].id, response.body.value[i].groups, ctx?.headers ? { headers: ctx?.headers } : undefined)
448
+ if (obj.signInActivity) {
449
+ delete obj.signInActivity.lastSignInRequestId
450
+ delete obj.signInActivity.lastNonInteractiveSignInRequestId
451
+ delete obj.signInActivity.lastSuccessfulSignInRequestId
362
452
  }
363
453
 
364
- if (attributes.includes('entitlements') || attributes.length === 0) {
365
- if (mapAttributesTo.includes('entitlements')) { // assignedLicenses
366
- if (response.body.value[i].assignedLicenses && Array.isArray(response.body.value[i].assignedLicenses)) {
367
- if (!response.body.value[i].entitlements) response.body.value[i].entitlements = []
368
- for (const lic of response.body.value[i].assignedLicenses) {
369
- if (lic.skuId && skuIdDefs[lic.skuId]) response.body.value[i].entitlements.push(skuIdDefs[lic.skuId])
454
+ // include roles and entitlements
455
+ if (obj.id) {
456
+ const roles = async (obj: Record<string, any>): Promise<Record<string, any>[]> => {
457
+ // roles type=Permanent/Eligible
458
+ if ((attributes.includes('roles') || attributes.length === 0) && mapAttributesTo.includes('roles')) {
459
+ return await getUserRoles(baseEntity, obj.id, obj.groups, false, ctx?.headers ? { headers: ctx?.headers } : undefined)
460
+ } else return []
461
+ }
462
+ const entitlements = async (obj: Record<string, any>): Promise<Record<string, any>[]> => {
463
+ const result: Record<string, any>[] = []
464
+ if ((attributes.includes('entitlements') || attributes.length === 0) && mapAttributesTo.includes('entitlements')) {
465
+ // entitlements type=License => assignedLicenses
466
+ if (obj.assignedLicenses && Array.isArray(obj.assignedLicenses)) {
467
+ for (const lic of response.body.value[i].assignedLicenses) {
468
+ if (lic.skuId && byValues[lic.skuId]) result.push(byValues[lic.skuId])
469
+ }
470
+ }
471
+ // entitlements type=AccessPackage
472
+ if (permission[baseEntity]?.accessPackage) {
473
+ const aps = await getUserAccessPackages(baseEntity, obj.id, false, ctx?.headers ? { headers: ctx?.headers } : undefined)
474
+ result.push(...aps)
370
475
  }
371
476
  }
477
+ return result
372
478
  }
479
+ const arrResolve = await Promise.all([
480
+ roles(obj),
481
+ entitlements(obj),
482
+ ])
483
+ obj.roles = arrResolve[0]
484
+ obj.entitlements = arrResolve[1]
373
485
  }
374
486
 
375
487
  // map to inbound
376
- const [scimObj] = scimgateway.endpointMapper('inbound', response.body.value[i], config.map.user) // endpoint => SCIM/CustomSCIM attribute standard
488
+ const [scimObj] = scimgateway.endpointMapper('inbound', obj, config.map.user) // endpoint => SCIM/CustomSCIM attribute standard
377
489
  if (scimObj && typeof scimObj === 'object' && Object.keys(scimObj).length > 0) {
378
- if (response.body.value[i].groups && !scimObj.groups) scimObj.groups = response.body.value[i].groups // not included in mapper
490
+ if (obj.groups && !scimObj.groups) scimObj.groups = obj.groups // not included in mapper
379
491
  ret.Resources.push(scimObj)
380
492
  }
381
493
  }
@@ -467,7 +579,7 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
467
579
 
468
580
  // roles and entitlements only supported for getUsers - readOnly
469
581
  // if (attrObj.roles) delete attrObj.roles
470
- if (attrObj.entitlements) delete attrObj.entitlements
582
+ // if (attrObj.entitlements) delete attrObj.entitlements
471
583
 
472
584
  const [parsedAttrObj]: Record<string, any>[] = scimgateway.endpointMapper('outbound', attrObj, config.map.user) // SCIM/CustomSCIM => endpoint attribute standard
473
585
  if (parsedAttrObj instanceof Error) throw (parsedAttrObj) // error object
@@ -479,8 +591,8 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
479
591
  delete parsedAttrObj.manager
480
592
  }
481
593
 
482
- // const fnArr: Array<() => Promise<any>> = []
483
594
  const fnArr: { fn: () => Promise<any> }[] = []
595
+ let isRolesChanged = false
484
596
 
485
597
  const getValueByDisplayName = async (display: string): Promise<string | undefined> => {
486
598
  const res = await scimgateway.getRoles(baseEntity, { attribute: 'displayName', operator: 'eq', value: display }, [], ctx)
@@ -488,6 +600,7 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
488
600
  return undefined
489
601
  }
490
602
 
603
+ // Roles
491
604
  if (Object.hasOwn(parsedAttrObj, 'roles') && Array.isArray(parsedAttrObj.roles)) {
492
605
  const r: Record<string, any>[] = []
493
606
  for (const el of parsedAttrObj.roles) {
@@ -514,32 +627,40 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
514
627
 
515
628
  const rolesAdd: Record<string, any> [] = r.filter(m => m.operation !== 'delete')
516
629
  const rolesRemove: Record<string, any> [] = r.filter(m => m.operation === 'delete')
517
- let isRolesChanged = false
518
630
 
519
631
  if (rolesAdd.length > 0 || rolesRemove.length > 0) {
520
- const currentRoles = await getUserRoles(baseEntity, id, [], ctx, true)
632
+ const currentRoles = await getUserRoles(baseEntity, id, [], true, ctx)
521
633
 
522
634
  for (const r of rolesAdd) {
523
- const roleExist = currentRoles.filter(m => m.value === r.value && m.type === r.type)
635
+ const roleExist = currentRoles.filter(c => c.value === r.value && c.type === r.type)
524
636
  if (roleExist.length > 0) continue // exlude adding already assigned
525
-
526
- const method = 'POST'
527
- let path = `/roleManagement/directory/roleAssignments`
528
- const body: Record<string, any> = {
529
- principalId: id,
530
- roleDefinitionId: r.value,
531
- directoryScopeId: '/',
532
- }
533
- if (r.type === 'Eligible') {
534
- path = '/roleManagement/directory/roleEligibilityScheduleRequests'
535
- body.action = 'AdminAssign'
536
- body.justification = 'Assigned by SCIM Gateway'
537
- body.scheduleInfo = {
538
- startDateTime: new Date().toISOString(),
539
- expiration: {
540
- type: 'noExpiration',
637
+ let method = 'POST'
638
+ let path = ''
639
+ let body: Record<string, any> = {}
640
+
641
+ if ((r.type === 'Eligible' && permission[baseEntity]?.eligible) || (r.type === 'Permanent' && permission[baseEntity]?.permanentSchedule)) {
642
+ path = (r.type === 'Eligible') ? '/roleManagement/directory/roleEligibilityScheduleRequests' : '/roleManagement/directory/roleAssignmentScheduleRequests'
643
+ body = {
644
+ action: 'AdminAssign',
645
+ principalId: id,
646
+ roleDefinitionId: r.value,
647
+ directoryScopeId: '/',
648
+ justification: 'Automated assignment submitted by SCIM Gateway',
649
+ scheduleInfo: {
650
+ startDateTime: new Date().toISOString(),
651
+ expiration: {
652
+ type: 'noExpiration',
653
+ },
541
654
  },
542
655
  }
656
+ } else {
657
+ if (r.type === 'Eligible') throw new Error(`${action} error: add/remove eligible roles requires permission RoleEligibilitySchedule.ReadWrite.All`)
658
+ path = '/roleManagement/directory/roleAssignments'
659
+ body = {
660
+ principalId: id,
661
+ roleDefinitionId: r.value,
662
+ directoryScopeId: '/',
663
+ }
543
664
  }
544
665
 
545
666
  const fn = () => helper.doRequest(baseEntity, method, path, body, ctx)
@@ -549,44 +670,107 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
549
670
 
550
671
  for (const r of rolesRemove) {
551
672
  const arrRemove: Record<string, any> [] = []
552
- const removeAssignments = currentRoles.filter(m => m.value === r.value && m.type === r.type && m.assignmentId).map((m) => { return { assignmentId: m.assignmentId, value: m.value, type: m.type } })
673
+ const removeAssignments = currentRoles.filter(c => c.value === r.value && c.type === r.type && c.assignmentId).map((n) => { return { assignmentId: n.assignmentId, value: n.value, type: n.type } })
553
674
  arrRemove.push(...removeAssignments)
554
675
 
555
676
  for (const rm of arrRemove) {
556
- let method = 'DELETE'
557
- let path = `/roleManagement/directory/roleAssignments/${rm.assignmentId}`
558
- let body = null
559
- if (rm.type === 'Eligible') {
560
- method = 'POST'
677
+ let method = 'POST'
678
+ let path = ''
679
+ let body: Record<string, any> | null = {}
680
+
681
+ if (rm.type === 'Eligible' && permission[baseEntity]?.eligible) {
561
682
  path = '/roleManagement/directory/roleEligibilityScheduleRequests'
562
683
  body = {
563
684
  action: 'AdminRemove',
564
685
  principalId: id,
565
686
  roleDefinitionId: rm.value,
566
687
  directoryScopeId: '/',
567
- justification: 'Revoked by SCIM Gateway',
688
+ justification: 'Automated revoke submitted by SCIM Gateway',
568
689
  }
690
+ } else {
691
+ if (r.type === 'Eligible') throw new Error(`${action} error: add/remove eligible roles requires permission RoleEligibilitySchedule.ReadWrite.All`)
692
+ method = 'DELETE'
693
+ path = `/roleManagement/directory/roleAssignments/${rm.assignmentId}`
694
+ body = null
569
695
  }
570
696
  const fn = () => helper.doRequest(baseEntity, method, path, body, ctx)
571
697
  fnArr.push({ fn })
572
698
  isRolesChanged = true
573
699
  }
574
700
  }
701
+ }
702
+ }
575
703
 
576
- try {
577
- await fnCunckExecute(fnArr)
578
- if (isRolesChanged) {
579
- (async () => {
580
- await new Promise(resolve => setTimeout(resolve, 15000))
581
- await getRolesAssignments(baseEntity, ctx, true) // make sure internal assignments list become updated
582
- })()
704
+ // Entitlements - Access Packages - Note, License management not supported through entitlements, instead use groups
705
+ if (Object.hasOwn(parsedAttrObj, 'entitlements') && Array.isArray(parsedAttrObj.entitlements)) {
706
+ const accessPackagesAdd: Record<string, any> [] = parsedAttrObj.entitlements.filter(m => m.type === 'AccessPackage' && m.operation !== 'delete')
707
+ const accessPackagesRemove: Record<string, any> [] = parsedAttrObj.entitlements.filter(m => m.type === 'AccessPackage' && m.operation === 'delete')
708
+
709
+ if (accessPackagesAdd.length > 0) {
710
+ const byValues = await getEntitlementsByValues(baseEntity, ctx)
711
+ for (const a of accessPackagesAdd) {
712
+ if (!byValues[a.value]) continue
713
+ const assignmentPolicyId = byValues[a.value]?.typeInfo?.assignmentPolicies[0]?.id // TODO: note, using the first policy and this might be wrong if more than one defined...
714
+ if (!assignmentPolicyId) throw new Error(`${action} error: Access Package could not be assigned to user - entitlements value ${a.value} (Access Package ID) - no policy found for this Access Package`)
715
+ const method = 'POST'
716
+ let path = `/identityGovernance/entitlementManagement/accessPackageAssignmentRequests`
717
+ const body: Record<string, any> = {
718
+ requestType: 'AdminAdd',
719
+ accessPackageAssignment: {
720
+ target: {
721
+ '@odata.type': '#microsoft.graph.accessPackageSubject',
722
+ 'objectId': id,
723
+ },
724
+ assignmentPolicyId,
725
+ accessPackageId: a.value,
726
+ },
727
+ justification: 'Automated assignment request submitted by SCIM Gateway',
728
+ }
729
+ const fn = () => helper.doRequest(baseEntity, method, path, body, ctx)
730
+ fnArr.push({ fn })
731
+ }
732
+ }
733
+
734
+ if (accessPackagesRemove.length > 0) {
735
+ const arrRemove: Record<string, any> [] = []
736
+ const currentAPs = await getUserAccessPackages(baseEntity, id, true, ctx)
737
+ for (const r of accessPackagesRemove) {
738
+ const removeAssignments = currentAPs.filter(c => c.value === r.value && c.type === r.type && c.assignmentId)
739
+ arrRemove.push(...removeAssignments)
740
+ }
741
+ for (const rm of arrRemove) {
742
+ const method = 'POST'
743
+ let path = `/identityGovernance/entitlementManagement/accessPackageAssignmentRequests`
744
+ const body: Record<string, any> = {
745
+ requestType: 'adminRemove',
746
+ accessPackageAssignment: {
747
+ id: rm.assignmentId,
748
+ },
749
+ justification: 'Automated revoke request submitted by SCIM Gateway',
583
750
  }
584
- } catch (err: any) {
585
- throw new Error(`${action} roles modify error: ${err.message}`)
751
+ const fn = () => helper.doRequest(baseEntity, method, path, body, ctx)
752
+ fnArr.push({ fn })
753
+ }
754
+ }
755
+ }
756
+
757
+ if (fnArr.length > 0) { // update roles/entitlements
758
+ try {
759
+ await fnCunckExecute(fnArr)
760
+ if (isRolesChanged) {
761
+ (async () => {
762
+ await new Promise(resolve => setTimeout(resolve, 15000))
763
+ await getRolesAssignments(baseEntity, ctx, true) // make sure the internal assignments list becomes updated
764
+ })()
586
765
  }
766
+ } catch (err: any) {
767
+ throw new Error(`${action} roles modify error: ${err.message}`)
587
768
  }
588
769
  }
589
770
 
771
+ if (parsedAttrObj.roles) delete parsedAttrObj.roles
772
+ if (parsedAttrObj.entitlements) delete parsedAttrObj.entitlements
773
+
590
774
  const profile = () => { // patch
591
775
  return new Promise((resolve, reject) => {
592
776
  (async () => {
@@ -657,7 +841,7 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
657
841
  })
658
842
  }
659
843
 
660
- return Promise.all([profile(), manager()]) // license() deprecated - use license management through groups
844
+ return Promise.all([profile(), manager()])
661
845
  .then((_) => { return (null) })
662
846
  .catch((err) => { throw new Error(`${action} error: ${err.message}`) })
663
847
  }
@@ -712,7 +896,7 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
712
896
  } else if (isUserMemberOf) {
713
897
  // mandatory - return all groups the user 'id' (getObj.value) is member of - correspond to getGroupMembers() in versions < 4.x.x
714
898
  // Resources = [{ id: <id-group>> , displayName: <displayName-group>, members [{value: <id-user>}] }]
715
- path = `/users/${getObj.value}/transitiveMemberOf/microsoft.graph.group?$top=${getObj.count}&$count=true&select=id,displayName`
899
+ path = `/users/${getObj.value}/transitiveMemberOf/microsoft.graph.group?$top=${getObj.count}&$count=true&$select=id,displayName`
716
900
  } else {
717
901
  // optional - simpel filtering
718
902
  throw new Error(`${action} error: Entra ID only supports group filter operator 'eq' for a limited set of attributes ('id', 'displayName' and 'members.value') and therefore not supporting filter: ${getObj.rawFilter}`)
@@ -726,6 +910,11 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
726
910
  if (includeMembers) path = `/groups?$top=${getObj.count}&$count=true&$select=${attrs.join()}&$expand=members($select=id,displayName)`
727
911
  else path = `/groups?$top=${getObj.count}&$count=true&$select=${attrs.join()}`
728
912
  }
913
+ if (getObj.and || getObj.or) {
914
+ // plugin have enabled 'scimgateway.pluginAndOrFilterEnabled' and the query includes an additonal and/or getObj that must to be handled and combined with the initial getObj
915
+ // we could have this logic above, if not it must be defined here
916
+ throw new Error(`${action} error: logic for handling and/or filter is not implemented by plugin, not supporting: ${getObj.rawFilter}`)
917
+ }
729
918
  // mandatory if-else logic - end
730
919
 
731
920
  if (!path) throw new Error(`${action} error: mandatory if-else logic not fully implemented`)
@@ -750,7 +939,7 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
750
939
  if (!isUserMemberOf) response = await helper.doRequest(baseEntity, method, path, body, ctx, options)
751
940
  else {
752
941
  // request both the default transitiveMemberOf (includes nested groups) and memberOf because we want to distinguish SCIM type=direct/indirect
753
- const pathMemberOf = `/users/${getObj.value}/memberOf/microsoft.graph.group?$top=${getObj.count}&$count=true&select=id,displayName`
942
+ const pathMemberOf = `/users/${getObj.value}/memberOf/microsoft.graph.group?$top=${getObj.count}&$count=true&$select=id,displayName`
754
943
  const allErrors: string[] = []
755
944
  const results = await Promise.allSettled([
756
945
  helper.doRequest(baseEntity, method, path, body, ctx, options),
@@ -972,11 +1161,8 @@ scimgateway.getEntitlements = async (baseEntity, getObj, attributes, ctx) => {
972
1161
  // rawFilter is always included when filtering - attribute, operator and value are included when requesting unique object or simpel filtering
973
1162
  // See comments in the "mandatory if-else logic - start"
974
1163
  //
975
- // "attributes" contains a list of attributes to be returned - if blank, all supported attributes should be returned
976
- // Should normally return all supported user attributes having id and servicePlanName as mandatory
977
- // id and servicePlanName are most often considered as "the same" having value = <servicePlanName>
978
- // Note, the value of returned 'id' will be used as 'id' in modifyServicePlan and deleteServicePlan
979
- // scimgateway will automatically filter response according to the attributes list
1164
+ // getEntitlements() should return all 'type' (categories) of supported entitlements
1165
+ // Response format: Resources[{type: <category e.g, License>, value: <unique id>, displayName: <display name>}]
980
1166
  //
981
1167
  const action = 'getEntitlements'
982
1168
  scimgateway.logDebug(baseEntity, `handling ${action} getObj=${getObj ? JSON.stringify(getObj) : ''} attributes=${attributes} passThrough=${ctx ? 'true' : 'false'}`)
@@ -984,27 +1170,21 @@ scimgateway.getEntitlements = async (baseEntity, getObj, attributes, ctx) => {
984
1170
  const ret: any = {
985
1171
  Resources: [],
986
1172
  totalResults: null,
1173
+ startIndex: 1, // no paging support for Entitlements
987
1174
  }
988
1175
 
989
- const method = 'GET'
990
- const body = null
991
- let path
992
1176
  let searchAttr
993
1177
 
994
1178
  // mandatory if-else logic - start
995
1179
  if (getObj.operator) {
996
1180
  if (getObj.attribute === 'value') {
997
- path = '/subscribedSkus'
998
- searchAttr = 'value' // skuId
1181
+ searchAttr = 'value' // License skuId or AccessPackage id
999
1182
  } else if (getObj.attribute === 'type') {
1000
- path = '/subscribedSkus'
1001
- searchAttr = 'type' // skuPartNumber
1002
- } else if (getObj.attribute === 'display') {
1003
- path = '/subscribedSkus'
1004
- searchAttr = 'display'
1183
+ searchAttr = 'type' // License or AccessPackage
1184
+ } else if (getObj.attribute === 'displayName') {
1185
+ searchAttr = 'displayName'
1005
1186
  } else {
1006
1187
  // optional - simpel filtering
1007
- path = '/subscribedSkus'
1008
1188
  searchAttr = getObj.attribute
1009
1189
  }
1010
1190
  } else if (getObj.rawFilter) {
@@ -1012,15 +1192,16 @@ scimgateway.getEntitlements = async (baseEntity, getObj, attributes, ctx) => {
1012
1192
  throw new Error(`${action} error: advanced filtering not supported: ${getObj.rawFilter}`)
1013
1193
  } else {
1014
1194
  // mandatory - no filtering
1015
- path = '/subscribedSkus'
1016
1195
  }
1017
1196
 
1018
- if (!path) throw new Error(`${action} error: mandatory if-else logic not fully implemented`)
1019
- path += '?$select=skuId,skuPartNumber,consumedUnits,prepaidUnits'
1197
+ // Licenses: entitlement type=License
1198
+ const licenses = async (): Promise<Record<string, any>[]> => {
1199
+ const result: Record<string, any>[] = []
1200
+ const method = 'GET'
1201
+ const body = null
1202
+ const path = '/subscribedSkus?$select=skuId,skuPartNumber,consumedUnits,prepaidUnits'
1020
1203
 
1021
- try {
1022
- let response
1023
- response = await helper.doRequest(baseEntity, method, path, body, ctx)
1204
+ const response = await helper.doRequest(baseEntity, method, path, body, ctx?.headers ? { headers: ctx?.headers } : undefined)
1024
1205
  if (!response.body?.value) {
1025
1206
  if (response.body?.skuId) response.body.value = [response.body]
1026
1207
  else throw new Error(`invalid response: ${JSON.stringify(response)}`)
@@ -1046,32 +1227,68 @@ scimgateway.getEntitlements = async (baseEntity, getObj, attributes, ctx) => {
1046
1227
  typeInfo.priceUSD = licenseMapping[skuPartNumber].priceUSD
1047
1228
  typeInfo.derivedIncludes = licenseMapping[skuPartNumber].derivedIncludes
1048
1229
  }
1049
- ret.Resources.push({
1230
+ result.push({
1050
1231
  type: 'License', id: response.body.value[i].skuId, displayName, typeInfo,
1051
1232
  })
1052
1233
  }
1234
+ return result
1235
+ }
1053
1236
 
1054
- if (searchAttr && ret.Resources.length > 0) {
1055
- const arrAttr = searchAttr.split('.')
1056
- ret.Resources = ret.Resources.filter((el: any) => {
1057
- let elValue
1058
- if (arrAttr.length === 1) elValue = el[arrAttr[0]]
1059
- else if (arrAttr.length === 2) elValue = el[arrAttr[0]][arrAttr[1]]
1060
- else return false
1061
- switch (getObj.operator) {
1062
- case 'eq': return elValue?.toLowerCase() === getObj.value?.toLowerCase()
1063
- case 'co': return elValue?.toLowerCase().includes(getObj.value?.toLowerCase())
1064
- case 'sw': return elValue?.toLowerCase().startsWith(getObj.value?.toLowerCase())
1065
- default: return false
1066
- }
1237
+ // Access Packages: entitlement type=AccessPackage
1238
+ const accessPackages = async (): Promise<Record<string, any>[]> => {
1239
+ const result: Record<string, any>[] = []
1240
+ if (!permission[baseEntity]?.accessPackage) return result
1241
+ const method = 'GET'
1242
+ const body = null
1243
+ const path = '/identityGovernance/entitlementManagement/accessPackages?$select=id,displayName&$expand=accessPackageAssignmentPolicies' // v1.0 $expand=assignmentPolicies
1244
+
1245
+ const response = await helper.doRequest(baseEntity, method, path, body, ctx?.headers ? { headers: ctx?.headers } : undefined)
1246
+ if (!response.body?.value) {
1247
+ if (response.body?.skuId) response.body.value = [response.body]
1248
+ else throw new Error(`invalid response: ${JSON.stringify(response)}`)
1249
+ }
1250
+ for (let i = 0; i < response.body.value.length; i++) {
1251
+ const typeInfo: Record<string, any> = {}
1252
+ if (Array.isArray(response.body.value[i].accessPackageAssignmentPolicies)) {
1253
+ typeInfo.assignmentPolicies = response.body.value[i].accessPackageAssignmentPolicies.map((a: Record<string, any>) => { // accessPackageAssignmentPolicies.id needed for assign access package to user
1254
+ return { id: a.id, displayName: a.displayName }
1255
+ })
1256
+ }
1257
+ result.push({
1258
+ type: 'AccessPackage', id: response.body.value[i].id, displayName: response.body.value[i].displayName, typeInfo,
1067
1259
  })
1068
1260
  }
1261
+ return result
1262
+ }
1069
1263
 
1070
- ret.totalResults = ret.Resources.length // '/subscribedSkus' does not support paging
1071
- return ret
1264
+ try {
1265
+ const arrResolve = await Promise.all([
1266
+ licenses(),
1267
+ accessPackages(),
1268
+ ])
1269
+ ret.Resources = [...arrResolve[0], ...arrResolve[1]]
1072
1270
  } catch (err: any) {
1073
1271
  throw new Error(`${action} error: ${err.message}`)
1074
1272
  }
1273
+
1274
+ if (searchAttr && ret.Resources.length > 0) {
1275
+ const arrAttr = searchAttr.split('.')
1276
+ ret.Resources = ret.Resources.filter((el: any) => {
1277
+ let elValue
1278
+ if (arrAttr.length === 1) elValue = el[arrAttr[0]]
1279
+ else if (arrAttr.length === 2) elValue = el[arrAttr[0]][arrAttr[1]]
1280
+ else return false
1281
+ switch (getObj.operator) {
1282
+ case 'eq': return elValue?.toLowerCase() === getObj.value?.toLowerCase()
1283
+ case 'co': return elValue?.toLowerCase().includes(getObj.value?.toLowerCase())
1284
+ case 'sw': return elValue?.toLowerCase().startsWith(getObj.value?.toLowerCase())
1285
+ default: return false
1286
+ }
1287
+ })
1288
+ }
1289
+
1290
+ ret.totalResults = ret.Resources.length // no paging support for Entitlements
1291
+ return ret
1075
1292
  }
1076
1293
 
1077
1294
  // =================================================
@@ -1095,7 +1312,7 @@ scimgateway.getRoles = async (baseEntity, getObj, attributes, ctx) => {
1095
1312
  // mandatory if-else logic - start
1096
1313
  if (getObj.operator) {
1097
1314
  if (getObj.operator === 'eq' && ['id'].includes(getObj.attribute)) path = `/roleManagement/directory/roleDefinitions/${getObj.value}`
1098
- else if (getObj.operator === 'eq' && getObj.attribute === 'displayName') path = `/roleManagement/directory/roleDefinitions?&filter=displayName eq '${getObj.value}'`
1315
+ else if (getObj.operator === 'eq' && getObj.attribute === 'displayName') path = `/roleManagement/directory/roleDefinitions?$filter=displayName eq '${getObj.value}'`
1099
1316
  else {
1100
1317
  path = '/roleManagement/directory/roleDefinitions'
1101
1318
  searchAttr = getObj.attribute
@@ -1111,7 +1328,7 @@ scimgateway.getRoles = async (baseEntity, getObj, attributes, ctx) => {
1111
1328
  if (!path) throw new Error(`${action} error: mandatory if-else logic not fully implemented`)
1112
1329
  if (path.includes('?')) path += '&'
1113
1330
  else path += '?'
1114
- path += '$select=id,displayName,isBuiltIn,assignmentMode'
1331
+ path += '$select=id,displayName,isBuiltIn'
1115
1332
 
1116
1333
  try {
1117
1334
  let response = await helper.doRequest(baseEntity, method, path, body, ctx, options)
@@ -1121,11 +1338,9 @@ scimgateway.getRoles = async (baseEntity, getObj, attributes, ctx) => {
1121
1338
  }
1122
1339
 
1123
1340
  for (let i = 0; i < response.body.value.length; i++) {
1124
- // if (response.body.value[i].assignmentMode !== 'allowed') continue
1125
1341
  const id = response.body.value[i].id
1126
1342
  const displayName = response.body.value[i].displayName
1127
1343
  const type = response.body.value[i].isBuiltIn ? 'BuiltIn' : 'Custom'
1128
-
1129
1344
  ret.Resources.push({
1130
1345
  type, id, displayName,
1131
1346
 
@@ -1176,13 +1391,13 @@ const operatorMap: Record<string, ScimOpFn> = {
1176
1391
  }
1177
1392
 
1178
1393
  //
1179
- // getSkuIdDefs returns entitlements keys having the entitlements as values
1394
+ // getEntitlementsByValues returns entitlements keys having the entitlements as values
1180
1395
  // {entitlement1.value: [type1, value1, display1], entitlement2.value: [type2, value2, display2], ...}
1181
1396
  // entitlement.value = skuId
1182
1397
  // Keep an updated entitlementsByValues in memory
1183
1398
  // We can then use users/assignedLicenses instead of costly users/licenseDetails
1184
1399
  //
1185
- const getSkuIdDefs = async (baseEntity: string, getObj: Record<string, any>, attributes: string[], ctx?: undefined | Record<string, any>): Promise<Record<string, any>> => {
1400
+ const getEntitlementsByValues = async (baseEntity: string, ctx?: undefined | Record<string, any>): Promise<Record<string, any>> => {
1186
1401
  if (!entitlementsByValues[baseEntity]) entitlementsByValues[baseEntity] = {}
1187
1402
  if (!entitlementsByValues[baseEntity].validTo || Date.now() > entitlementsByValues[baseEntity].validTo) {
1188
1403
  await lockEntitlement.acquire()
@@ -1190,17 +1405,17 @@ const getSkuIdDefs = async (baseEntity: string, getObj: Record<string, any>, att
1190
1405
  lockEntitlement.release()
1191
1406
  return entitlementsByValues[baseEntity]
1192
1407
  }
1193
- const entitlements = await scimgateway.getEntitlements(baseEntity, getObj, attributes, ctx)
1408
+ const entitlements = await scimgateway.getEntitlements(baseEntity, {}, [], ctx)
1194
1409
  Object.keys(entitlementsByValues[baseEntity]).forEach(key => delete entitlementsByValues[baseEntity][key])
1195
1410
  for (const r of entitlements.Resources) {
1196
- if (r.type === 'License' && r.id && r.displayName) {
1197
- const entitlement = {
1198
- type: r.type,
1199
- value: r.id, // skUId
1200
- display: r.displayName,
1201
- }
1202
- entitlementsByValues[baseEntity][entitlement.value] = entitlement
1411
+ const entitlement: Record<string, any> = {
1412
+ type: r.type,
1413
+ value: r.id,
1414
+ display: r.displayName,
1203
1415
  }
1416
+ if (r.type === 'AccessPackage') entitlement.typeInfo = r.typeInfo // only used by modifyUser() entitlements
1417
+
1418
+ entitlementsByValues[baseEntity][entitlement.value] = entitlement
1204
1419
  }
1205
1420
  entitlementsByValues[baseEntity].validTo = Date.now() + 24 * 60 * 60 * 1000 // 24 hours
1206
1421
  lockEntitlement.release()
@@ -1209,38 +1424,106 @@ const getSkuIdDefs = async (baseEntity: string, getObj: Record<string, any>, att
1209
1424
  }
1210
1425
 
1211
1426
  //
1212
- // searchSkuIdDefs returns array of skuIds matching getObj filter
1427
+ // searchEntitlementsByValues returns array of entitlements value (id) matching getObj filter
1213
1428
  //
1214
- const searchSkuIdDefs = (skuIdDefs: Record<string, any>, getObj: Record<string, any>): string[] => {
1215
- if (typeof skuIdDefs !== 'object' || !getObj?.attribute || !getObj?.operator || !getObj?.value) return []
1429
+ const searchEntitlementsByValues = async (baseEntity: string, getObj: Record<string, any>, type?: string, ctx?: undefined | Record<string, any>): Promise<string[]> => { // (getObj: Record<string, any>): string[] => {
1430
+ const byValues = await getEntitlementsByValues(baseEntity, ctx)
1216
1431
  const arr = getObj.attribute.split('.')
1217
1432
  if (arr.length !== 2 || arr[0] !== 'entitlements') return []
1218
1433
  const attribute = arr[1]
1219
- const skuIds: string[] = []
1434
+ const ids: string[] = []
1220
1435
  const getObjValue = decodeURIComponent(getObj.value)
1221
1436
 
1222
- for (const key in skuIdDefs) {
1223
- if (typeof skuIdDefs[key] !== 'object') continue
1437
+ for (const key in byValues) {
1438
+ if (type && byValues[key].type !== type) continue
1224
1439
  switch (getObj.operator) {
1225
1440
  case 'eq':
1226
- if (attribute === 'value' && skuIdDefs[key]?.value === getObjValue) skuIds.push(key)
1227
- else if (attribute === 'type' && skuIdDefs[key]?.type === getObjValue) skuIds.push(key)
1228
- else if (attribute === 'display' && skuIdDefs[key]?.display === getObjValue) skuIds.push(key)
1441
+ if (attribute === 'value' && byValues[key]?.value === getObjValue) ids.push(key)
1442
+ else if (attribute === 'type' && byValues[key]?.type === getObjValue) ids.push(key)
1443
+ else if (attribute === 'display' && byValues[key]?.display === getObjValue) ids.push(key)
1229
1444
  break
1230
1445
  case 'co':
1231
- if (attribute === 'value' && skuIdDefs[key]?.value?.toLowerCase().includes(getObjValue?.toLowerCase())) skuIds.push(key)
1232
- else if (attribute === 'type' && skuIdDefs[key]?.type?.toLowerCase().includes(getObjValue?.toLowerCase())) skuIds.push(key)
1233
- else if (attribute === 'display' && skuIdDefs[key]?.display?.toLowerCase().includes(getObjValue?.toLowerCase())) skuIds.push(key)
1446
+ if (attribute === 'value' && byValues[key]?.value?.toLowerCase().includes(getObjValue?.toLowerCase())) ids.push(key)
1447
+ else if (attribute === 'type' && byValues[key]?.type?.toLowerCase().includes(getObjValue?.toLowerCase())) ids.push(key)
1448
+ else if (attribute === 'display' && byValues[key]?.display?.toLowerCase().includes(getObjValue?.toLowerCase())) ids.push(key)
1234
1449
  break
1235
1450
  case 'sw':
1236
- if (attribute === 'value' && skuIdDefs[key]?.value?.toLowerCase().startsWith(getObjValue.toLowerCase())) skuIds.push(key)
1237
- else if (attribute === 'type' && skuIdDefs[key]?.type?.toLowerCase().startsWith(getObjValue?.toLowerCase())) skuIds.push(key)
1238
- else if (attribute === 'display' && skuIdDefs[key]?.display?.toLowerCase().startsWith(getObjValue?.toLowerCase())) skuIds.push(key)
1451
+ if (attribute === 'value' && byValues[key]?.value?.toLowerCase().startsWith(getObjValue.toLowerCase())) ids.push(key)
1452
+ else if (attribute === 'type' && byValues[key]?.type?.toLowerCase().startsWith(getObjValue?.toLowerCase())) ids.push(key)
1453
+ else if (attribute === 'display' && byValues[key]?.display?.toLowerCase().startsWith(getObjValue?.toLowerCase())) ids.push(key)
1239
1454
  break
1240
1455
  default: break
1241
1456
  }
1242
1457
  }
1243
- return skuIds
1458
+ return ids
1459
+ }
1460
+
1461
+ const isAccessPackageScheduleValid = (now: Date, expiredDateTime: string, schedule: Record<string, any>): boolean => {
1462
+ if (expiredDateTime) return false
1463
+ if (typeof schedule !== 'object' || schedule === null) return false
1464
+ if (schedule?.startDateTime && now < new Date(schedule.startDateTime)) return false
1465
+ if (schedule?.expiration?.endDateTime && now > new Date(schedule.expiration.endDateTime)) return false
1466
+ return true
1467
+ }
1468
+
1469
+ /**
1470
+ * getUsersByAccessPackage returns an object with keys of user object id`s that includes an elements array of users Access Packages.
1471
+ * @returns { "UserID": { "id": \<UserID\, "displayName": \<UserDisplayName\>, entitlements: [ {"type": "AccessPackage", "value": \<AP-id\>, "display": \<AP-displayName\>}, ...] } }
1472
+ **/
1473
+ const getUsersByAccessPackage = async (baseEntity: string, getObj: Record<string, any>, ctx?: Record<string, any> | undefined): Promise<Record<string, any>> => {
1474
+ const action = 'getUsersByAccessPackage'
1475
+ const result: Record<string, any> = {}
1476
+ if (!getObj?.value) return result
1477
+ const count = 100
1478
+ let path
1479
+
1480
+ if (getObj.operator === 'eq') {
1481
+ if (getObj.attribute === 'entitlements.type') {
1482
+ // return all users having access packages
1483
+ path = `/identityGovernance/entitlementManagement/accessPackageAssignments?$expand=accessPackage($select=id,displayName),target($select=id,displayName)&$top=${count}` // v1.0 /assignments?$filter=accessPackage/${attribute}
1484
+ } else {
1485
+ let attribute
1486
+ if (getObj.attribute === 'entitlements.display') attribute = 'displayName'
1487
+ else if (getObj.attribute === 'entitlements.value') attribute = 'id'
1488
+ if (attribute) {
1489
+ path = `/identityGovernance/entitlementManagement/accessPackageAssignments?$filter=accessPackage/${attribute} eq '${getObj.value}'&$expand=accessPackage($select=id,displayName),target($select=id,displayName)&$top=100` // v1.0 /assignments?$filter=accessPackage/${attribute}
1490
+ }
1491
+ }
1492
+ }
1493
+
1494
+ if (path) {
1495
+ let previousStartIndext = 0
1496
+ let startIndex = 1
1497
+ const method = 'GET'
1498
+ const body = null
1499
+
1500
+ while (startIndex > previousStartIndext) {
1501
+ previousStartIndext = startIndex
1502
+
1503
+ // enable doRequest() OData paging support
1504
+ let paging = { startIndex: startIndex }
1505
+ if (!ctx) ctx = { paging }
1506
+ else ctx.paging = paging
1507
+
1508
+ const res = await helper.doRequest(baseEntity, method, path, body, ctx)
1509
+ if (!res.body?.value || !Array.isArray(res.body.value)) throw new Error(`${action} error: invalid response: ${JSON.stringify(res)}`)
1510
+ const now = new Date()
1511
+ for (const el of res.body.value) {
1512
+ if (!isAccessPackageScheduleValid(now, el.expiredDateTime, el.schedule)) continue
1513
+ if (!el.target || !el.target.id) continue
1514
+ const ap: Record<string, any> = el.accessPackage
1515
+ if (!ap || !ap.id || !ap.displayName) continue
1516
+ if (!result[el.target.id]) result[el.target.id] = { id: el.target.id, displayName: el.target.displayName, entitlements: [] }
1517
+ result[el.target.id].entitlements.push({
1518
+ type: 'AccessPackage', value: ap.id, display: ap.displayName,
1519
+ })
1520
+ }
1521
+ const itemsPerPage = res.body.value.length
1522
+ if (ctx.paging.totalResults !== undefined && ctx.paging.totalResults > itemsPerPage + startIndex - 1) startIndex += itemsPerPage
1523
+ }
1524
+ }
1525
+
1526
+ return result
1244
1527
  }
1245
1528
 
1246
1529
  //
@@ -1248,7 +1531,7 @@ const searchSkuIdDefs = (skuIdDefs: Record<string, any>, getObj: Record<string,
1248
1531
  // {role1.value: [type1, value1, display1], role2.value: [type2, value2, display2], ...}
1249
1532
  // Keep an updated rolesByValues in memory
1250
1533
  //
1251
- const getRoleDefs = async (baseEntity: string, getObj: Record<string, any>, attributes: string[], ctx?: undefined | Record<string, any>): Promise<Record<string, any>> => {
1534
+ const getRoleDefs = async (baseEntity: string, getObj: Record<string, any>, attributes: string[], ctx?: Record<string, any> | undefined): Promise<Record<string, any>> => {
1252
1535
  if (!rolesByValues[baseEntity]) rolesByValues[baseEntity] = {}
1253
1536
  if (!rolesByValues[baseEntity].validTo || Date.now() > rolesByValues[baseEntity].validTo) {
1254
1537
  await lockRole.acquire()
@@ -1328,7 +1611,8 @@ const isEligibleActive = (scheduleInfo: Record<string, any>) => {
1328
1611
  // getUserRoles returns user´s Entra ID roles as a SCIM roles array having type=Permanent/Eligible.
1329
1612
  // includeAssignmentId=true is only used for modifyUser when deleting roles, roles array then includes the required assignmentId
1330
1613
  //
1331
- const getUserRoles = async (baseEntity: string, userId: string, groups: Record<string, any>[], ctx?: undefined | Record<string, any>, includeAssignmentId?: boolean): Promise<Record<string, any>[]> => {
1614
+ const getUserRoles = async (baseEntity: string, userId: string, groups: Record<string, any>[], includeAssignmentId: boolean, ctx?: undefined | Record<string, any>): Promise<Record<string, any>[]> => {
1615
+ const action = 'getUserRoles'
1332
1616
  let roleDefs: Record<string, any> = {}
1333
1617
  let rolesAssignments: Record<string, any> = {}
1334
1618
 
@@ -1340,7 +1624,7 @@ const getUserRoles = async (baseEntity: string, userId: string, groups: Record<s
1340
1624
  roleDefs = arrResolve[0]
1341
1625
  rolesAssignments = arrResolve[1]
1342
1626
  } catch (err: any) {
1343
- throw new Error(`getUserRoles error: ${err.message}`)
1627
+ throw new Error(`${action} error: ${err.message}`)
1344
1628
  }
1345
1629
 
1346
1630
  // permanent roles
@@ -1352,8 +1636,9 @@ const getUserRoles = async (baseEntity: string, userId: string, groups: Record<s
1352
1636
  const eligibleRoles = rolesAssignments.eligible.filter((role: any) => role.principalId === userId).map((role: any) => {
1353
1637
  const roleDef = roleDefs[role.roleDefinitionId]
1354
1638
  if (roleDef && isEligibleActive(role.scheduleInfo)) {
1355
- if (includeAssignmentId === true) return { type: 'Eligible', value: roleDef.id, display: roleDef.displayName, assignmentId: role.id }
1356
- return { type: 'Eligible', value: roleDef.id, display: roleDef.displayName }
1639
+ const entitlement: Record<string, any> = { type: 'Eligible', value: roleDef.id, display: roleDef.displayName }
1640
+ if (includeAssignmentId === true) entitlement.assignmentId = role.id
1641
+ return entitlement
1357
1642
  }
1358
1643
  return null
1359
1644
  })
@@ -1370,18 +1655,126 @@ const getUserRoles = async (baseEntity: string, userId: string, groups: Record<s
1370
1655
  return [...permanentRoles, ...eligibleRoles].filter((role: any) => role !== null)
1371
1656
  }
1372
1657
 
1658
+ //
1659
+ // getUserRoles returns user´s Entra ID Access Packaes as a SCIM entitlements array having type=AccessPackage.
1660
+ // includeAssignmentId=true is only used for modifyUser when deleting AccessPackage, entitlement array then includes the required assignmentId
1661
+ //
1662
+ const getUserAccessPackages = async (baseEntity: string, userId: string, includeAssignmentId: boolean, ctx?: undefined | Record<string, any>): Promise<Record<string, any>[]> => {
1663
+ const action = 'getUserAccessPackages'
1664
+ const result: Record<string, any>[] = []
1665
+ const method = 'GET'
1666
+ const body = null
1667
+ const path = `/identityGovernance/entitlementManagement/accessPackageAssignments?$filter=target/objectId eq '${userId}'&$expand=accessPackage($select=id,displayName)` // v1.0 /assignments
1668
+ const r = await helper.doRequest(baseEntity, method, path, body, ctx?.headers ? { headers: ctx?.headers } : undefined)
1669
+ if (!r.body?.value) {
1670
+ if (r.body?.id) r.body.value = [r.body]
1671
+ else throw new Error(`${action} error: invalid response: ${JSON.stringify(r)}`)
1672
+ }
1673
+ const now = new Date()
1674
+ for (let j = 0; j < r.body.value.length; j++) {
1675
+ if (!isAccessPackageScheduleValid(now, r.body.value[j].expiredDateTime, r.body.value[j].schedule)) continue
1676
+ const ap: Record<string, any> = r.body.value[j].accessPackage
1677
+ if (!ap || !ap.id || !ap.displayName) continue
1678
+ const entitlement: Record<string, any> = { type: 'AccessPackage', value: ap.id, display: ap.displayName }
1679
+ if (includeAssignmentId === true) entitlement.assignmentId = r.body.value[j].id
1680
+ result.push(entitlement)
1681
+ }
1682
+ return result
1683
+ }
1684
+
1685
+ /**
1686
+ * getUsersByRole returns an array of user IDs having a specific role assigned
1687
+ * @param baseEntity
1688
+ * @param getObj { attribute: "xxx", operator: <eq/co/sw>, value: <value> }
1689
+ * @param ctx
1690
+ * @param type "Permanent", "Eligible" or undefined
1691
+ * @returns string[] user-ids
1692
+ */
1693
+ const getUsersByRole = async (baseEntity: string, getObj: Record<string, any>, type?: 'Permanent' | 'Eligible', ctx?: Record<string, any> | undefined): Promise<string[]> => {
1694
+ // 1. Identify Role ID(s) based on getObj (supporting display, value, type)
1695
+ if (typeof getObj !== 'object' || getObj === null) return []
1696
+ let obj: Record<string, any> = { operator: getObj.operator, value: getObj.value }
1697
+ if (getObj.attribute === 'roles.value') obj.attribute = 'id'
1698
+ else if (getObj.attribute === 'roles.display') obj.attribute = 'displayName'
1699
+ else if (getObj.attribute === 'roles.type') obj = {} // no getRoles() filtering
1700
+ else return []
1701
+
1702
+ const roles = await scimgateway.getRoles(baseEntity, obj, ['id'], ctx)
1703
+ if (!roles.Resources || roles.Resources.length === 0) return []
1704
+ const roleIds = roles.Resources.map((r: any) => r.id)
1705
+
1706
+ // 2. Get all directory assignments (cached for 1h by getRolesAssignments)
1707
+ const assignments = await getRolesAssignments(baseEntity, ctx)
1708
+ const activePrincipals = new Set<string>()
1709
+
1710
+ const check = (list: any[], isEligible: boolean) => {
1711
+ for (const a of list) {
1712
+ if (roleIds.includes(a.roleDefinitionId)) {
1713
+ if (isEligible && !isEligibleActive(a.scheduleInfo)) continue
1714
+ activePrincipals.add(a.principalId)
1715
+ }
1716
+ }
1717
+ }
1718
+ if (!type || type === 'Permanent') {
1719
+ check(assignments.permanent || [], false)
1720
+ }
1721
+ if (!type || type === 'Eligible') {
1722
+ check(assignments.eligible || [], true)
1723
+ }
1724
+
1725
+ if (activePrincipals.size === 0) return []
1726
+
1727
+ // 3. Resolve Principals (determine if User or Group)
1728
+ const userIds = new Set<string>()
1729
+ const principalsToResolve: { fn: () => Promise<any> }[] = []
1730
+ for (const pId of activePrincipals) {
1731
+ const path = `/directoryObjects/${pId}`
1732
+ principalsToResolve.push({ fn: () => helper.doRequest(baseEntity, 'GET', path, null, ctx?.headers ? { headers: ctx?.headers } : undefined) })
1733
+ }
1734
+ const principalObjects: any[] = []
1735
+ await fnCunckExecute(principalsToResolve, principalObjects)
1736
+
1737
+ // 4. Handle Users directly and fetch transitive members for Groups
1738
+ const groupMembersToFetch: { fn: () => Promise<any> }[] = []
1739
+ for (const obj of principalObjects) {
1740
+ if (!obj || !obj.id) continue
1741
+ if (obj['@odata.type'] === '#microsoft.graph.user') userIds.add(obj.id)
1742
+ else if (obj['@odata.type'] === '#microsoft.graph.group') {
1743
+ // Use a custom function to fetch all transitive members including paging
1744
+ const fetchAllMembers = async (groupId: string) => {
1745
+ let members: any[] = []
1746
+ let nextPath: string | null = `/groups/${groupId}/transitiveMembers/microsoft.graph.user?$select=id`
1747
+ while (nextPath) {
1748
+ const res = await helper.doRequest(baseEntity, 'GET', nextPath, null, ctx?.headers ? { headers: ctx?.headers } : undefined)
1749
+ if (!res.body?.value) break
1750
+ members.push(...res.body.value)
1751
+ // extract nextLink and convert to relative path
1752
+ nextPath = res.body['@odata.nextLink'] ? res.body['@odata.nextLink'].split('/beta')[1] : null
1753
+ }
1754
+ return { body: { value: members } } // Wrap results for fnCunckExecute compatibility
1755
+ }
1756
+ groupMembersToFetch.push({ fn: () => fetchAllMembers(obj.id) })
1757
+ }
1758
+ }
1759
+ const groupResults: any[] = []
1760
+ if (groupMembersToFetch.length > 0) await fnCunckExecute(groupMembersToFetch, groupResults)
1761
+ groupResults.forEach((m: any) => m.id && userIds.add(m.id.toLowerCase()))
1762
+
1763
+ return Array.from(userIds)
1764
+ }
1765
+
1373
1766
  /**
1374
1767
  * fnCunckExecute runs functions asynchronous in chunks
1375
- * @param fnArr array of objects that must include function and optionally index [{fn, index}]. If `index` is included, it represent the index of `responseValue` that should be updated with `key` set to the value of the function result.
1376
- * @param responseValue optionally array of objects. `responseValue[index].key` will be set to function result
1768
+ * @param fnArr array of objects that must include function and optionally index [{fn, index}]. If `index` is included, it represent the index of `objArr` that should be updated with `key` set to the value of the function result.
1769
+ * @param objArr optionally array of objects. `objArr[index].key` will be set to function result. If objArr included e.g. empty, but no index and no key, function result will be inserted to objArr.
1377
1770
  * @param key optionally key
1378
- * @returns undefined. If index, responseValue and key being used the caller's responseValue will be updated with function results.
1771
+ * @returns undefined, but updated objArr if objArr argument is included
1379
1772
  **/
1380
- const fnCunckExecute = async (fnArr: { index?: number, fn: () => Promise<any> }[], responseValue?: Record<string, any>[], key?: string) => {
1381
- if (!Array.isArray(fnArr)) throw new Error(`fnCunckExecute get ${key} error: fnArr and/or responseValue is not array`)
1773
+ const fnCunckExecute = async (fnArr: { index?: number, fn: () => Promise<any> }[], objArr?: Record<string, any>[], key?: string) => {
1774
+ if (!Array.isArray(fnArr)) throw new Error(`fnCunckExecute get ${key} error: fnArr and/or objArr is not array`)
1382
1775
  if (fnArr.length > 0) {
1383
1776
  if (typeof fnArr[0] !== 'object' || !fnArr[0].fn) throw new Error(`fnCunckExecute error: fnArr missing fn object(s)`)
1384
- else if (fnArr[0].index !== undefined && !(responseValue || key)) throw new Error(`fnCunckExecute error: missing reponseValue/key`)
1777
+ else if (fnArr[0].index !== undefined && !(objArr || key)) throw new Error(`fnCunckExecute error: missing reponseValue/key`)
1385
1778
  const chunk = 5
1386
1779
  do {
1387
1780
  const arrChunk = fnArr.splice(0, chunk)
@@ -1398,9 +1791,16 @@ const fnCunckExecute = async (fnArr: { index?: number, fn: () => Promise<any> }[
1398
1791
  if (statusCode !== 404) throw new Error(errMsg)
1399
1792
  }
1400
1793
  results.forEach((result, idx) => {
1401
- if (result.status === 'fulfilled' && typeof arrChunk[idx].index === 'number' && responseValue && key) {
1402
- if (result.value) responseValue[arrChunk[idx].index][key] = result.value
1403
- else responseValue[arrChunk[idx].index][key] = result
1794
+ if (result.status === 'fulfilled') {
1795
+ if (!result.value?.body) return
1796
+ const res = result.value.body
1797
+ if (typeof arrChunk[idx].index === 'number' && objArr && key) {
1798
+ if (res.value) objArr[arrChunk[idx].index][key] = res.value
1799
+ else objArr[arrChunk[idx].index][key] = res // Assign the result to the specific index and key
1800
+ } else if (arrChunk[idx].index === undefined && objArr && key === undefined) { // When index and key are undefined, append to objArr if objArr provided
1801
+ if (Array.isArray(res.value)) objArr.push(...res.value) // If res.value is an array, spread its elements into objArr
1802
+ else objArr.push(res) // Otherwise, push the entire res object
1803
+ }
1404
1804
  }
1405
1805
  })
1406
1806
  } while (fnArr.length > 0)