scimgateway 6.1.20 → 6.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -1
- package/config/{plugin-scim.json → plugin-generic.json} +60 -0
- package/index.ts +1 -1
- package/lib/helper-rest.ts +19 -37
- package/lib/plugin-entra-id.ts +2 -1
- package/lib/plugin-generic.ts +554 -0
- package/lib/postinstall.ts +2 -2
- package/lib/scimgateway.ts +10 -1
- package/lib/utils-scim.ts +16 -1
- package/package.json +1 -1
- package/test/index.ts +1 -1
- package/test/lib/{plugin-scim_test.ts → plugin-generic_test.ts} +7 -5
- package/lib/plugin-scim.ts +0 -493
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
Latest news:
|
|
19
19
|
|
|
20
|
+
- New `plugin-generic` replacing previous `plugin-scim`. This new plugin use the endpointMapper for flexible attribute mapping and also supports the new mapper option `valueMap` (e.g., group filtering and mapping).
|
|
20
21
|
- Now supports `GET /Roles` and `GET /Entitlements` endpoint requests, with corresponding user management via the standard SCIM `roles` and `entitlements` attributes. The Entra ID plugin uses `entitlements` for Entra ID licenses (read-only) and `roles` for Entra ID Permanent and Eligible roles (full management).
|
|
21
22
|
- Bun binary build is now supported, allowing SCIM Gateway to be compiled into a single executable binary for simplified deployment and execution. SCIM Gateway can now run as an ES module (TypeScript) in Node.js.
|
|
22
23
|
- Major release **v6.0.0** introduces changes to API method responses (not SCIM-related) and a new method `publicApi()` for handling public path `/pub/api` requests with no authentication required. In addition, the configuration option `bearerJwtAzure.tenantIdGUID` has been replaced by `bearerJwt.azureTenantId`. See the version history for details.
|
|
@@ -61,7 +62,7 @@ The following fully functional plugins are included for demonstration and produc
|
|
|
61
62
|
| **Loki** | NoSQL Database | Transforms the SCIM Gateway into a standalone SCIM endpoint utilizing the internal [LokiJS](https://github.com/techfort/LokiJS) database. Includes two test users and groups |
|
|
62
63
|
| **MongoDB** | NoSQL Database | Similar to the Loki plugin, but using an externally managed MongoDB database, showcasing multi-tenant and multi-endpoint capabilities via `baseEntity` |
|
|
63
64
|
| **Entra ID** | REST Webservices | Entra ID user provisioning via Microsoft Graph API |
|
|
64
|
-
| **
|
|
65
|
+
| **Generic** | REST Webservice | Generic template plugin configured to use plugin-loki as a SCIM provisioning endpoint. Supports the endpointMapper `valueMap` option for allowlisting and mapping (e.g., groups). Can also be used as a SCIM version gateway (e.g., 1.1 => 2.0) |
|
|
65
66
|
| **API** | REST Webservices | A non-SCIM plugin demonstrating API Gateway functionality for custom REST specifications |
|
|
66
67
|
| **Soap** | SOAP Webservice | Demonstrates user provisioning to a SOAP-based endpoint with example WSDLs |
|
|
67
68
|
| **MSSQL** | Database | Demonstrates user provisioning to an MSSQL database |
|
|
@@ -1304,12 +1305,58 @@ MIT © [Jarle Elshaug](https://www.elshaug.xyz)
|
|
|
1304
1305
|
|
|
1305
1306
|
## Change log
|
|
1306
1307
|
|
|
1308
|
+
### v6.2.1
|
|
1309
|
+
|
|
1310
|
+
[Fixed]
|
|
1311
|
+
|
|
1312
|
+
- `HelperRest`: fixed some minor log cosmetics introduced in v6.2.0
|
|
1313
|
+
|
|
1314
|
+
### v6.2.0
|
|
1315
|
+
|
|
1316
|
+
[Fixed]
|
|
1317
|
+
|
|
1318
|
+
- `HelperRest`: failed on Bun v1.3.14 due to stricter compliance with Fetch standards.
|
|
1319
|
+
|
|
1320
|
+
[Improved]
|
|
1321
|
+
|
|
1322
|
+
- New `plugin-generic` replacing previous `plugin-scim`. This new plugin use the endpointMapper for flexible attribute mapping and also supports the new mapper option `valueMap` (e.g., group filtering and mapping). The default configuration uses one-to-one SCIM mapping, with plugin-loki as the target SCIM endpoint.
|
|
1323
|
+
- endpointMapper now supports the `valueMap` option
|
|
1324
|
+
|
|
1325
|
+
Example configuration:
|
|
1326
|
+
|
|
1327
|
+
"map": {
|
|
1328
|
+
"group": {
|
|
1329
|
+
...
|
|
1330
|
+
"displayName": {
|
|
1331
|
+
"mapTo": "displayName",
|
|
1332
|
+
"type": "string",
|
|
1333
|
+
"valueMap": {
|
|
1334
|
+
"outboundEndpointGrp1": "inboundScimGrp1",
|
|
1335
|
+
"Employees": "Admins"
|
|
1336
|
+
}
|
|
1337
|
+
},
|
|
1338
|
+
...
|
|
1339
|
+
}
|
|
1340
|
+
...
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
Using the above settings restricts the client using SCIM Gateway with regard to group management.
|
|
1344
|
+
The client will only see and be able to manage groups with SCIM names "inboundScimGrp1" and "Admins",
|
|
1345
|
+
if their mapped counterparts exist at the target endpoint as "outboundEndpointGrp1" and "Employees".
|
|
1346
|
+
|
|
1347
|
+
Use case:
|
|
1348
|
+
|
|
1349
|
+
- Allowlisting specific groups or user objects that includes attribute mapping having the valueMap option configured.
|
|
1350
|
+
- Supporting different inbound/outbound names (e.g., Entra ID group provisioning to SCIM Gateway).
|
|
1351
|
+
|
|
1352
|
+
|
|
1307
1353
|
### v6.1.20
|
|
1308
1354
|
|
|
1309
1355
|
[Fixed]
|
|
1310
1356
|
|
|
1311
1357
|
- plugin-entra-id: Roles introduced in v6.1.19 were missing when retrieving a single user.
|
|
1312
1358
|
|
|
1359
|
+
|
|
1313
1360
|
### v6.1.19
|
|
1314
1361
|
|
|
1315
1362
|
[Fixed]
|
|
@@ -156,6 +156,66 @@
|
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
|
+
},
|
|
160
|
+
"map": {
|
|
161
|
+
"group": {
|
|
162
|
+
"id": {
|
|
163
|
+
"mapTo": "id",
|
|
164
|
+
"type": "string"
|
|
165
|
+
},
|
|
166
|
+
"displayName": {
|
|
167
|
+
"mapTo": "displayName",
|
|
168
|
+
"type": "string",
|
|
169
|
+
"valueMap": {}
|
|
170
|
+
},
|
|
171
|
+
"members": {
|
|
172
|
+
"mapTo": "members",
|
|
173
|
+
"type": "complexArray"
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
"user": {
|
|
177
|
+
"id": {
|
|
178
|
+
"mapTo": "id",
|
|
179
|
+
"type": "string"
|
|
180
|
+
},
|
|
181
|
+
"userName": {
|
|
182
|
+
"mapTo": "userName",
|
|
183
|
+
"type": "string"
|
|
184
|
+
},
|
|
185
|
+
"active": {
|
|
186
|
+
"mapTo": "active",
|
|
187
|
+
"type": "boolean"
|
|
188
|
+
},
|
|
189
|
+
"password": {
|
|
190
|
+
"mapTo": "password",
|
|
191
|
+
"type": "string"
|
|
192
|
+
},
|
|
193
|
+
"title": {
|
|
194
|
+
"mapTo": "title",
|
|
195
|
+
"type": "string"
|
|
196
|
+
},
|
|
197
|
+
"name": {
|
|
198
|
+
"mapTo": "name",
|
|
199
|
+
"type": "complexObject",
|
|
200
|
+
"subAttributes": []
|
|
201
|
+
},
|
|
202
|
+
"emails": {
|
|
203
|
+
"mapTo": "emails",
|
|
204
|
+
"type": "complexArray"
|
|
205
|
+
},
|
|
206
|
+
"phoneNumbers": {
|
|
207
|
+
"mapTo": "phoneNumbers",
|
|
208
|
+
"type": "complexArray"
|
|
209
|
+
},
|
|
210
|
+
"roles": {
|
|
211
|
+
"mapTo": "roles",
|
|
212
|
+
"type": "complexArray"
|
|
213
|
+
},
|
|
214
|
+
"entitlements": {
|
|
215
|
+
"mapTo": "entitlements",
|
|
216
|
+
"type": "complexArray"
|
|
217
|
+
}
|
|
218
|
+
}
|
|
159
219
|
}
|
|
160
220
|
}
|
|
161
221
|
}
|
package/index.ts
CHANGED
package/lib/helper-rest.ts
CHANGED
|
@@ -435,10 +435,9 @@ export class HelperRest {
|
|
|
435
435
|
private async getServiceClient(baseEntity: string, connectionObj: Record<string, any>, method: string, path: string, opt?: any, ctx?: any) {
|
|
436
436
|
const action = 'getServiceClient'
|
|
437
437
|
if (typeof connectionObj !== 'object' || connectionObj === null) connectionObj = {}
|
|
438
|
-
let urlObj: any
|
|
439
438
|
if (!path) path = ''
|
|
440
439
|
try {
|
|
441
|
-
|
|
440
|
+
new URL(path)
|
|
442
441
|
} catch (err) {
|
|
443
442
|
//
|
|
444
443
|
// path (no url) - default approach and client will be cached based on config
|
|
@@ -473,18 +472,12 @@ export class HelperRest {
|
|
|
473
472
|
const err = new Error(`missing connection configuration: baseUrls`)
|
|
474
473
|
throw err
|
|
475
474
|
}
|
|
476
|
-
urlObj = new URL(connectionObj.baseUrls[0])
|
|
477
475
|
const param: any = {
|
|
478
476
|
baseUrl: connectionObj.baseUrls[0],
|
|
479
477
|
options: {
|
|
480
|
-
json: true, // json-object response instead of string
|
|
481
478
|
headers: {
|
|
482
479
|
Accept: 'application/json',
|
|
483
480
|
},
|
|
484
|
-
host: urlObj.hostname,
|
|
485
|
-
port: urlObj.port, // null if https and 443 defined in url
|
|
486
|
-
protocol: urlObj.protocol, // http: or https:
|
|
487
|
-
// 'method' and 'path' added at the end
|
|
488
481
|
},
|
|
489
482
|
}
|
|
490
483
|
|
|
@@ -567,16 +560,9 @@ export class HelperRest {
|
|
|
567
560
|
}
|
|
568
561
|
const cli: any = structuredClone(this._serviceClient[baseEntity]) // client ready
|
|
569
562
|
|
|
570
|
-
// failover support
|
|
571
|
-
path = this._serviceClient[baseEntity].baseUrl + path
|
|
572
|
-
urlObj = new URL(path)
|
|
573
|
-
cli.options.host = urlObj.hostname
|
|
574
|
-
cli.options.port = urlObj.port
|
|
575
|
-
cli.options.protocol = urlObj.protocol
|
|
576
|
-
|
|
577
|
-
// adding none static
|
|
578
563
|
cli.options.method = method
|
|
579
|
-
cli.options.
|
|
564
|
+
cli.options.url = this._serviceClient[baseEntity].baseUrl + path // failover supported
|
|
565
|
+
|
|
580
566
|
if (opt) {
|
|
581
567
|
if (opt?.connection) delete opt.connection // only used for internal connection options
|
|
582
568
|
cli.options = utils.extendObj(cli.options, opt) // merge with argument options
|
|
@@ -589,15 +575,11 @@ export class HelperRest {
|
|
|
589
575
|
//
|
|
590
576
|
this.scimgateway.logDebug(baseEntity, `${action}: Using raw client`)
|
|
591
577
|
let options: any = {
|
|
592
|
-
json: true,
|
|
593
578
|
headers: {
|
|
594
579
|
Accept: 'application/json',
|
|
595
580
|
},
|
|
596
|
-
host: urlObj.hostname,
|
|
597
|
-
port: urlObj.port,
|
|
598
|
-
protocol: urlObj.protocol,
|
|
599
581
|
method: method,
|
|
600
|
-
|
|
582
|
+
url: path,
|
|
601
583
|
}
|
|
602
584
|
|
|
603
585
|
// proxy
|
|
@@ -646,12 +628,13 @@ export class HelperRest {
|
|
|
646
628
|
**/
|
|
647
629
|
private async doRequestHandler(baseEntity: string, method: string, path: string, body?: any, ctx?: any, opt?: any, retryCount?: number): Promise<any> {
|
|
648
630
|
const connectionObj = this.config_entity[baseEntity]?.connection ?? {}
|
|
631
|
+
let options: Record<any, any> = {}
|
|
649
632
|
let retryAfter = 0
|
|
650
633
|
try {
|
|
651
634
|
const controller = new AbortController()
|
|
652
635
|
const signal = controller.signal
|
|
653
636
|
const cli = await this.getServiceClient(baseEntity, connectionObj, method, path, opt, ctx)
|
|
654
|
-
|
|
637
|
+
options = cli.options
|
|
655
638
|
const timeout = setTimeout(() => controller.abort(), options.abortTimeout ? options.abortTimeout * 1000 : this.idleTimeout * 1000) // 120 seconds default abort timeout
|
|
656
639
|
options.signal = signal
|
|
657
640
|
|
|
@@ -676,34 +659,33 @@ export class HelperRest {
|
|
|
676
659
|
options.body = dataString
|
|
677
660
|
} else if (options.headers) delete options.headers['Content-Type']
|
|
678
661
|
|
|
679
|
-
|
|
680
|
-
if (this._serviceClient[baseEntity]?.nextLink[url]) {
|
|
662
|
+
if (this._serviceClient[baseEntity]?.nextLink[options.url]) {
|
|
681
663
|
if (ctx?.paging?.startIndex && ctx.paging.startIndex > 1) {
|
|
682
|
-
if (ctx.paging.startIndex === this._serviceClient[baseEntity]?.nextLink[url].startIndex) {
|
|
683
|
-
url = this._serviceClient[baseEntity]?.nextLink[url]['@odata.nextLink']
|
|
664
|
+
if (ctx.paging.startIndex === this._serviceClient[baseEntity]?.nextLink[options.url].startIndex) {
|
|
665
|
+
options.url = this._serviceClient[baseEntity]?.nextLink[options.url]['@odata.nextLink']
|
|
684
666
|
} else {
|
|
685
667
|
if (!ctx) ctx = {}
|
|
686
668
|
if (!ctx.paging) ctx.paging = {}
|
|
687
|
-
if (this._serviceClient[baseEntity]?.nextLink[url].totalResults
|
|
688
|
-
&& ctx.paging.startIndex > this._serviceClient[baseEntity]?.nextLink[url].totalResults) {
|
|
689
|
-
ctx.paging.totalResults = this._serviceClient[baseEntity]?.nextLink[url].totalResults
|
|
669
|
+
if (this._serviceClient[baseEntity]?.nextLink[options.url].totalResults
|
|
670
|
+
&& ctx.paging.startIndex > this._serviceClient[baseEntity]?.nextLink[options.url].totalResults) {
|
|
671
|
+
ctx.paging.totalResults = this._serviceClient[baseEntity]?.nextLink[options.url].totalResults
|
|
690
672
|
return { body: { value: [] } }
|
|
691
673
|
} else {
|
|
692
674
|
// reset the paging cursor - none expected startIndex sequence, using default none paged url
|
|
693
675
|
ctx.paging.startIndex = 1 // caller should check and return this new startIndex in final response
|
|
694
|
-
delete this._serviceClient[baseEntity].nextLink[url]
|
|
676
|
+
delete this._serviceClient[baseEntity].nextLink[options.url]
|
|
695
677
|
}
|
|
696
678
|
}
|
|
697
679
|
}
|
|
698
680
|
} else {
|
|
699
|
-
if (ctx?.paging?.startIndex > 1 && !this._serviceClient[baseEntity]?.nextLink[url]) { // no previous paging and invalid startIndex
|
|
681
|
+
if (ctx?.paging?.startIndex > 1 && !this._serviceClient[baseEntity]?.nextLink[options.url]) { // no previous paging and invalid startIndex
|
|
700
682
|
ctx.paging.totalResults = ctx.paging.startIndex - 1
|
|
701
683
|
return { body: { value: [] } }
|
|
702
684
|
}
|
|
703
685
|
}
|
|
704
686
|
|
|
705
687
|
// execute request
|
|
706
|
-
const f = await fetch(url, options)
|
|
688
|
+
const f = await fetch(options.url, options)
|
|
707
689
|
if (!f.status) throw new Error('Response missing status code')
|
|
708
690
|
|
|
709
691
|
const result: any = {
|
|
@@ -728,7 +710,7 @@ export class HelperRest {
|
|
|
728
710
|
}
|
|
729
711
|
throw new Error(JSON.stringify(result))
|
|
730
712
|
}
|
|
731
|
-
this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${options.
|
|
713
|
+
this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${options.url} Body = ${JSON.stringify(body)} Response = ${JSON.stringify(result)}`)
|
|
732
714
|
|
|
733
715
|
// OData paging logic
|
|
734
716
|
// client prerequisite for enabling doRequest() OData paging support (see plugin-entra-id):
|
|
@@ -761,7 +743,7 @@ export class HelperRest {
|
|
|
761
743
|
ctx.paging.totalResults = totalResults
|
|
762
744
|
}
|
|
763
745
|
} else { // no more paging
|
|
764
|
-
const linkBase = decodeURIComponent(url
|
|
746
|
+
const linkBase = decodeURIComponent(options.url?.substring(0, options.url?.indexOf('$skiptoken') - 1))
|
|
765
747
|
if (ctx?.paging?.startIndex && ctx.paging.startIndex > 1 && this._serviceClient[baseEntity]?.nextLink[linkBase]) {
|
|
766
748
|
if (!this._serviceClient[baseEntity]?.nextLink[linkBase].isCount) { // final no count page
|
|
767
749
|
const itemsPerPage = result.body.value.length
|
|
@@ -816,8 +798,8 @@ export class HelperRest {
|
|
|
816
798
|
}
|
|
817
799
|
} else {
|
|
818
800
|
if (statusCode === 404) { // not logged as error e.g. getUser-manager
|
|
819
|
-
this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${
|
|
820
|
-
} else this.scimgateway.logError(baseEntity, `doRequest ${method} ${
|
|
801
|
+
this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${options.url} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
|
|
802
|
+
} else this.scimgateway.logError(baseEntity, `doRequest ${method} ${options.url} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
|
|
821
803
|
if (statusCode === 401) delete this._serviceClient[baseEntity]
|
|
822
804
|
throw err
|
|
823
805
|
}
|
package/lib/plugin-entra-id.ts
CHANGED
|
@@ -294,6 +294,7 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
|
|
|
294
294
|
if (!path) throw new Error(`${action} error: mandatory if-else logic not fully implemented`)
|
|
295
295
|
|
|
296
296
|
if (path.includes('$count=true')) { // $count=true requires ConsistencyLevel
|
|
297
|
+
// note: when using $expand, the $count=true might be ignored by target endpoint and the ctx.paging.totalResults updated by doReqest() will be incremental
|
|
297
298
|
if (!options.headers) options.headers = {}
|
|
298
299
|
options.headers.ConsistencyLevel = 'eventual'
|
|
299
300
|
}
|
|
@@ -730,6 +731,7 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
|
|
|
730
731
|
if (!path) throw new Error(`${action} error: mandatory if-else logic not fully implemented`)
|
|
731
732
|
|
|
732
733
|
if (path.includes('$count=true')) { // $count=true requires ConsistencyLevel
|
|
734
|
+
// note: when using $expand, the $count=true might be ignored by target endpoint and the ctx.paging.totalResults updated by doReqest() will be incremental
|
|
733
735
|
if (!options.headers) options.headers = {}
|
|
734
736
|
options.headers.ConsistencyLevel = 'eventual'
|
|
735
737
|
}
|
|
@@ -1357,7 +1359,6 @@ const getUserRoles = async (baseEntity: string, userId: string, groups: Record<s
|
|
|
1357
1359
|
})
|
|
1358
1360
|
|
|
1359
1361
|
const permanentRoles = rolesAssignments.permanent.filter((role: any) => Ids.includes(role.principalId)).map((role: any) => {
|
|
1360
|
-
if (eligibleRoles.filter((r: any) => r.value === role.roleDefinitionId).length > 0) return null // eligible role activated becomes listed as permanent, skip those...
|
|
1361
1362
|
const roleDef = roleDefs[role.roleDefinitionId]
|
|
1362
1363
|
if (roleDef) {
|
|
1363
1364
|
if (includeAssignmentId === true) return { type: 'Permanent', value: roleDef.id, display: roleDef.displayName, assignmentId: role.id }
|