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 +127 -0
- package/README.md +6 -152
- package/config/plugin-entra-id.json +25 -1
- package/lib/helper-rest.ts +13 -10
- package/lib/plugin-entra-id.ts +176 -96
- package/lib/scimgateway.ts +1 -1
- package/package.json +1 -1
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,
|
|
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
|
-
>
|
|
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
|
|
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
|
},
|
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
|
|
|
@@ -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 (
|
|
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
|
|
1789
|
+
// 3. Resolve principals (determine if user or group)
|
|
1728
1790
|
const userIds = new Set<string>()
|
|
1729
|
-
const
|
|
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
|
-
|
|
1795
|
+
fnArrPrincipalObjects.push({ fn: () => helper.doRequest(baseEntity, 'GET', path, null, ctx?.headers ? { headers: ctx?.headers } : undefined), objArr: principalObjects })
|
|
1733
1796
|
}
|
|
1734
|
-
|
|
1735
|
-
await fnCunckExecute(principalsToResolve, principalObjects)
|
|
1797
|
+
if (fnArrPrincipalObjects.length > 0) await fnCunckExecute(fnArrPrincipalObjects)
|
|
1736
1798
|
|
|
1737
|
-
// 4. Handle
|
|
1738
|
-
const
|
|
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
|
-
//
|
|
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 } }
|
|
1816
|
+
return { body: { value: members } }
|
|
1755
1817
|
}
|
|
1756
|
-
|
|
1818
|
+
fnArrGroupResults.push({ fn: () => fetchAllMembers(obj.id), objArr: groupResults })
|
|
1757
1819
|
}
|
|
1758
1820
|
}
|
|
1759
|
-
|
|
1760
|
-
if (
|
|
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}].
|
|
1769
|
-
* @param
|
|
1770
|
-
* @param
|
|
1771
|
-
* @
|
|
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: {
|
|
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
|
-
|
|
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
|
-
}
|
|
1807
|
-
}
|
|
1885
|
+
}
|
|
1886
|
+
})
|
|
1887
|
+
} while (fnArr.length > 0)
|
|
1808
1888
|
}
|
|
1809
1889
|
|
|
1810
1890
|
//
|
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
|
}
|
package/package.json
CHANGED