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.
- package/README.md +1117 -3440
- package/config/plugin-entra-id.json +3 -3
- package/config/plugin-generic.json +1 -4
- package/lib/helper-rest.ts +23 -46
- package/lib/plugin-entra-id.ts +587 -187
- package/lib/plugin-generic.ts +31 -18
- package/lib/plugin-ldap.ts +15 -2
- package/lib/plugin-loki.ts +11 -0
- package/lib/plugin-mongodb.ts +11 -0
- package/lib/plugin-mssql.ts +11 -0
- package/lib/plugin-saphana.ts +11 -0
- package/lib/plugin-soap.ts +11 -0
- package/lib/scimgateway.ts +55 -37
- package/package.json +1 -1
package/lib/plugin-entra-id.ts
CHANGED
|
@@ -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 (
|
|
18
|
-
// 'GET /Entitlements' retrieves a list of all available entitlements specified by type (
|
|
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'.
|
|
24
|
-
// - 'map.user.
|
|
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
|
-
//
|
|
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
|
|
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> = {}
|
|
85
|
-
const rolesByValues: Record<string, any> = {}
|
|
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
|
|
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] === '
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
if (
|
|
261
|
-
|
|
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 (
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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 (
|
|
361
|
-
|
|
448
|
+
if (obj.signInActivity) {
|
|
449
|
+
delete obj.signInActivity.lastSignInRequestId
|
|
450
|
+
delete obj.signInActivity.lastNonInteractiveSignInRequestId
|
|
451
|
+
delete obj.signInActivity.lastSuccessfulSignInRequestId
|
|
362
452
|
}
|
|
363
453
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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',
|
|
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 (
|
|
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, [],
|
|
632
|
+
const currentRoles = await getUserRoles(baseEntity, id, [], true, ctx)
|
|
521
633
|
|
|
522
634
|
for (const r of rolesAdd) {
|
|
523
|
-
const roleExist = currentRoles.filter(
|
|
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
|
-
|
|
527
|
-
let
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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(
|
|
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 = '
|
|
557
|
-
let path =
|
|
558
|
-
let body =
|
|
559
|
-
|
|
560
|
-
|
|
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: '
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
585
|
-
|
|
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()])
|
|
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
|
|
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
|
|
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
|
-
//
|
|
976
|
-
//
|
|
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
|
-
|
|
998
|
-
searchAttr = 'value' // skuId
|
|
1181
|
+
searchAttr = 'value' // License skuId or AccessPackage id
|
|
999
1182
|
} else if (getObj.attribute === 'type') {
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
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
|
-
|
|
1019
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
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
|
-
|
|
1071
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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,
|
|
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
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
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
|
-
//
|
|
1427
|
+
// searchEntitlementsByValues returns array of entitlements value (id) matching getObj filter
|
|
1213
1428
|
//
|
|
1214
|
-
const
|
|
1215
|
-
|
|
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
|
|
1434
|
+
const ids: string[] = []
|
|
1220
1435
|
const getObjValue = decodeURIComponent(getObj.value)
|
|
1221
1436
|
|
|
1222
|
-
for (const key in
|
|
1223
|
-
if (
|
|
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' &&
|
|
1227
|
-
else if (attribute === 'type' &&
|
|
1228
|
-
else if (attribute === 'display' &&
|
|
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' &&
|
|
1232
|
-
else if (attribute === 'type' &&
|
|
1233
|
-
else if (attribute === 'display' &&
|
|
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' &&
|
|
1237
|
-
else if (attribute === 'type' &&
|
|
1238
|
-
else if (attribute === 'display' &&
|
|
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
|
|
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?:
|
|
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
|
|
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(
|
|
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
|
-
|
|
1356
|
-
|
|
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 `
|
|
1376
|
-
* @param
|
|
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
|
|
1771
|
+
* @returns undefined, but updated objArr if objArr argument is included
|
|
1379
1772
|
**/
|
|
1380
|
-
const fnCunckExecute = async (fnArr: { index?: number, fn: () => Promise<any> }[],
|
|
1381
|
-
if (!Array.isArray(fnArr)) throw new Error(`fnCunckExecute get ${key} error: fnArr and/or
|
|
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 && !(
|
|
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'
|
|
1402
|
-
if (result.value)
|
|
1403
|
-
|
|
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)
|