scimgateway 6.2.2 → 6.2.3

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/CHANGELOG.md ADDED
@@ -0,0 +1,127 @@
1
+ # Change Log
2
+
3
+ ### v6.2.3
4
+ - **[Improved]** `plugin-entra-id` now includes information on whether a user has registered for MFA (has an MFA-capable method registered).
5
+
6
+ ### v6.2.2
7
+ - **[Improved]** `plugin-entra-id` now supports Entra ID IGA Access Packages. For required API permissions, see Entra ID App Registration
8
+
9
+ ### v6.2.1
10
+ - `HelperRest`: fixed minor log cosmetics introduced in v6.2.0
11
+
12
+ ### v6.2.0
13
+ - **[Fixed]** `HelperRest`: failed on Bun v1.3.14 due to stricter Fetch standards compliance
14
+ - **[Improved]** New `plugin-generic` replaces `plugin-scim`. Uses `endpointMapper` with the new `valueMap` option for group allowlisting and name mapping. Default config uses one-to-one SCIM mapping with plugin-loki as the target endpoint.
15
+ - **[Improved]** `endpointMapper` now supports `valueMap`:
16
+
17
+ ```json
18
+ "map": {
19
+ "group": {
20
+ "displayName": {
21
+ "mapTo": "displayName",
22
+ "type": "string",
23
+ "valueMap": {
24
+ "outboundEndpointGrp1": "inboundScimGrp1",
25
+ "Employees": "Admins"
26
+ }
27
+ }
28
+ }
29
+ }
30
+ ```
31
+
32
+ Clients only see and manage the SCIM-named groups (`inboundScimGrp1`, `Admins`), mapped to their endpoint counterparts (`outboundEndpointGrp1`, `Employees`). Useful for allowlisting specific groups or supporting different inbound/outbound names.
33
+
34
+ ### v6.1.20
35
+ - `plugin-entra-id`: roles introduced in v6.1.19 were missing when retrieving a single user
36
+
37
+ ### v6.1.19
38
+ - **[Fixed]** SCIM v2.0 ResourceType endpoint schemas using incorrect id
39
+ - **[Improved]** `GET /Roles` and `GET /Entitlements` endpoint support, with user management via SCIM `roles` and `entitlements` attributes
40
+ - **[Improved]** `plugin-entra-id`: `entitlements` for Entra ID licenses (read-only); `roles` for Permanent and Eligible PIM roles (full management)
41
+ - PIM Eligible roles: requires `RoleEligibilitySchedule.ReadWrite.All`
42
+ - PIM Permanent roles: requires `RoleManagement.ReadWrite.Directory`
43
+ - Remove `map.user.roles` if above conditions are not met
44
+ - `skipSignInActivity` option (v6.1.17) no longer used; `signInActivity` and PIM role permissions are validated at startup
45
+
46
+ ### v6.1.18
47
+ - `createUser` and `modifyUser` now return the full user object, ensuring returned data reflects what was modified even when the endpoint hasn't internally synced yet
48
+
49
+ ### v6.1.17
50
+ - `plugin-entra-id`: fixed broken `filter=userName eq "user_upn"` introduced in v6.1.11 when using updated config with `map.user.signInActivity`
51
+ - `plugin-entra-id`: new option `endpoint.entity.[baseEntity].skipSignInActivity = true` to exclude `signInActivity` (requires Entra ID Premium + `AuditLog.Read.All`)
52
+
53
+ ### v6.1.16
54
+ - `plugin-entra-id`: `GET /Entitlements` now uses `derivedIncludes` with full recursive expansion
55
+
56
+ ### v6.1.15
57
+ - `plugin-entra-id`: fixed `filter=entitlements pr`
58
+
59
+ ### v6.1.14
60
+ - Support for filter `attribute not pr`
61
+ - Dependencies bump
62
+
63
+ ### v6.1.13
64
+ - `plugin-entra-id`: `signInActivity` attributes are now filterable
65
+
66
+ ### v6.1.12
67
+ - Filter operator `pr` (presence) now forwarded to plugins (previously rejected)
68
+ - `plugin-entra-id`: handles `pr` filter on entitlements
69
+
70
+ ### v6.1.11
71
+ - **[Fixed]** Incorrect schema generation when using `endpointMapper` (regression from v6.1.6)
72
+ - **[Improved]** New `GET /Entitlements` endpoint and `scimgateway.getEntitlements()` method
73
+ - `plugin-entra-id`: user license information via `entitlements`; remove `map.user.signInActivity` if Entra ID Premium is unavailable
74
+
75
+ ### v6.1.10
76
+ - `plugin-entra-id`: group membership now includes nested (transitive) groups (`direct` and `indirect`)
77
+ - Fixed missing Docker files: `config/docker/.dockerignore` and `docker-compose-mssql.yml`
78
+
79
+ ### v6.1.9
80
+ - `createUser`/`createGroup` responses now correctly include the generated ID
81
+
82
+ ### v6.1.8 / v6.1.7
83
+ - Fixed incorrect masking of secrets in request info log messages
84
+ - `plugin-entra-id`: fixed edge case where `createUser` with a manager could fail
85
+
86
+ ### v6.1.6
87
+ - Fixed `plugin-loki` and `plugin-mongodb` returning empty results when using extension schema attributes in search
88
+ - Auth failure due to `readOnly` now returns HTTP 405 instead of 401
89
+ - `postinstall` ensures `"type": "module"` is set in `package.json`
90
+ - `endpointMapper` now generates a custom schema; supports `"x-agent-schema"` for AI MCP tool instructions
91
+
92
+ ### v6.1.5
93
+ - Complex filtering (`and`/`or`) handled by the gateway using the plugin's simple filter logic
94
+ - `modifyGroup` now returns HTTP 204 instead of 200
95
+ - New `/auth` endpoint for validating external authentication
96
+ - `plugin-entra-id`: supports `sw` (startsWith) filter
97
+
98
+ ### v6.1.4
99
+ - Fixed OData paging in `plugin-entra-id` and `helper-rest` — missing users/groups/members in large directories
100
+ - Fixed incomplete group membership when paging not fully iterated
101
+
102
+ ### v6.1.3
103
+ - Azure Relay: improved recovery on failure
104
+ - `plugin-ldap`: improvements for Active Directory and `objectGUID`/`mS-DS-ConsistencyGuid`
105
+ - `modifyGroup`: adding an existing member or removing a non-existent member now returns 200 OK instead of an error
106
+
107
+ ### v6.1.2
108
+ - Fixed SMTP mail failure caused by an updated dependency
109
+ - Fixed `endpointMapper` when `mapTo` contained multiple comma-separated attributes including a multivalued one
110
+
111
+ ### v6.1.1
112
+ - `plugin-ldap`: fixed race condition where `createUser` immediately followed by `readUser` could fail on some systems (e.g. Samba AD)
113
+ - Final info log message now includes full JSON serialization (durationMs, status, requestBody, responseBody, …)
114
+
115
+ ### v6.1.0
116
+ - `tsx` included — SCIM Gateway now runs as ES module (TypeScript) in Node.js: `node --import=tsx ./index.ts`
117
+ - Simplified mandatory plugin initialization using static `import`
118
+ - `index.ts` updated to use static imports
119
+ - Bun binary builds now supported (see Single Binary Deployment)
120
+
121
+ ### v6.0.0 — Major
122
+ - API method response bodies returned as-is (previously wrapped in `{ result: <content> }`) — **clients parsing responses must be updated**
123
+ - New `scimgateway.publicApi()` for unauthenticated `/pub/api` routes
124
+ - `bearerJwtAzure.tenantIdGUID` replaced by `bearerJwt.azureTenantId` — **existing configurations must be updated**
125
+
126
+ ### v5.x — Previous Major Series
127
+ For v5.x change history (Bun/TypeScript migration, Azure Relay, Bulk Operations, SCIM Stream, HelperRest, Docker, email OAuth, and more), see the GitHub commit history.
package/README.md CHANGED
@@ -76,37 +76,12 @@ SCIM Gateway is a user provisioning bridge built with [Bun](https://bun.sh/) and
76
76
  - [Custom Schemas](#custom-schemas)
77
77
  - [License](#license)
78
78
  - [Change Log](#change-log)
79
- - [v6.2.2](#v622)
80
- - [v6.2.1](#v621)
81
- - [v6.2.0](#v620)
82
- - [v6.1.20](#v6120)
83
- - [v6.1.19](#v6119)
84
- - [v6.1.18](#v6118)
85
- - [v6.1.17](#v6117)
86
- - [v6.1.16](#v6116)
87
- - [v6.1.15](#v6115)
88
- - [v6.1.14](#v6114)
89
- - [v6.1.13](#v6113)
90
- - [v6.1.12](#v6112)
91
- - [v6.1.11](#v6111)
92
- - [v6.1.10](#v6110)
93
- - [v6.1.9](#v619)
94
- - [v6.1.8 / v6.1.7](#v618--v617)
95
- - [v6.1.6](#v616)
96
- - [v6.1.5](#v615)
97
- - [v6.1.4](#v614)
98
- - [v6.1.3](#v613)
99
- - [v6.1.2](#v612)
100
- - [v6.1.1](#v611)
101
- - [v6.1.0](#v610)
102
- - [v6.0.0 — Major](#v600--major)
103
- - [v5.x — Previous Major Series](#v5x--previous-major-series)
104
79
 
105
80
  ---
106
81
 
107
82
  ## What's New
108
83
 
109
- - **`plugin-entra-id`** now supports Entra ID roles and access packages, in addition to reading licenses.
84
+ - **`plugin-entra-id`** now supports Entra ID roles and access packages, in addition to reading MFA capabilities and licenses.
110
85
  - **`plugin-generic`** replaces `plugin-scim` — a flexible template using `endpointMapper` with the new `valueMap` option for allowlisting and name mapping e.g., groups
111
86
  - **`GET /Roles` and `GET /Entitlements`** endpoint support, with user management via SCIM `roles` and `entitlements` attributes; `plugin-entra-id` uses `entitlements` for Entra ID licenses (read-only) and `roles` for Permanent and Eligible PIM roles (full management)
112
87
  - **AI Agent ready** — `x-agent-schema` configuration in `endpointMapper` enables custom schema generation with MCP tool instructions for autonomous provisioning agents
@@ -1077,8 +1052,9 @@ The `baseEntity` parameter enables multi-tenant setups — create multiple endpo
1077
1052
  4. **API permissions → Add → Microsoft Graph → Application permissions:**
1078
1053
  - `Directory.ReadWriteAll`
1079
1054
  - `Organization.ReadWrite.All`
1080
- - Additional for signInActivity, roles, licenses and access packages:
1081
- - `AuditLog.Read.All` *(only if using `map.user.signInActivity`; requires Entra ID Premium)*
1055
+ - Additional for signInActivity, MFA, roles, and access packages:
1056
+ - `AuditLog.Read.All` *(sign-in activity; only if using `map.user.signInActivity`; requires Entra ID Premium)*
1057
+ - `UserAuthenticationMethod.Read.All` *(MFA information; only if using `map.user.mfa`)*
1082
1058
  - `RoleEligibilitySchedule.ReadWrite.Directory` *(PIM Eligible roles; only if using `map.user.roles`)*
1083
1059
  - `RoleManagement.ReadWrite.Directory` *(PIM Permanent roles; only if using `map.user.roles`)*
1084
1060
  - `EntitlementManagement.ReadWrite.All` *(IGA Access Packages; only if using `map.user.entitlements`)*
@@ -1087,7 +1063,7 @@ The `baseEntity` parameter enables multi-tenant setups — create multiple endpo
1087
1063
 
1088
1064
  > For full access to admin users, assign the `Global Administrator` role. The `User Administrator` role has limitations on users with admin roles.
1089
1065
 
1090
- > `signInActivity, roles, licenses and access packages` requires permissions above. Note, `ReadWrite` can be replaced with `Read` if management is not required. **Remove any mapping configuration whose conditions are not met** — Minimum read permissions are validated at startup.
1066
+ > Note, if PIM and Access Package `management` is not required, `ReadWrite` can be replaced with `Read`. **Remove any mapping configuration whose conditions are not met** — The minimum `Read` permissions are validated at startup.
1091
1067
 
1092
1068
  ### Plugin Configuration
1093
1069
 
@@ -1279,126 +1255,4 @@ MIT © [Jarle Elshaug](https://www.elshaug.xyz)
1279
1255
  ---
1280
1256
 
1281
1257
  ## Change Log
1282
-
1283
- ### v6.2.2
1284
- - **[Improved]** `plugin-entra-id` now supports Entra ID IGA Access Packages. For required API permissions, see [Entra ID App Registration](#entra-id-app-registration)
1285
-
1286
- ### v6.2.1
1287
- - `HelperRest`: fixed minor log cosmetics introduced in v6.2.0
1288
-
1289
- ### v6.2.0
1290
- - **[Fixed]** `HelperRest`: failed on Bun v1.3.14 due to stricter Fetch standards compliance
1291
- - **[Improved]** New `plugin-generic` replaces `plugin-scim`. Uses `endpointMapper` with the new `valueMap` option for group allowlisting and name mapping. Default config uses one-to-one SCIM mapping with plugin-loki as the target endpoint.
1292
- - **[Improved]** `endpointMapper` now supports `valueMap`:
1293
-
1294
- ```json
1295
- "map": {
1296
- "group": {
1297
- "displayName": {
1298
- "mapTo": "displayName",
1299
- "type": "string",
1300
- "valueMap": {
1301
- "outboundEndpointGrp1": "inboundScimGrp1",
1302
- "Employees": "Admins"
1303
- }
1304
- }
1305
- }
1306
- }
1307
- ```
1308
-
1309
- Clients only see and manage the SCIM-named groups (`inboundScimGrp1`, `Admins`), mapped to their endpoint counterparts (`outboundEndpointGrp1`, `Employees`). Useful for allowlisting specific groups or supporting different inbound/outbound names.
1310
-
1311
- ### v6.1.20
1312
- - `plugin-entra-id`: roles introduced in v6.1.19 were missing when retrieving a single user
1313
-
1314
- ### v6.1.19
1315
- - **[Fixed]** SCIM v2.0 ResourceType endpoint schemas using incorrect id
1316
- - **[Improved]** `GET /Roles` and `GET /Entitlements` endpoint support, with user management via SCIM `roles` and `entitlements` attributes
1317
- - **[Improved]** `plugin-entra-id`: `entitlements` for Entra ID licenses (read-only); `roles` for Permanent and Eligible PIM roles (full management)
1318
- - PIM Eligible roles: requires `RoleEligibilitySchedule.ReadWrite.All`
1319
- - PIM Permanent roles: requires `RoleManagement.ReadWrite.Directory`
1320
- - Remove `map.user.roles` if above conditions are not met
1321
- - `skipSignInActivity` option (v6.1.17) no longer used; `signInActivity` and PIM role permissions are validated at startup
1322
-
1323
- ### v6.1.18
1324
- - `createUser` and `modifyUser` now return the full user object, ensuring returned data reflects what was modified even when the endpoint hasn't internally synced yet
1325
-
1326
- ### v6.1.17
1327
- - `plugin-entra-id`: fixed broken `filter=userName eq "user_upn"` introduced in v6.1.11 when using updated config with `map.user.signInActivity`
1328
- - `plugin-entra-id`: new option `endpoint.entity.[baseEntity].skipSignInActivity = true` to exclude `signInActivity` (requires Entra ID Premium + `AuditLog.Read.All`)
1329
-
1330
- ### v6.1.16
1331
- - `plugin-entra-id`: `GET /Entitlements` now uses `derivedIncludes` with full recursive expansion
1332
-
1333
- ### v6.1.15
1334
- - `plugin-entra-id`: fixed `filter=entitlements pr`
1335
-
1336
- ### v6.1.14
1337
- - Support for filter `attribute not pr`
1338
- - Dependencies bump
1339
-
1340
- ### v6.1.13
1341
- - `plugin-entra-id`: `signInActivity` attributes are now filterable
1342
-
1343
- ### v6.1.12
1344
- - Filter operator `pr` (presence) now forwarded to plugins (previously rejected)
1345
- - `plugin-entra-id`: handles `pr` filter on entitlements
1346
-
1347
- ### v6.1.11
1348
- - **[Fixed]** Incorrect schema generation when using `endpointMapper` (regression from v6.1.6)
1349
- - **[Improved]** New `GET /Entitlements` endpoint and `scimgateway.getEntitlements()` method
1350
- - `plugin-entra-id`: user license information via `entitlements`; remove `map.user.signInActivity` if Entra ID Premium is unavailable
1351
-
1352
- ### v6.1.10
1353
- - `plugin-entra-id`: group membership now includes nested (transitive) groups (`direct` and `indirect`)
1354
- - Fixed missing Docker files: `config/docker/.dockerignore` and `docker-compose-mssql.yml`
1355
-
1356
- ### v6.1.9
1357
- - `createUser`/`createGroup` responses now correctly include the generated ID
1358
-
1359
- ### v6.1.8 / v6.1.7
1360
- - Fixed incorrect masking of secrets in request info log messages
1361
- - `plugin-entra-id`: fixed edge case where `createUser` with a manager could fail
1362
-
1363
- ### v6.1.6
1364
- - Fixed `plugin-loki` and `plugin-mongodb` returning empty results when using extension schema attributes in search
1365
- - Auth failure due to `readOnly` now returns HTTP 405 instead of 401
1366
- - `postinstall` ensures `"type": "module"` is set in `package.json`
1367
- - `endpointMapper` now generates a custom schema; supports `"x-agent-schema"` for AI MCP tool instructions
1368
-
1369
- ### v6.1.5
1370
- - Complex filtering (`and`/`or`) handled by the gateway using the plugin's simple filter logic
1371
- - `modifyGroup` now returns HTTP 204 instead of 200
1372
- - New `/auth` endpoint for validating external authentication
1373
- - `plugin-entra-id`: supports `sw` (startsWith) filter
1374
-
1375
- ### v6.1.4
1376
- - Fixed OData paging in `plugin-entra-id` and `helper-rest` — missing users/groups/members in large directories
1377
- - Fixed incomplete group membership when paging not fully iterated
1378
-
1379
- ### v6.1.3
1380
- - Azure Relay: improved recovery on failure
1381
- - `plugin-ldap`: improvements for Active Directory and `objectGUID`/`mS-DS-ConsistencyGuid`
1382
- - `modifyGroup`: adding an existing member or removing a non-existent member now returns 200 OK instead of an error
1383
-
1384
- ### v6.1.2
1385
- - Fixed SMTP mail failure caused by an updated dependency
1386
- - Fixed `endpointMapper` when `mapTo` contained multiple comma-separated attributes including a multivalued one
1387
-
1388
- ### v6.1.1
1389
- - `plugin-ldap`: fixed race condition where `createUser` immediately followed by `readUser` could fail on some systems (e.g. Samba AD)
1390
- - Final info log message now includes full JSON serialization (durationMs, status, requestBody, responseBody, …)
1391
-
1392
- ### v6.1.0
1393
- - `tsx` included — SCIM Gateway now runs as ES module (TypeScript) in Node.js: `node --import=tsx ./index.ts`
1394
- - Simplified mandatory plugin initialization using static `import`
1395
- - `index.ts` updated to use static imports
1396
- - Bun binary builds now supported (see [Single Binary Deployment](#single-binary-deployment))
1397
-
1398
- ### v6.0.0 — Major
1399
- - API method response bodies returned as-is (previously wrapped in `{ result: <content> }`) — **clients parsing responses must be updated**
1400
- - New `scimgateway.publicApi()` for unauthenticated `/pub/api` routes
1401
- - `bearerJwtAzure.tenantIdGUID` replaced by `bearerJwt.azureTenantId` — **existing configurations must be updated**
1402
-
1403
- ### v5.x — Previous Major Series
1404
- For v5.x change history (Bun/TypeScript migration, Azure Relay, Bulk Operations, SCIM Stream, HelperRest, Docker, email OAuth, and more), see the [GitHub commit history](https://github.com/jelhub/scimgateway/commits/master/).
1258
+ For a detailed history of changes, please see the [Change Log](https://github.com/jelhub/scimgateway/blob/master/CHANGELOG.md).
@@ -171,6 +171,7 @@
171
171
  "comment": "This mapping requires Entra ID Premium license and API permissions: 'AuditLog.Read.All'. Remove this mapping if conditions not met",
172
172
  "mapTo": "signInActivity",
173
173
  "type": "complexObject",
174
+ "mutability": "readOnly",
174
175
  "subAttributes": [
175
176
  {
176
177
  "name": "lastSignInDateTime",
@@ -189,7 +190,30 @@
189
190
  }
190
191
  ],
191
192
  "x-agent-schema": {
192
- "description": "Read-only attribute-object representing Entra ID signInActivity like: lastSignInDateTime, lastSuccessfulSignInDateTime and lastNonInteractiveSignInDateTime. Note: Only availabe for users having P1/P2 licenses. If signInActivity is missing, agent should not consider this to be no sign-in activity for the user.",
193
+ "description": "Read-only attribute object representing Entra ID signInActivity like: lastSignInDateTime, lastSuccessfulSignInDateTime and lastNonInteractiveSignInDateTime. Note: Only availabe for users having P1/P2 licenses. If signInActivity is missing, agent should not consider this to be no sign-in activity for the user.",
194
+ "readOnly": true
195
+ }
196
+ },
197
+ "mfa": {
198
+ "mapTo": "mfa",
199
+ "type": "complexObject",
200
+ "mutability": "readOnly",
201
+ "subAttributes": [
202
+ {
203
+ "name": "isMfaCapable",
204
+ "mutability": "readOnly",
205
+ "description": "true/false - user has at least one MFA-capable method registered (members only; for guests, methods are in home tenant and enforcement is via Conditional Access).",
206
+ "type": "boolean"
207
+ },
208
+ {
209
+ "name": "isLegacyEnabled",
210
+ "mutability": "readOnly",
211
+ "description": "true/false - legacy per-user MFA is enabled ('perUserMfaState' is 'enabled' or 'enforced'); such users bypass/aren´t fully controlled by standard Conditional Access only design and therefore require special attention.",
212
+ "type": "boolean"
213
+ }
214
+ ],
215
+ "x-agent-schema": {
216
+ "description": "Read-only attribute object representing the user's MFA capabilities. Note: 'mfa.isMfaCapable' does not mean MFA is enabled for the user. Conditional Access policies or using Security Defaults must be verified separately.",
193
217
  "readOnly": true
194
218
  }
195
219
  },
@@ -705,8 +705,11 @@ export class HelperRest {
705
705
  if (f.status > 399) {
706
706
  if (f.status === 429) { // throttle
707
707
  const v = f.headers.get('retry-after')
708
- if (v) retryAfter = parseInt(v, 10) + 1
709
- else retryAfter = 10
708
+ if (v) {
709
+ retryAfter = parseInt(v, 10)
710
+ if (isNaN(retryAfter)) retryAfter = 10
711
+ retryAfter += 1
712
+ } else retryAfter = 10
710
713
  }
711
714
  throw new Error(JSON.stringify(result))
712
715
  }
@@ -765,6 +768,12 @@ export class HelperRest {
765
768
  if (err.message.includes('ratelimit')) { // have seen throttling not follow standard 429/retry-after, but instead using 500 and error message only
766
769
  if (!retryAfter) retryAfter = 60
767
770
  }
771
+
772
+ if (retryAfter) {
773
+ this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} throttle/ratelimit error - awaiting ${retryAfter} seconds before automatic retry`)
774
+ await new Promise(resolve => setTimeout(resolve, retryAfter * 1000))
775
+ }
776
+
768
777
  if (!retryCount) retryCount = 0
769
778
  let urlObj
770
779
  try { urlObj = new URL(path) } catch (err) { void 0 }
@@ -773,12 +782,6 @@ export class HelperRest {
773
782
 
774
783
  if (isServiceClient && (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.code === 'ABORT_ERR' || err.code === 'ETIMEDOUT' || statusCode === 504 || oAuthTokeErr || retryAfter)) {
775
784
  this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
776
- if (retryAfter) {
777
- this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} throttle/ratelimit error - awaiting ${retryAfter} seconds before automatic retry`)
778
- await new Promise(resolve => setTimeout(function () {
779
- resolve(null)
780
- }, retryAfter * 1000))
781
- }
782
785
  if (retryCount < connectionObj.baseUrls.length) {
783
786
  retryCount++
784
787
  if (isServiceClient) {
@@ -791,13 +794,13 @@ export class HelperRest {
791
794
  const ret = await this.doRequestHandler(baseEntity, method, path, body, ctx, opt, retryCount) // retry
792
795
  return ret // problem fixed
793
796
  } else {
794
- if (statusCode === 404) { // not logged as error e.g. getUser-manager
797
+ if (statusCode === 404 || ctx.skipLogAsError) {
795
798
  this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
796
799
  } else this.scimgateway.logError(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
797
800
  throw err
798
801
  }
799
802
  } else {
800
- if (statusCode === 404) { // not logged as error e.g. getUser-manager
803
+ if (statusCode === 404 || ctx.skipLogAsError) { // 404 not logged as error e.g. getUser-manager
801
804
  this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${options.url} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
802
805
  } else this.scimgateway.logError(baseEntity, `doRequest ${method} ${options.url} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
803
806
  if (statusCode === 401) delete this._serviceClient[baseEntity]
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // Author: Jarle Elshaug
5
5
  //
6
- // Purpose: Entra ID provisioning including licenses e.g. O365
6
+ // Purpose: Entra ID user and group provisioning including roles and access packages in addition to retrieving license, MFA and sign-in information
7
7
  //
8
8
  // Prereq: Entra ID configuration:
9
9
  // Entra Application key defined (clientsecret). Other options are upload a certificate or configure "Federated Identity Credentials"
@@ -21,14 +21,15 @@
21
21
  // Schema generated according mapping configuration.
22
22
  // Note:
23
23
  // - 'map.user.signInActivity' requires Entra ID Premium license and API permissions 'AuditLog.Read.All'.
24
+ // - 'map.user.mfa' provides MFA status information and requires API permissions 'UserAuthenticationMethod.Read.All'
24
25
  // - 'map.user.entitlements' relates to Licenses and Access Packages. Access Packages requires API permissions 'EntitlementManagement.ReadWrite.All'
25
26
  // - 'map.user.roles relates to standard Permanent roles and PIM Permanent and Eligible roles.
26
27
  // PIM is included on tenant having P2 or Governance License and requires following API permissions:
27
28
  // - PIM Eligible roles requires API permissions 'RoleEligiblitySchedule.ReadWrite.All'
28
29
  // - PIM Permanent roles requires API permissions 'RoleManagement.ReadWrite.Directory'
29
- // - Remove mapping if conditions not met
30
+ // - Remove mapping if conditions not met or consider using only 'Read' if no management needed
30
31
  //
31
- // /User SCIM (custom) Endpoint (AAD)
32
+ // /User SCIM (custom) Endpoint (Entra ID)
32
33
  // --------------------------------------------------------------------------------------------
33
34
  // User Principal Name userName userPrincipalName
34
35
  // Id id id
@@ -61,9 +62,10 @@
61
62
  // Groups groups - virtual readOnly N/A
62
63
  // Roles roles roles (roleAssignments/roleEligibilitySchedules) - type=Permanent/Eligiable, value=id, display=role display name
63
64
  // Entitlements entitlements entitlements (assignedLicenses) - type=License, value=skuId and display=user-friendly-license-name / type=AccessPackage, value=AP-id and display=AP-displayName
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".
65
+ // SignInActivity signInActivity signInActivity (lastSignInDateTime, lastSuccessfulSignInDateTime and lastNonInteractiveSignInDateTime)
66
+ // MFA mfa mfa (mfa.isMfaCapable and mfa.isLegacyEnabled)
65
67
  //
66
- // /Group SCIM (custom) Endpoint (AAD)
68
+ // /Group SCIM (custom) Endpoint (Entra ID)
67
69
  // --------------------------------------------------------------------------------------------
68
70
  // Name displayName displayName
69
71
  // Id id id
@@ -122,6 +124,7 @@ for (const key in config.map.user) { // mapAttributesTo = ['id', 'country', 'pre
122
124
  let attr = key.split('.')[0]
123
125
  // complexArray/complexObject are special
124
126
  if (config.map.user[key].mapTo === 'entitlements') attr = 'assignedLicenses'
127
+ if (config.map.user[key].mapTo === 'mfa') attr = 'perUserMfaState'
125
128
  if (config.map.user[key].mapTo === 'roles') continue
126
129
 
127
130
  if (!userSelectAttributes.includes(attr)) userSelectAttributes.push(attr)
@@ -147,22 +150,32 @@ if (!groupAttributes.includes('members.value')) groupAttributes.push('members.va
147
150
  for (const baseEntity in config.entity) {
148
151
  try {
149
152
  permission[baseEntity] = {}
150
- const [signInResult, eligibleResult, permanentScheduleResult, accessPackageResult] = await Promise.allSettled([
153
+ let probeUserId: string | undefined
154
+ try {
155
+ const res = await helper.doRequest(baseEntity, 'GET', '/users?$top=1&$select=id', null, null)
156
+ probeUserId = res?.body?.value?.[0]?.id
157
+ } catch (err) { }
158
+
159
+ const [signInResult, eligibleResult, permanentScheduleResult, accessPackageResult, mfaResult] = await Promise.allSettled([
151
160
  (async () => {
152
161
  if (!mapAttributesTo.includes('signInActivity')) throw new Error('skipping signInActivity check')
153
- await helper.doRequest(baseEntity, 'GET', '/users?$top=1&$select=id,signInActivity', null, null)
162
+ await helper.doRequest(baseEntity, 'GET', '/users?$top=1&$select=id,signInActivity', null, { skipLogAsError: true })
154
163
  })(),
155
164
  (async () => {
156
165
  if (!mapAttributesTo.includes('roles')) throw new Error('skipping eligible check')
157
- await helper.doRequest(baseEntity, 'GET', '/roleManagement/directory/roleEligibilityScheduleInstances?$top=1', null, null)
166
+ await helper.doRequest(baseEntity, 'GET', '/roleManagement/directory/roleEligibilityScheduleInstances?$top=1', null, { skipLogAsError: true })
158
167
  })(),
159
168
  (async () => {
160
169
  if (!mapAttributesTo.includes('roles')) throw new Error('skipping permanent schedule check')
161
- await helper.doRequest(baseEntity, 'GET', '/roleManagement/directory/roleAssignmentScheduleInstances?$top=1', null, null)
170
+ await helper.doRequest(baseEntity, 'GET', '/roleManagement/directory/roleAssignmentScheduleInstances?$top=1', null, { skipLogAsError: true })
162
171
  })(),
163
172
  (async () => {
164
173
  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)
174
+ await helper.doRequest(baseEntity, 'GET', '/identityGovernance/entitlementManagement/accessPackages?$top=1&$select=id', null, { skipLogAsError: true })
175
+ })(),
176
+ (async () => {
177
+ if (!mapAttributesTo.includes('mfa') || !probeUserId) throw new Error('skipping mfaMethods check')
178
+ await helper.doRequest(baseEntity, 'GET', `/users/${probeUserId}/authentication/methods`, null, { skipLogAsError: true })
166
179
  })(),
167
180
  ])
168
181
  if (signInResult.status === 'fulfilled') {
@@ -175,19 +188,25 @@ if (!groupAttributes.includes('members.value')) groupAttributes.push('members.va
175
188
  permission[baseEntity].eligible = true
176
189
  } else {
177
190
  permission[baseEntity].eligible = false
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'`)
191
+ 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 minimum API permission 'RoleEligibilitySchedule.Read.All'`)
179
192
  }
180
193
  if (permanentScheduleResult.status === 'fulfilled') {
181
194
  permission[baseEntity].permanentSchedule = true
182
195
  } else {
183
196
  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'`)
197
+ 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 minimum API permission 'RoleManagement.Read.Directory'`)
185
198
  }
186
199
  if (accessPackageResult.status === 'fulfilled') {
187
200
  permission[baseEntity].accessPackage = true
188
201
  } else {
189
202
  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'`)
203
+ if (mapAttributesTo.includes('roles')) scimgateway.logError(baseEntity, `IGA Access Packages functionality has been deactivated because it requires minimum API permission 'EntitlementManagement.Read.All'`)
204
+ }
205
+ if (mfaResult.status === 'fulfilled') {
206
+ permission[baseEntity].mfa = true
207
+ } else {
208
+ permission[baseEntity].mfa = false
209
+ if (mapAttributesTo.includes('mfa')) scimgateway.logError(baseEntity, `MFA Methods functionality has been deactivated because it requires API permissions 'UserAuthenticationMethod.Read.All'`)
191
210
  }
192
211
  } catch (err) {}
193
212
  }
@@ -217,7 +236,7 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
217
236
  Resources: [],
218
237
  totalResults: null,
219
238
  }
220
- let response: any
239
+ let response: Record<string, any> = {}
221
240
  let selectAttributes: string[] = []
222
241
 
223
242
  if (attributes.length > 0) {
@@ -227,6 +246,7 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
227
246
  if (!attr) continue
228
247
  // complexArray/complexObject are special
229
248
  if (attribute.startsWith('entitlements')) attr = 'assignedLicenses'
249
+ if (attribute.startsWith('mfa')) attr = 'perUserMfaState'
230
250
  if (attribute.startsWith('roles')) continue
231
251
  if (!selectAttributes.includes(attr)) selectAttributes.push(attr)
232
252
  }
@@ -289,17 +309,18 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
289
309
  if (config.map.user[arr[0]] && ['complexArray', 'complexObject'].includes(config.map.user[arr[0]]?.type)) {
290
310
  if (arr[0] === 'roles') {
291
311
  if (type && type !== 'Permanent' && type !== 'Eligible') throw new Error(`${action} filter error: when using roles.type, the type must be either 'Permanent' or 'Eligible`)
312
+ if (!type && obj.operator === 'pr') obj = { attribute: 'roles.type' } // 'filter=roles pr' - precense => filter all objects having roles
292
313
  const o = await getUsersByRole(baseEntity, obj, (type) ? decodeURIComponent(type) as 'Permanent' | 'Eligible' : undefined, ctx)
293
-
294
314
  if (!Array.isArray(o) || o.length === 0) return ret
295
- const fnArr: { fn: () => Promise<any> }[] = []
315
+ const fnArr: { fn: () => Promise<any>, index?: number, objArr?: any, key?: string }[] = []
316
+ response.body = { value: [] }
317
+ if (!response.body?.value) response.body = { value: [] }
296
318
  for (const id of o) {
297
319
  const userPath = `/users/${id}?$select=${selectAttributes.join(',')}`
298
320
  const fn = () => helper.doRequest(baseEntity, 'GET', userPath, null, ctx?.headers ? { headers: ctx?.headers } : undefined)
299
- fnArr.push({ fn })
321
+ fnArr.push({ fn, objArr: response.body.value })
300
322
  }
301
- response = { body: { value: [] } }
302
- await fnCunckExecute(fnArr, response.body.value) // fnCunckExecute results in response.body.value and evaluated later
323
+ await fnCunckExecute(fnArr) // fnCunckExecute results in response.body.value and evaluated later
303
324
  if (response.body.value.length === 0) return ret
304
325
  } else if (arr[0] === 'entitlements') { // using entitlements for licenses and access packages
305
326
  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"]`)
@@ -325,19 +346,20 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
325
346
  }
326
347
  if (typeof o !== 'object' || o === null || Object.keys(o).length === 0) return ret
327
348
  const isAttrsOk = attributes.length > 0 && attributes.length < 3 && (attributes.includes('id') || attributes.includes('displayName'))
328
- const fnArr: { fn: () => Promise<any> }[] = []
349
+ const fnArr: { fn: () => Promise<any>, index?: number, objArr?: any, key?: string }[] = []
350
+ response.body = { value: [] }
351
+ if (!response.body?.value) response.body = { value: [] }
329
352
  for (const key in o) {
330
353
  if (isAttrsOk) ret.Resources.push(o[key])
331
354
  else {
332
355
  const userPath = `/users/${key}?$select=${selectAttributes.join(',')}`
333
356
  const fn = () => helper.doRequest(baseEntity, 'GET', userPath, null, ctx?.headers ? { headers: ctx?.headers } : undefined)
334
- fnArr.push({ fn })
357
+ fnArr.push({ fn, objArr: response.body.value })
335
358
  }
336
359
  }
337
360
  if (isAttrsOk) return ret
338
361
  else {
339
- response = { body: { value: [] } }
340
- await fnCunckExecute(fnArr, response.body.value) // fnCunckExecute results in response.body.value and evaluated later
362
+ await fnCunckExecute(fnArr)
341
363
  if (response.body.value.length === 0) return ret
342
364
  }
343
365
  } else throw new Error(`${action} error: entitlements.type must be either "License" or "AccessPackage"`)
@@ -412,31 +434,30 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
412
434
  if (!response.body.value) {
413
435
  throw new Error(`invalid response: ${JSON.stringify(response)}`)
414
436
  }
415
- const fnArr: { index: number, fn: () => Promise<any> }[] = []
437
+ const fnArr: { fn: () => Promise<any>, index?: number, objArr?: any, key?: string }[] = []
416
438
  const byValues = await getEntitlementsByValues(baseEntity, ctx)
417
439
 
418
- // include manager
419
- if (!isExpandManager && selectAttributes.includes('manager')) {
420
- for (let i = 0; i < response.body.value.length; ++i) {
421
- if (!response.body.value[i].id) break
440
+ for (let i = 0; i < response.body.value.length; ++i) {
441
+ if (!response.body.value[i].id) break
442
+
443
+ // include manager
444
+ if (!isExpandManager && selectAttributes.includes('manager')) {
422
445
  const singleUserPath = `/users/${response.body.value[i].id}/manager?$select=userPrincipalName`
423
446
  const fn = () => helper.doRequest(baseEntity, 'GET', singleUserPath, null, ctx?.headers ? { headers: ctx?.headers } : undefined, options)
424
- fnArr.push({ index: i, fn })
447
+ // fnArr.push({ index: i, fn })
448
+ fnArr.push({ index: i, fn, objArr: response.body.value, key: 'manager' })
425
449
  }
426
- await fnCunckExecute(fnArr, response.body.value, 'manager')
427
- }
428
450
 
429
- // include groups (before roles)
430
- if (attributes.length === 0 || attributes.includes('groups')) {
431
- for (let i = 0; i < response.body.value.length; ++i) {
432
- if (!response.body.value[i].id) break
451
+ // include groups (before roles)
452
+ if (attributes.length === 0 || attributes.includes('groups')) {
433
453
  const fn = () => scimgateway.getUserGroups(baseEntity, response.body.value[i].id, ctx?.headers ? { headers: ctx?.headers } : undefined)
434
- fnArr.push({ index: i, fn })
454
+ fnArr.push({ index: i, fn, objArr: response.body.value, key: 'groups' })
435
455
  }
436
- await fnCunckExecute(fnArr, response.body.value, 'groups')
437
456
  }
438
457
 
439
- // attribute cleanup and mapping
458
+ if (fnArr.length > 0) await fnCunckExecute(fnArr)
459
+
460
+ // attribute mapping and cleanup
440
461
  for (let i = 0; i < response.body.value.length; ++i) {
441
462
  const obj = response.body.value[i]
442
463
  if (obj.manager?.userPrincipalName) {
@@ -445,13 +466,19 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
445
466
  else delete obj.manager
446
467
  }
447
468
 
469
+ if (obj.perUserMfaState && permission[baseEntity]?.mfa) {
470
+ const isLegacyEnabled = obj.perUserMfaState === 'enabled' || obj.perUserMfaState === 'enforced'
471
+ if (!obj.mfa) obj.mfa = {}
472
+ obj.mfa.isLegacyEnabled = isLegacyEnabled
473
+ }
474
+
448
475
  if (obj.signInActivity) {
449
476
  delete obj.signInActivity.lastSignInRequestId
450
477
  delete obj.signInActivity.lastNonInteractiveSignInRequestId
451
478
  delete obj.signInActivity.lastSuccessfulSignInRequestId
452
479
  }
453
480
 
454
- // include roles and entitlements
481
+ // include roles, entitlements and MFA - MFA here and not in above fnArr/fnCunckExecute to reduce throttle noise
455
482
  if (obj.id) {
456
483
  const roles = async (obj: Record<string, any>): Promise<Record<string, any>[]> => {
457
484
  // roles type=Permanent/Eligible
@@ -476,12 +503,26 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
476
503
  }
477
504
  return result
478
505
  }
506
+ const mfa = async (obj: Record<string, any>): Promise<boolean | undefined> => {
507
+ // include MFA 'isMfaCapable', which indicates whether the user has registered authentication methods eligible for MFA.
508
+ if (attributes.length === 0 || attributes.includes('mfa') || attributes.includes('mfa.isMfaCapable')) {
509
+ if (permission[baseEntity]?.mfa) {
510
+ return await getUserisMfaCapable(baseEntity, obj.id, ctx?.headers ? { headers: ctx?.headers } : undefined)
511
+ } else return undefined
512
+ }
513
+ }
514
+
479
515
  const arrResolve = await Promise.all([
480
516
  roles(obj),
481
517
  entitlements(obj),
518
+ mfa(obj),
482
519
  ])
483
520
  obj.roles = arrResolve[0]
484
521
  obj.entitlements = arrResolve[1]
522
+ if (arrResolve[2] !== undefined) {
523
+ if (!obj.mfa) obj.mfa = {}
524
+ obj.mfa.isMfaCapable = arrResolve[2]
525
+ }
485
526
  }
486
527
 
487
528
  // map to inbound
@@ -562,7 +603,6 @@ scimgateway.deleteUser = async (baseEntity, id, ctx) => {
562
603
  const method = 'DELETE'
563
604
  const path = `/Users/${id}`
564
605
  const body = null
565
-
566
606
  try {
567
607
  await helper.doRequest(baseEntity, method, path, body, ctx)
568
608
  return (null)
@@ -570,6 +610,7 @@ scimgateway.deleteUser = async (baseEntity, id, ctx) => {
570
610
  throw new Error(`${action} error: ${err.message}`)
571
611
  }
572
612
  }
613
+
573
614
  // =================================================
574
615
  // modifyUser
575
616
  // =================================================
@@ -577,10 +618,6 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
577
618
  const action = 'modifyUser'
578
619
  scimgateway.logDebug(baseEntity, `handling ${action} id=${id} attrObj=${JSON.stringify(attrObj)} passThrough=${ctx ? 'true' : 'false'}`)
579
620
 
580
- // roles and entitlements only supported for getUsers - readOnly
581
- // if (attrObj.roles) delete attrObj.roles
582
- // if (attrObj.entitlements) delete attrObj.entitlements
583
-
584
621
  const [parsedAttrObj]: Record<string, any>[] = scimgateway.endpointMapper('outbound', attrObj, config.map.user) // SCIM/CustomSCIM => endpoint attribute standard
585
622
  if (parsedAttrObj instanceof Error) throw (parsedAttrObj) // error object
586
623
 
@@ -774,7 +811,7 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
774
811
  const profile = () => { // patch
775
812
  return new Promise((resolve, reject) => {
776
813
  (async () => {
777
- if (JSON.stringify(parsedAttrObj) === '{}') return resolve(null)
814
+ if (Object.keys(parsedAttrObj).length === 0) return resolve(null)
778
815
  let res: any
779
816
  for (const key in parsedAttrObj) { // if object, the modified Entra ID object must contain all elements, if not they will be cleared e.g. employeeOrgData
780
817
  if (typeof parsedAttrObj[key] === 'object') { // get original object and merge
@@ -1682,6 +1719,31 @@ const getUserAccessPackages = async (baseEntity: string, userId: string, include
1682
1719
  return result
1683
1720
  }
1684
1721
 
1722
+ const isMethodMfaCapable = (odataType: string): boolean => {
1723
+ return [
1724
+ '#microsoft.graph.microsoftAuthenticatorAuthenticationMethod',
1725
+ '#microsoft.graph.passwordlessMicrosoftAuthenticatorAuthenticationMethod',
1726
+ '#microsoft.graph.phoneAuthenticationMethod',
1727
+ '#microsoft.graph.softwareOathAuthenticationMethod',
1728
+ '#microsoft.graph.fido2AuthenticationMethod',
1729
+ ].includes(odataType)
1730
+ }
1731
+
1732
+ const getUserisMfaCapable = async (baseEntity: string, userId: string, ctx?: undefined | Record<string, any>): Promise<boolean> => {
1733
+ const action = 'getUserAccessPackages'
1734
+ const method = 'GET'
1735
+ const body = null
1736
+ const path = `/users/${userId}/authentication/methods`
1737
+ const r = await helper.doRequest(baseEntity, method, path, body, ctx?.headers ? { headers: ctx?.headers } : undefined)
1738
+ if (!r.body?.value) {
1739
+ if (r.body?.id) r.body.value = [r.body]
1740
+ else throw new Error(`${action} error: invalid response: ${JSON.stringify(r)}`)
1741
+ }
1742
+ const methodTypes = r.body.value.map((m: any) => m['@odata.type'])
1743
+ if (methodTypes.length < 1) return false
1744
+ return methodTypes.some(isMethodMfaCapable)
1745
+ }
1746
+
1685
1747
  /**
1686
1748
  * getUsersByRole returns an array of user IDs having a specific role assigned
1687
1749
  * @param baseEntity
@@ -1724,23 +1786,24 @@ const getUsersByRole = async (baseEntity: string, getObj: Record<string, any>, t
1724
1786
 
1725
1787
  if (activePrincipals.size === 0) return []
1726
1788
 
1727
- // 3. Resolve Principals (determine if User or Group)
1789
+ // 3. Resolve principals (determine if user or group)
1728
1790
  const userIds = new Set<string>()
1729
- const principalsToResolve: { fn: () => Promise<any> }[] = []
1791
+ const fnArrPrincipalObjects: { fn: () => Promise<any>, index?: number, objArr?: any, key?: string }[] = []
1792
+ const principalObjects: any[] = []
1730
1793
  for (const pId of activePrincipals) {
1731
1794
  const path = `/directoryObjects/${pId}`
1732
- principalsToResolve.push({ fn: () => helper.doRequest(baseEntity, 'GET', path, null, ctx?.headers ? { headers: ctx?.headers } : undefined) })
1795
+ fnArrPrincipalObjects.push({ fn: () => helper.doRequest(baseEntity, 'GET', path, null, ctx?.headers ? { headers: ctx?.headers } : undefined), objArr: principalObjects })
1733
1796
  }
1734
- const principalObjects: any[] = []
1735
- await fnCunckExecute(principalsToResolve, principalObjects)
1797
+ if (fnArrPrincipalObjects.length > 0) await fnCunckExecute(fnArrPrincipalObjects)
1736
1798
 
1737
- // 4. Handle Users directly and fetch transitive members for Groups
1738
- const groupMembersToFetch: { fn: () => Promise<any> }[] = []
1799
+ // 4. Handle users directly and fetch transitive members for groups
1800
+ const fnArrGroupResults: { fn: () => Promise<any>, index?: number, objArr?: any, key?: string }[] = []
1801
+ const groupResults: any[] = []
1739
1802
  for (const obj of principalObjects) {
1740
1803
  if (!obj || !obj.id) continue
1741
1804
  if (obj['@odata.type'] === '#microsoft.graph.user') userIds.add(obj.id)
1742
1805
  else if (obj['@odata.type'] === '#microsoft.graph.group') {
1743
- // Use a custom function to fetch all transitive members including paging
1806
+ // fetch all transitive members including paging
1744
1807
  const fetchAllMembers = async (groupId: string) => {
1745
1808
  let members: any[] = []
1746
1809
  let nextPath: string | null = `/groups/${groupId}/transitiveMembers/microsoft.graph.user?$select=id`
@@ -1748,63 +1811,80 @@ const getUsersByRole = async (baseEntity: string, getObj: Record<string, any>, t
1748
1811
  const res = await helper.doRequest(baseEntity, 'GET', nextPath, null, ctx?.headers ? { headers: ctx?.headers } : undefined)
1749
1812
  if (!res.body?.value) break
1750
1813
  members.push(...res.body.value)
1751
- // extract nextLink and convert to relative path
1752
1814
  nextPath = res.body['@odata.nextLink'] ? res.body['@odata.nextLink'].split('/beta')[1] : null
1753
1815
  }
1754
- return { body: { value: members } } // Wrap results for fnCunckExecute compatibility
1816
+ return { body: { value: members } }
1755
1817
  }
1756
- groupMembersToFetch.push({ fn: () => fetchAllMembers(obj.id) })
1818
+ fnArrGroupResults.push({ fn: () => fetchAllMembers(obj.id), objArr: groupResults })
1757
1819
  }
1758
1820
  }
1759
- const groupResults: any[] = []
1760
- if (groupMembersToFetch.length > 0) await fnCunckExecute(groupMembersToFetch, groupResults)
1821
+
1822
+ if (fnArrGroupResults.length > 0) await fnCunckExecute(fnArrGroupResults)
1761
1823
  groupResults.forEach((m: any) => m.id && userIds.add(m.id.toLowerCase()))
1762
1824
 
1763
1825
  return Array.from(userIds)
1764
1826
  }
1765
1827
 
1766
1828
  /**
1767
- * fnCunckExecute runs functions asynchronous in chunks
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.
1770
- * @param key optionally key
1771
- * @returns undefined, but updated objArr if objArr argument is included
1829
+ * fnCunckExecute runs array of functions asynchronous in chunks
1830
+ * @param fnArr array of objects that must include function and optionally index, objArr and key [{fn, index, objArr, key}]. Function will always be executed. Any optional objArr will be updated according to provided index/key
1831
+ * @param fnArr.index optional and represent the index of `objArr` that should be updated with `key` set to the value of the function result
1832
+ * @param fnArr.objArr optionally array of objects or a single object that will be updated based on fn result. If index, `objArr[index].key` will be set to function result. If key, but no index, function result will be inserted to objArr[key].
1833
+ * @param fnArr.key optionally key
1834
+ * @param chunkSize optinally size of chunks used, default 5
1835
+ * @returns undefined, but updated objArr if objArr is included
1772
1836
  **/
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`)
1775
- if (fnArr.length > 0) {
1776
- if (typeof fnArr[0] !== 'object' || !fnArr[0].fn) throw new Error(`fnCunckExecute error: fnArr missing fn object(s)`)
1777
- else if (fnArr[0].index !== undefined && !(objArr || key)) throw new Error(`fnCunckExecute error: missing reponseValue/key`)
1778
- const chunk = 5
1779
- do {
1780
- const arrChunk = fnArr.splice(0, chunk)
1781
- const results = await Promise.allSettled(arrChunk.map(o => o.fn())) as { status: 'fulfilled' | 'rejected', reason: any, value: any }[] // processing max chunk async
1782
- const errors = results.filter(result => result.status === 'rejected').map(result => result.reason.message)
1783
- if (errors.length > 0) {
1784
- let errMsg
1785
- let statusCode
1786
- try {
1787
- const res = JSON.parse(errors[0])
1788
- statusCode = res?.statusCode
1789
- errMsg = res?.body?.error?.message
1790
- } catch (err) { errMsg = errors.join(', ') }
1791
- if (statusCode !== 404) throw new Error(errMsg)
1792
- }
1793
- results.forEach((result, idx) => {
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
1837
+ const fnCunckExecute = async (fnArr: { fn: () => Promise<any>, index?: number, objArr?: Record<string, any>[] | Record<string, any>, key?: string }[], chunkSize?: number) => {
1838
+ if (!Array.isArray(fnArr)) throw new Error(`fnCunckExecute error: fnArr is not array`)
1839
+ if (fnArr.length === 0) return
1840
+ if (typeof fnArr[0] !== 'object' || !fnArr[0].fn) throw new Error(`fnCunckExecute error: fnArr missing fn object(s)`)
1841
+ else if (fnArr[0].index !== undefined && !(fnArr[0].objArr || fnArr[0].key)) throw new Error(`fnCunckExecute error: missing reponseValue/key`)
1842
+ if (!chunkSize || chunkSize < 1) chunkSize = 5
1843
+ do {
1844
+ const arrChunk = fnArr.splice(0, chunkSize)
1845
+ const results = await Promise.allSettled(arrChunk.map(o => o.fn())) as { status: 'fulfilled' | 'rejected', reason: any, value: any }[] // processing max chunk async
1846
+ const errors = results.filter(result => result.status === 'rejected').map(result => result.reason.message)
1847
+ if (errors.length > 0) {
1848
+ let errMsg
1849
+ let statusCode
1850
+ try {
1851
+ const res = JSON.parse(errors[0])
1852
+ statusCode = res?.statusCode
1853
+ errMsg = res?.body?.error?.message
1854
+ } catch (err) { errMsg = errors.join(', ') }
1855
+ if (statusCode !== 404) throw new Error(errMsg)
1856
+ }
1857
+ results.forEach((result, idx) => {
1858
+ if (result.status === 'fulfilled') {
1859
+ const objArr: any = arrChunk[idx].objArr
1860
+ const key = arrChunk[idx].key
1861
+ const index = arrChunk[idx].index
1862
+ if (result.value === undefined) return
1863
+ const res: any = result.value.body || result.value
1864
+ const val = (res && res.value !== undefined) ? res.value : res
1865
+ if (typeof index === 'number' && objArr && key) {
1866
+ const keyArr = key.split('.')
1867
+ if (keyArr.length > 2) throw new Error(`fnCunckExecute error: key ${key} can not be more than 2 levels deep`)
1868
+ if (keyArr.length === 2 && !objArr[index][keyArr[0]]) objArr[index][keyArr[0]] = {}
1869
+ if (keyArr.length === 1) objArr[index][keyArr[0]] = val
1870
+ else objArr[index][keyArr[0]][keyArr[1]] = val
1871
+ } else if (index === undefined && objArr) {
1872
+ if (key === undefined) { // When index and key are undefined, append to objArr if objArr provided
1873
+ if (Array.isArray(objArr)) {
1874
+ if (Array.isArray(res.value)) objArr.push(...res.value) // If res.value is an array, spread its elements into objArr
1875
+ else objArr.push(res) // Otherwise, push the entire res object
1876
+ }
1877
+ } else {
1878
+ const keyArr = key.split('.')
1879
+ if (keyArr.length > 2) throw new Error(`fnCunckExecute error: key ${key} can not be more than 2 levels deep`)
1880
+ if (keyArr.length === 2 && !objArr[keyArr[0]]) objArr[keyArr[0]] = {}
1881
+ if (keyArr.length === 1) objArr[keyArr[0]] = val
1882
+ else objArr[keyArr[0]][keyArr[1]] = val
1803
1883
  }
1804
1884
  }
1805
- })
1806
- } while (fnArr.length > 0)
1807
- }
1885
+ }
1886
+ })
1887
+ } while (fnArr.length > 0)
1808
1888
  }
1809
1889
 
1810
1890
  //
@@ -945,7 +945,7 @@ export class ScimGateway {
945
945
  description: item.description ?? '',
946
946
  required: (item.mapTo === 'userName') ? true : false,
947
947
  caseExact: false,
948
- mutability: 'readWrite',
948
+ mutability: item.mutability ?? 'readWrite',
949
949
  returned: 'default',
950
950
  uniqueness: (item.mapTo === 'userName') ? 'server' : 'none',
951
951
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scimgateway",
3
- "version": "6.2.2",
3
+ "version": "6.2.3",
4
4
  "type": "module",
5
5
  "description": "Using SCIM protocol as a gateway for user provisioning to other endpoints",
6
6
  "author": "Jarle Elshaug <jarle.elshaug@gmail.com> (https://elshaug.xyz)",