scimgateway 6.2.2 → 6.2.4
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 +130 -0
- package/README.md +6 -152
- package/config/plugin-entra-id.json +31 -1
- package/lib/helper-rest.ts +13 -10
- package/lib/plugin-entra-id.ts +243 -97
- package/lib/scimgateway.ts +2 -1
- package/package.json +1 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Change Log
|
|
2
|
+
|
|
3
|
+
### v6.2.4
|
|
4
|
+
- **[Improved]** `plugin-entra-id` now support reset users MFA capabilites and user will be forced to re-register MFA.
|
|
5
|
+
|
|
6
|
+
### v6.2.3
|
|
7
|
+
- **[Improved]** `plugin-entra-id` now includes information on whether a user has registered for MFA (has an MFA-capable method registered).
|
|
8
|
+
|
|
9
|
+
### v6.2.2
|
|
10
|
+
- **[Improved]** `plugin-entra-id` now supports Entra ID IGA Access Packages. For required API permissions, see Entra ID App Registration
|
|
11
|
+
|
|
12
|
+
### v6.2.1
|
|
13
|
+
- `HelperRest`: fixed minor log cosmetics introduced in v6.2.0
|
|
14
|
+
|
|
15
|
+
### v6.2.0
|
|
16
|
+
- **[Fixed]** `HelperRest`: failed on Bun v1.3.14 due to stricter Fetch standards compliance
|
|
17
|
+
- **[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.
|
|
18
|
+
- **[Improved]** `endpointMapper` now supports `valueMap`:
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
"map": {
|
|
22
|
+
"group": {
|
|
23
|
+
"displayName": {
|
|
24
|
+
"mapTo": "displayName",
|
|
25
|
+
"type": "string",
|
|
26
|
+
"valueMap": {
|
|
27
|
+
"outboundEndpointGrp1": "inboundScimGrp1",
|
|
28
|
+
"Employees": "Admins"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
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.
|
|
36
|
+
|
|
37
|
+
### v6.1.20
|
|
38
|
+
- `plugin-entra-id`: roles introduced in v6.1.19 were missing when retrieving a single user
|
|
39
|
+
|
|
40
|
+
### v6.1.19
|
|
41
|
+
- **[Fixed]** SCIM v2.0 ResourceType endpoint schemas using incorrect id
|
|
42
|
+
- **[Improved]** `GET /Roles` and `GET /Entitlements` endpoint support, with user management via SCIM `roles` and `entitlements` attributes
|
|
43
|
+
- **[Improved]** `plugin-entra-id`: `entitlements` for Entra ID licenses (read-only); `roles` for Permanent and Eligible PIM roles (full management)
|
|
44
|
+
- PIM Eligible roles: requires `RoleEligibilitySchedule.ReadWrite.All`
|
|
45
|
+
- PIM Permanent roles: requires `RoleManagement.ReadWrite.Directory`
|
|
46
|
+
- Remove `map.user.roles` if above conditions are not met
|
|
47
|
+
- `skipSignInActivity` option (v6.1.17) no longer used; `signInActivity` and PIM role permissions are validated at startup
|
|
48
|
+
|
|
49
|
+
### v6.1.18
|
|
50
|
+
- `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
|
|
51
|
+
|
|
52
|
+
### v6.1.17
|
|
53
|
+
- `plugin-entra-id`: fixed broken `filter=userName eq "user_upn"` introduced in v6.1.11 when using updated config with `map.user.signInActivity`
|
|
54
|
+
- `plugin-entra-id`: new option `endpoint.entity.[baseEntity].skipSignInActivity = true` to exclude `signInActivity` (requires Entra ID Premium + `AuditLog.Read.All`)
|
|
55
|
+
|
|
56
|
+
### v6.1.16
|
|
57
|
+
- `plugin-entra-id`: `GET /Entitlements` now uses `derivedIncludes` with full recursive expansion
|
|
58
|
+
|
|
59
|
+
### v6.1.15
|
|
60
|
+
- `plugin-entra-id`: fixed `filter=entitlements pr`
|
|
61
|
+
|
|
62
|
+
### v6.1.14
|
|
63
|
+
- Support for filter `attribute not pr`
|
|
64
|
+
- Dependencies bump
|
|
65
|
+
|
|
66
|
+
### v6.1.13
|
|
67
|
+
- `plugin-entra-id`: `signInActivity` attributes are now filterable
|
|
68
|
+
|
|
69
|
+
### v6.1.12
|
|
70
|
+
- Filter operator `pr` (presence) now forwarded to plugins (previously rejected)
|
|
71
|
+
- `plugin-entra-id`: handles `pr` filter on entitlements
|
|
72
|
+
|
|
73
|
+
### v6.1.11
|
|
74
|
+
- **[Fixed]** Incorrect schema generation when using `endpointMapper` (regression from v6.1.6)
|
|
75
|
+
- **[Improved]** New `GET /Entitlements` endpoint and `scimgateway.getEntitlements()` method
|
|
76
|
+
- `plugin-entra-id`: user license information via `entitlements`; remove `map.user.signInActivity` if Entra ID Premium is unavailable
|
|
77
|
+
|
|
78
|
+
### v6.1.10
|
|
79
|
+
- `plugin-entra-id`: group membership now includes nested (transitive) groups (`direct` and `indirect`)
|
|
80
|
+
- Fixed missing Docker files: `config/docker/.dockerignore` and `docker-compose-mssql.yml`
|
|
81
|
+
|
|
82
|
+
### v6.1.9
|
|
83
|
+
- `createUser`/`createGroup` responses now correctly include the generated ID
|
|
84
|
+
|
|
85
|
+
### v6.1.8 / v6.1.7
|
|
86
|
+
- Fixed incorrect masking of secrets in request info log messages
|
|
87
|
+
- `plugin-entra-id`: fixed edge case where `createUser` with a manager could fail
|
|
88
|
+
|
|
89
|
+
### v6.1.6
|
|
90
|
+
- Fixed `plugin-loki` and `plugin-mongodb` returning empty results when using extension schema attributes in search
|
|
91
|
+
- Auth failure due to `readOnly` now returns HTTP 405 instead of 401
|
|
92
|
+
- `postinstall` ensures `"type": "module"` is set in `package.json`
|
|
93
|
+
- `endpointMapper` now generates a custom schema; supports `"x-agent-schema"` for AI MCP tool instructions
|
|
94
|
+
|
|
95
|
+
### v6.1.5
|
|
96
|
+
- Complex filtering (`and`/`or`) handled by the gateway using the plugin's simple filter logic
|
|
97
|
+
- `modifyGroup` now returns HTTP 204 instead of 200
|
|
98
|
+
- New `/auth` endpoint for validating external authentication
|
|
99
|
+
- `plugin-entra-id`: supports `sw` (startsWith) filter
|
|
100
|
+
|
|
101
|
+
### v6.1.4
|
|
102
|
+
- Fixed OData paging in `plugin-entra-id` and `helper-rest` — missing users/groups/members in large directories
|
|
103
|
+
- Fixed incomplete group membership when paging not fully iterated
|
|
104
|
+
|
|
105
|
+
### v6.1.3
|
|
106
|
+
- Azure Relay: improved recovery on failure
|
|
107
|
+
- `plugin-ldap`: improvements for Active Directory and `objectGUID`/`mS-DS-ConsistencyGuid`
|
|
108
|
+
- `modifyGroup`: adding an existing member or removing a non-existent member now returns 200 OK instead of an error
|
|
109
|
+
|
|
110
|
+
### v6.1.2
|
|
111
|
+
- Fixed SMTP mail failure caused by an updated dependency
|
|
112
|
+
- Fixed `endpointMapper` when `mapTo` contained multiple comma-separated attributes including a multivalued one
|
|
113
|
+
|
|
114
|
+
### v6.1.1
|
|
115
|
+
- `plugin-ldap`: fixed race condition where `createUser` immediately followed by `readUser` could fail on some systems (e.g. Samba AD)
|
|
116
|
+
- Final info log message now includes full JSON serialization (durationMs, status, requestBody, responseBody, …)
|
|
117
|
+
|
|
118
|
+
### v6.1.0
|
|
119
|
+
- `tsx` included — SCIM Gateway now runs as ES module (TypeScript) in Node.js: `node --import=tsx ./index.ts`
|
|
120
|
+
- Simplified mandatory plugin initialization using static `import`
|
|
121
|
+
- `index.ts` updated to use static imports
|
|
122
|
+
- Bun binary builds now supported (see Single Binary Deployment)
|
|
123
|
+
|
|
124
|
+
### v6.0.0 — Major
|
|
125
|
+
- API method response bodies returned as-is (previously wrapped in `{ result: <content> }`) — **clients parsing responses must be updated**
|
|
126
|
+
- New `scimgateway.publicApi()` for unauthenticated `/pub/api` routes
|
|
127
|
+
- `bearerJwtAzure.tenantIdGUID` replaced by `bearerJwt.azureTenantId` — **existing configurations must be updated**
|
|
128
|
+
|
|
129
|
+
### v5.x — Previous Major Series
|
|
130
|
+
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
|
|
84
|
+
- **`plugin-entra-id`** now supports Entra ID roles, access packages and MFA, in addition to reading 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,
|
|
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.ReadWrite.All` *(MFA information and reset; 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
|
-
>
|
|
1066
|
+
> Note, if MFA, 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,36 @@
|
|
|
189
190
|
}
|
|
190
191
|
],
|
|
191
192
|
"x-agent-schema": {
|
|
192
|
-
"description": "Read-only attribute
|
|
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
|
+
"name": "reset",
|
|
216
|
+
"mutability": "writeOnly",
|
|
217
|
+
"description": "Set to 'true' will reset users MFA capabilites and user will be forced to re-register MFA.",
|
|
218
|
+
"type": "boolean"
|
|
219
|
+
}
|
|
220
|
+
],
|
|
221
|
+
"x-agent-schema": {
|
|
222
|
+
"description": "Read-only attribute object (except for 'mfa.reset') presenting 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
223
|
"readOnly": true
|
|
194
224
|
}
|
|
195
225
|
},
|
package/lib/helper-rest.ts
CHANGED
|
@@ -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)
|
|
709
|
-
|
|
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) {
|
|
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]
|
package/lib/plugin-entra-id.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Author: Jarle Elshaug
|
|
5
5
|
//
|
|
6
|
-
// Purpose: Entra ID provisioning including
|
|
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 (
|
|
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)
|
|
65
|
+
// SignInActivity signInActivity signInActivity (lastSignInDateTime, lastSuccessfulSignInDateTime and lastNonInteractiveSignInDateTime)
|
|
66
|
+
// MFA mfa mfa (mfa.isMfaCapable and mfa.isLegacyEnabled)
|
|
65
67
|
//
|
|
66
|
-
// /Group SCIM (custom) Endpoint (
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
437
|
+
const fnArr: { fn: () => Promise<any>, index?: number, objArr?: any, key?: string }[] = []
|
|
416
438
|
const byValues = await getEntitlementsByValues(baseEntity, ctx)
|
|
417
439
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
|
@@ -600,6 +637,18 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
|
|
|
600
637
|
return undefined
|
|
601
638
|
}
|
|
602
639
|
|
|
640
|
+
// MFA Reset
|
|
641
|
+
if (Object.hasOwn(parsedAttrObj, 'mfa')) {
|
|
642
|
+
if (parsedAttrObj.mfa.reset === true) {
|
|
643
|
+
if (!permission[baseEntity]?.mfa || !permission[baseEntity]?.eligible) throw new Error(`${action} error: MFA reset is not supported by the endpoint - missing permissions.`)
|
|
644
|
+
const currentRoles = await getUserRoles(baseEntity, id, [], true, ctx)
|
|
645
|
+
const isPrivileged = currentRoles.some(r => isPrivilegedRole(r.value))
|
|
646
|
+
if (isPrivileged) throw new Error(`${action} error: MFA reset is not allowed for users with high-privilege roles for security reasons.`)
|
|
647
|
+
await resetUserMfa(baseEntity, id, ctx)
|
|
648
|
+
}
|
|
649
|
+
delete parsedAttrObj.mfa
|
|
650
|
+
}
|
|
651
|
+
|
|
603
652
|
// Roles
|
|
604
653
|
if (Object.hasOwn(parsedAttrObj, 'roles') && Array.isArray(parsedAttrObj.roles)) {
|
|
605
654
|
const r: Record<string, any>[] = []
|
|
@@ -618,7 +667,7 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
|
|
|
618
667
|
const res: Record<string, any> = { value: el.value, type: el.type }
|
|
619
668
|
if (el.display) res.display = el.display
|
|
620
669
|
if (el.operation === 'delete') {
|
|
621
|
-
if (el.value
|
|
670
|
+
if (isPrivilegedRole(el.value)) throw new Error(`${action} error: Removal of high-privilege roles is not allowed for security reasons.`)
|
|
622
671
|
res.operation = el.operation
|
|
623
672
|
}
|
|
624
673
|
r.push(res)
|
|
@@ -774,7 +823,7 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
|
|
|
774
823
|
const profile = () => { // patch
|
|
775
824
|
return new Promise((resolve, reject) => {
|
|
776
825
|
(async () => {
|
|
777
|
-
if (
|
|
826
|
+
if (Object.keys(parsedAttrObj).length === 0) return resolve(null)
|
|
778
827
|
let res: any
|
|
779
828
|
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
829
|
if (typeof parsedAttrObj[key] === 'object') { // get original object and merge
|
|
@@ -1682,6 +1731,85 @@ const getUserAccessPackages = async (baseEntity: string, userId: string, include
|
|
|
1682
1731
|
return result
|
|
1683
1732
|
}
|
|
1684
1733
|
|
|
1734
|
+
const isPrivilegedRole = (roleId: string): boolean => {
|
|
1735
|
+
return [
|
|
1736
|
+
'62e90394-69f5-4237-9190-012177145e10', // Global Administrator
|
|
1737
|
+
'e8611ab8-c189-46e8-94e1-60213ab1f814', // Privileged Role Administrator
|
|
1738
|
+
'7be44c8a-adaf-4e2a-84d6-ab2649e08a13', // Privileged Authentication Administrator
|
|
1739
|
+
'c4e39bd9-1100-46d3-8c65-fb160da0071f', // Authentication Administrator
|
|
1740
|
+
].includes(roleId)
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
const isMethodMfaCapable = (odataType: string): boolean => {
|
|
1744
|
+
return [
|
|
1745
|
+
'#microsoft.graph.microsoftAuthenticatorAuthenticationMethod',
|
|
1746
|
+
'#microsoft.graph.passwordlessMicrosoftAuthenticatorAuthenticationMethod',
|
|
1747
|
+
'#microsoft.graph.phoneAuthenticationMethod',
|
|
1748
|
+
'#microsoft.graph.softwareOathAuthenticationMethod',
|
|
1749
|
+
'#microsoft.graph.fido2AuthenticationMethod',
|
|
1750
|
+
].includes(odataType)
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
const getAuthMethodCollectionFromODataType = (odataType: string): string => {
|
|
1754
|
+
const map: Record<string, string> = {
|
|
1755
|
+
'#microsoft.graph.microsoftAuthenticatorAuthenticationMethod': 'microsoftAuthenticatorMethods',
|
|
1756
|
+
'#microsoft.graph.passwordlessMicrosoftAuthenticatorAuthenticationMethod': 'microsoftAuthenticatorMethods',
|
|
1757
|
+
'#microsoft.graph.phoneAuthenticationMethod': 'phoneMethods',
|
|
1758
|
+
'#microsoft.graph.softwareOathAuthenticationMethod': 'softwareOathMethods',
|
|
1759
|
+
'#microsoft.graph.fido2AuthenticationMethod': 'fido2Methods',
|
|
1760
|
+
'#microsoft.graph.emailAuthenticationMethod': 'emailMethods',
|
|
1761
|
+
'#microsoft.graph.temporaryAccessPassAuthenticationMethod': 'temporaryAccessPassMethods',
|
|
1762
|
+
'#microsoft.graph.windowsHelloForBusinessAuthenticationMethod': 'windowsHelloForBusinessMethods',
|
|
1763
|
+
'#microsoft.graph.passwordAuthenticationMethod': 'passwordMethods',
|
|
1764
|
+
}
|
|
1765
|
+
const collection = map[odataType]
|
|
1766
|
+
return collection
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
const getUserisMfaCapable = async (baseEntity: string, userId: string, ctx?: undefined | Record<string, any>): Promise<boolean> => {
|
|
1770
|
+
const action = 'getUserisMfaCapable'
|
|
1771
|
+
const method = 'GET'
|
|
1772
|
+
const body = null
|
|
1773
|
+
const path = `/users/${userId}/authentication/methods`
|
|
1774
|
+
const r = await helper.doRequest(baseEntity, method, path, body, ctx?.headers ? { headers: ctx?.headers } : undefined)
|
|
1775
|
+
if (!r.body?.value) {
|
|
1776
|
+
if (r.body?.id) r.body.value = [r.body]
|
|
1777
|
+
else throw new Error(`${action} error: invalid response: ${JSON.stringify(r)}`)
|
|
1778
|
+
}
|
|
1779
|
+
const methodTypes = r.body.value.map((m: any) => m['@odata.type'])
|
|
1780
|
+
if (methodTypes.length < 1) return false
|
|
1781
|
+
return methodTypes.some(isMethodMfaCapable)
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
const resetUserMfa = async (baseEntity: string, userId: string, ctx?: undefined | Record<string, any>): Promise<boolean> => {
|
|
1785
|
+
const action = 'resetUserMfa'
|
|
1786
|
+
const method = 'GET'
|
|
1787
|
+
const body = null
|
|
1788
|
+
const path = `/users/${userId}/authentication/methods`
|
|
1789
|
+
const r = await helper.doRequest(baseEntity, method, path, body, ctx?.headers ? { headers: ctx?.headers } : undefined)
|
|
1790
|
+
if (!r.body?.value) {
|
|
1791
|
+
if (r.body?.id) r.body.value = [r.body]
|
|
1792
|
+
else throw new Error(`${action} error: invalid response: ${JSON.stringify(r)}`)
|
|
1793
|
+
}
|
|
1794
|
+
const methods = r.body.value.map((m: any) => { return { 'id': m.id, '@odata.type': m['@odata.type'] } })
|
|
1795
|
+
if (methods.length < 1) return false
|
|
1796
|
+
const fnArr: { fn: () => Promise<any>, index?: number, objArr?: any, key?: string }[] = []
|
|
1797
|
+
for (const m of methods) {
|
|
1798
|
+
if (!isMethodMfaCapable(m['@odata.type'])) continue
|
|
1799
|
+
const methodName = getAuthMethodCollectionFromODataType(m['@odata.type'])
|
|
1800
|
+
if (!methodName) continue
|
|
1801
|
+
const path = `/users/${userId}/authentication/${methodName}/${m.id}`
|
|
1802
|
+
const fn = () => helper.doRequest(baseEntity, 'DELETE', path, null, ctx?.headers ? { headers: ctx?.headers } : undefined)
|
|
1803
|
+
fnArr.push({ fn })
|
|
1804
|
+
}
|
|
1805
|
+
if (fnArr.length === 0) return false
|
|
1806
|
+
// include revoke sessions
|
|
1807
|
+
const fn = () => helper.doRequest(baseEntity, 'POST', `/users/${userId}/invalidateAllRefreshTokens`, null, ctx?.headers ? { headers: ctx?.headers } : undefined)
|
|
1808
|
+
fnArr.push({ fn })
|
|
1809
|
+
await fnCunckExecute(fnArr)
|
|
1810
|
+
return true
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1685
1813
|
/**
|
|
1686
1814
|
* getUsersByRole returns an array of user IDs having a specific role assigned
|
|
1687
1815
|
* @param baseEntity
|
|
@@ -1724,23 +1852,24 @@ const getUsersByRole = async (baseEntity: string, getObj: Record<string, any>, t
|
|
|
1724
1852
|
|
|
1725
1853
|
if (activePrincipals.size === 0) return []
|
|
1726
1854
|
|
|
1727
|
-
// 3. Resolve
|
|
1855
|
+
// 3. Resolve principals (determine if user or group)
|
|
1728
1856
|
const userIds = new Set<string>()
|
|
1729
|
-
const
|
|
1857
|
+
const fnArrPrincipalObjects: { fn: () => Promise<any>, index?: number, objArr?: any, key?: string }[] = []
|
|
1858
|
+
const principalObjects: any[] = []
|
|
1730
1859
|
for (const pId of activePrincipals) {
|
|
1731
1860
|
const path = `/directoryObjects/${pId}`
|
|
1732
|
-
|
|
1861
|
+
fnArrPrincipalObjects.push({ fn: () => helper.doRequest(baseEntity, 'GET', path, null, ctx?.headers ? { headers: ctx?.headers } : undefined), objArr: principalObjects })
|
|
1733
1862
|
}
|
|
1734
|
-
|
|
1735
|
-
await fnCunckExecute(principalsToResolve, principalObjects)
|
|
1863
|
+
if (fnArrPrincipalObjects.length > 0) await fnCunckExecute(fnArrPrincipalObjects)
|
|
1736
1864
|
|
|
1737
|
-
// 4. Handle
|
|
1738
|
-
const
|
|
1865
|
+
// 4. Handle users directly and fetch transitive members for groups
|
|
1866
|
+
const fnArrGroupResults: { fn: () => Promise<any>, index?: number, objArr?: any, key?: string }[] = []
|
|
1867
|
+
const groupResults: any[] = []
|
|
1739
1868
|
for (const obj of principalObjects) {
|
|
1740
1869
|
if (!obj || !obj.id) continue
|
|
1741
1870
|
if (obj['@odata.type'] === '#microsoft.graph.user') userIds.add(obj.id)
|
|
1742
1871
|
else if (obj['@odata.type'] === '#microsoft.graph.group') {
|
|
1743
|
-
//
|
|
1872
|
+
// fetch all transitive members including paging
|
|
1744
1873
|
const fetchAllMembers = async (groupId: string) => {
|
|
1745
1874
|
let members: any[] = []
|
|
1746
1875
|
let nextPath: string | null = `/groups/${groupId}/transitiveMembers/microsoft.graph.user?$select=id`
|
|
@@ -1748,63 +1877,80 @@ const getUsersByRole = async (baseEntity: string, getObj: Record<string, any>, t
|
|
|
1748
1877
|
const res = await helper.doRequest(baseEntity, 'GET', nextPath, null, ctx?.headers ? { headers: ctx?.headers } : undefined)
|
|
1749
1878
|
if (!res.body?.value) break
|
|
1750
1879
|
members.push(...res.body.value)
|
|
1751
|
-
// extract nextLink and convert to relative path
|
|
1752
1880
|
nextPath = res.body['@odata.nextLink'] ? res.body['@odata.nextLink'].split('/beta')[1] : null
|
|
1753
1881
|
}
|
|
1754
|
-
return { body: { value: members } }
|
|
1882
|
+
return { body: { value: members } }
|
|
1755
1883
|
}
|
|
1756
|
-
|
|
1884
|
+
fnArrGroupResults.push({ fn: () => fetchAllMembers(obj.id), objArr: groupResults })
|
|
1757
1885
|
}
|
|
1758
1886
|
}
|
|
1759
|
-
|
|
1760
|
-
if (
|
|
1887
|
+
|
|
1888
|
+
if (fnArrGroupResults.length > 0) await fnCunckExecute(fnArrGroupResults)
|
|
1761
1889
|
groupResults.forEach((m: any) => m.id && userIds.add(m.id.toLowerCase()))
|
|
1762
1890
|
|
|
1763
1891
|
return Array.from(userIds)
|
|
1764
1892
|
}
|
|
1765
1893
|
|
|
1766
1894
|
/**
|
|
1767
|
-
* fnCunckExecute runs functions asynchronous in chunks
|
|
1768
|
-
* @param fnArr array of objects that must include function and optionally index [{fn, index}].
|
|
1769
|
-
* @param
|
|
1770
|
-
* @param
|
|
1771
|
-
* @
|
|
1895
|
+
* fnCunckExecute runs array of functions asynchronous in chunks
|
|
1896
|
+
* @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
|
|
1897
|
+
* @param fnArr.index optional and represent the index of `objArr` that should be updated with `key` set to the value of the function result
|
|
1898
|
+
* @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].
|
|
1899
|
+
* @param fnArr.key optionally key
|
|
1900
|
+
* @param chunkSize optinally size of chunks used, default 5
|
|
1901
|
+
* @returns undefined, but updated objArr if objArr is included
|
|
1772
1902
|
**/
|
|
1773
|
-
const fnCunckExecute = async (fnArr: {
|
|
1774
|
-
if (!Array.isArray(fnArr)) throw new Error(`fnCunckExecute
|
|
1775
|
-
if (fnArr.length
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1903
|
+
const fnCunckExecute = async (fnArr: { fn: () => Promise<any>, index?: number, objArr?: Record<string, any>[] | Record<string, any>, key?: string }[], chunkSize?: number) => {
|
|
1904
|
+
if (!Array.isArray(fnArr)) throw new Error(`fnCunckExecute error: fnArr is not array`)
|
|
1905
|
+
if (fnArr.length === 0) return
|
|
1906
|
+
if (typeof fnArr[0] !== 'object' || !fnArr[0].fn) throw new Error(`fnCunckExecute error: fnArr missing fn object(s)`)
|
|
1907
|
+
else if (fnArr[0].index !== undefined && !(fnArr[0].objArr || fnArr[0].key)) throw new Error(`fnCunckExecute error: missing reponseValue/key`)
|
|
1908
|
+
if (!chunkSize || chunkSize < 1) chunkSize = 5
|
|
1909
|
+
do {
|
|
1910
|
+
const arrChunk = fnArr.splice(0, chunkSize)
|
|
1911
|
+
const results = await Promise.allSettled(arrChunk.map(o => o.fn())) as { status: 'fulfilled' | 'rejected', reason: any, value: any }[] // processing max chunk async
|
|
1912
|
+
const errors = results.filter(result => result.status === 'rejected').map(result => result.reason.message)
|
|
1913
|
+
if (errors.length > 0) {
|
|
1914
|
+
let errMsg
|
|
1915
|
+
let statusCode
|
|
1916
|
+
try {
|
|
1917
|
+
const res = JSON.parse(errors[0])
|
|
1918
|
+
statusCode = res?.statusCode
|
|
1919
|
+
errMsg = res?.body?.error?.message
|
|
1920
|
+
} catch (err) { errMsg = errors.join(', ') }
|
|
1921
|
+
if (statusCode !== 404) throw new Error(errMsg)
|
|
1922
|
+
}
|
|
1923
|
+
results.forEach((result, idx) => {
|
|
1924
|
+
if (result.status === 'fulfilled') {
|
|
1925
|
+
const objArr: any = arrChunk[idx].objArr
|
|
1926
|
+
const key = arrChunk[idx].key
|
|
1927
|
+
const index = arrChunk[idx].index
|
|
1928
|
+
if (result.value === undefined) return
|
|
1929
|
+
const res: any = result.value.body || result.value
|
|
1930
|
+
const val = (res && res.value !== undefined) ? res.value : res
|
|
1931
|
+
if (typeof index === 'number' && objArr && key) {
|
|
1932
|
+
const keyArr = key.split('.')
|
|
1933
|
+
if (keyArr.length > 2) throw new Error(`fnCunckExecute error: key ${key} can not be more than 2 levels deep`)
|
|
1934
|
+
if (keyArr.length === 2 && !objArr[index][keyArr[0]]) objArr[index][keyArr[0]] = {}
|
|
1935
|
+
if (keyArr.length === 1) objArr[index][keyArr[0]] = val
|
|
1936
|
+
else objArr[index][keyArr[0]][keyArr[1]] = val
|
|
1937
|
+
} else if (index === undefined && objArr) {
|
|
1938
|
+
if (key === undefined) { // When index and key are undefined, append to objArr if objArr provided
|
|
1939
|
+
if (Array.isArray(objArr)) {
|
|
1940
|
+
if (Array.isArray(res.value)) objArr.push(...res.value) // If res.value is an array, spread its elements into objArr
|
|
1941
|
+
else objArr.push(res) // Otherwise, push the entire res object
|
|
1942
|
+
}
|
|
1943
|
+
} else {
|
|
1944
|
+
const keyArr = key.split('.')
|
|
1945
|
+
if (keyArr.length > 2) throw new Error(`fnCunckExecute error: key ${key} can not be more than 2 levels deep`)
|
|
1946
|
+
if (keyArr.length === 2 && !objArr[keyArr[0]]) objArr[keyArr[0]] = {}
|
|
1947
|
+
if (keyArr.length === 1) objArr[keyArr[0]] = val
|
|
1948
|
+
else objArr[keyArr[0]][keyArr[1]] = val
|
|
1803
1949
|
}
|
|
1804
1950
|
}
|
|
1805
|
-
}
|
|
1806
|
-
}
|
|
1807
|
-
}
|
|
1951
|
+
}
|
|
1952
|
+
})
|
|
1953
|
+
} while (fnArr.length > 0)
|
|
1808
1954
|
}
|
|
1809
1955
|
|
|
1810
1956
|
//
|
package/lib/scimgateway.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -2261,6 +2261,7 @@ export class ScimGateway {
|
|
|
2261
2261
|
if (res?.Resources && Array.isArray(res.Resources) && res.Resources.length === 1) {
|
|
2262
2262
|
// we might have endpoint like Entra ID that hasn’t caught up yet due to internal sync
|
|
2263
2263
|
// ensure returned object reflects changes by doing a merge with patch payload (convertedScim)
|
|
2264
|
+
if (finalScimdata?.mfa) delete finalScimdata.mfa // writeOnly mfa.reset
|
|
2264
2265
|
res.Resources[0] = this.patchObj(res.Resources[0], finalScimdata) // merge
|
|
2265
2266
|
if (res.Resources[0].password) delete res.Resources[0].password
|
|
2266
2267
|
}
|
package/package.json
CHANGED