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 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
- | **SCIM** | REST Webservice | Using plugin Loki as a SCIM provisioning endpoint. May become a SCIM version-gateway (e.g., 1.1 => 2.0) |
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
@@ -12,7 +12,7 @@
12
12
  //
13
13
 
14
14
  // start one or more plugins:
15
- // import './lib/plugin-scim.ts'
15
+ // import './lib/plugin-generic.ts'
16
16
  // import './lib/plugin-entra-id.ts'
17
17
  // import './lib/plugin-ldap.ts'
18
18
  // import './lib/plugin-mongodb.ts'
@@ -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
- urlObj = new URL(path)
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.path = `${urlObj.pathname}${urlObj.search}`
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
- path: urlObj.pathname + urlObj.search,
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
- const options = cli.options
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
- let url = `${options.protocol}//${options.host}${options.port ? ':' + options.port : ''}${options.path}`
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.protocol}//${options.host}${(options.port ? `:${options.port}` : '')}${options.path} Body = ${JSON.stringify(body)} Response = ${JSON.stringify(result)}`)
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.substring(0, url.indexOf('$skiptoken') - 1))
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} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
820
- } else this.scimgateway.logError(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
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
  }
@@ -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 }