soajs.controller 4.3.17 → 4.3.19

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.
Files changed (35) hide show
  1. package/.github/workflows/ci.yml +4 -1
  2. package/.github/workflows/release.yml +4 -1
  3. package/ARCHITECTURE.md +1 -0
  4. package/docs/configuration-custom-registry.md +206 -6
  5. package/docs/middleware/idempotency.md +31 -2
  6. package/docs/middleware/mt.md +18 -0
  7. package/docs/multi-tenant-security.md +1 -0
  8. package/docs/tenant-access-resolver.md +81 -0
  9. package/mw/gotoService/redirectToService.js +20 -8
  10. package/mw/idempotency/index.js +42 -4
  11. package/mw/idempotency/model/memory.js +12 -2
  12. package/mw/idempotency/model/mongo.js +10 -3
  13. package/mw/mt/tenantAccess.js +173 -0
  14. package/mw/mt/urac.js +61 -12
  15. package/package.json +1 -1
  16. package/test/data/provision/customRegistry/gateway.js +32 -0
  17. package/test/data/provision/customRegistry/urac.js +22 -0
  18. package/test/data/provision/environment/dev.js +9 -0
  19. package/test/data/provision/hosts/departments.js +12 -0
  20. package/test/data/provision/hosts/idempotent.js +12 -0
  21. package/test/data/provision/marketplace/departments.js +38 -0
  22. package/test/data/provision/marketplace/idempotent.js +37 -0
  23. package/test/data/provision/oauth/token/department_member_token.js +72 -0
  24. package/test/data/provision/oauth/token/department_outsider_token.js +72 -0
  25. package/test/data/provision/products/dsbrd.js +25 -0
  26. package/test/data/provision/tenants/dbtn.js +6 -0
  27. package/test/integration/UC5-tenant-access/index.js +153 -0
  28. package/test/integration/UC6-idempotency/index.js +197 -0
  29. package/test/integration/index.js +8 -0
  30. package/test/unit/index.js +2 -0
  31. package/test/unit/mw/idempotency/index.js +122 -0
  32. package/test/unit/mw/idempotency/memory.js +36 -4
  33. package/test/unit/mw/idempotency/mongo.js +62 -0
  34. package/test/unit/mw/mt/tenantAccess.js +338 -0
  35. package/test/unit/mw/mt/urac.js +229 -1
@@ -17,7 +17,10 @@ jobs:
17
17
 
18
18
  services:
19
19
  mongodb:
20
- image: mongo:8
20
+ # TODO: back to mongo:8 once it starts on the GitHub runners again. Since 2026-10-19 mongod 8
21
+ # exits on start with "Linux kernel versions 6.19 and newer has a known incompatibility
22
+ # with this version of MongoDB" (https://jira.mongodb.org/browse/SERVER-121912).
23
+ image: mongo:7.0
21
24
  ports:
22
25
  - 27017:27017
23
26
 
@@ -17,7 +17,10 @@ jobs:
17
17
 
18
18
  services:
19
19
  mongodb:
20
- image: mongo:8
20
+ # TODO: back to mongo:8 once it starts on the GitHub runners again. Since 2026-10-19 mongod 8
21
+ # exits on start with "Linux kernel versions 6.19 and newer has a known incompatibility
22
+ # with this version of MongoDB" (https://jira.mongodb.org/browse/SERVER-121912).
23
+ image: mongo:7.0
21
24
  ports:
22
25
  - 27017:27017
23
26
 
package/ARCHITECTURE.md CHANGED
@@ -156,6 +156,7 @@ The gateway processes requests through a sequential middleware pipeline:
156
156
  | [External Integrations](docs/external-integrations.md) | OAuth, URAC, and infrastructure integrations |
157
157
  | [Deployment Architecture](docs/deployment.md) | High availability deployment patterns |
158
158
  | [Custom Registry Configuration](docs/configuration-custom-registry.md) | Environment-specific gateway settings |
159
+ | [Tenant Access Resolver Guide](docs/tenant-access-resolver.md) | Step by step: give a group of users access to a tenant through an API of your app |
159
160
  | [Services Config](docs/configuration-services-config.md) | Tenant key-specific configuration |
160
161
  | [Gateway Routes](docs/gateway-routes.md) | Built-in gateway routes (/soajs/acl, /soajs/proxy) |
161
162
  | [Maintenance Routes](docs/maintenance-routes.md) | Health check and admin endpoints |
@@ -4,7 +4,7 @@ Environment-specific gateway settings stored in the Core DB custom registry.
4
4
 
5
5
  ## Configuration Structure Overview
6
6
 
7
- These settings are accessed via `registry.custom.gateway.value.*` and `registry.custom.oauth.value.*`.
7
+ These settings are accessed via `registry.custom.gateway.value.*`, `registry.custom.oauth.value.*` and `registry.custom.urac.value.*`.
8
8
 
9
9
  ```
10
10
  registry.custom
@@ -35,11 +35,15 @@ registry.custom
35
35
  │ └── gotoService
36
36
  │ ├── monitor # Request monitoring config
37
37
  │ └── renewReqMonitorOff # Disable timeout renewal
38
- └── oauth
38
+ ├── oauth
39
+ │ └── value
40
+ │ ├── roaming # Cross-environment access
41
+ │ ├── pinWrapper # PIN-based login endpoint
42
+ │ └── pinWhitelist # PIN login API whitelist
43
+ └── urac
39
44
  └── value
40
- ├── roaming # Cross-environment access
41
- ├── pinWrapper # PIN-based login endpoint
42
- └── pinWhitelist # PIN login API whitelist
45
+ └── tenantAccessResolvers # APIs that grant access to a tenant
46
+ └── [resolver] # One API per resolver name
43
47
  ```
44
48
 
45
49
  ---
@@ -577,6 +581,185 @@ loaded from urac per request and the membership is current.
577
581
 
578
582
  ---
579
583
 
584
+ ## 15. Tenant Access Resolver
585
+
586
+ **Path:** `registry.custom.urac.value.tenantAccessResolvers`
587
+
588
+ **Middleware:** `mw/mt/urac.js`, called by `mw/mt/tenantAccess.js`
589
+
590
+ Gives a group of users access to a tenant without inviting each of them, for example
591
+ every user of a department of an app. The tenant names a resolver, and the resolver is
592
+ an API of that app that answers, per user, whether they have access and with what
593
+ groups. Nothing is written on the users, `allowedTenants` stays untouched.
594
+
595
+ Users always log in at their home tenant and then switch, by calling the gateway with
596
+ their token and the key of the other tenant. The resolver is asked on those calls.
597
+
598
+ Setting it up takes three steps:
599
+
600
+ 1. Build the API in a service of the app, see [The API to build](#the-api-to-build)
601
+ 2. Add the resolver to the `urac` custom registry, see [The resolver](#the-resolver)
602
+ 3. Name the resolver in the profile of each tenant, see [The tenant](#the-tenant)
603
+
604
+ ### The API to build
605
+
606
+ Any route of a service in the registry. The gateway calls it directly, not through the
607
+ gateway, so it does not need to be granted in any package. Keep it out of every ACL,
608
+ its answer is trusted as is.
609
+
610
+ It receives:
611
+
612
+ ```javascript
613
+ // Body
614
+ {
615
+ "user": {
616
+ "id": "5c8d0c505653de3985aa1001",
617
+ "username": "ann",
618
+ "tenant": { "id": "...", "code": "HOME" } // Home tenant of the user
619
+ },
620
+ "tenant": {
621
+ "id": "...",
622
+ "code": "DEPT",
623
+ "main": { "id": "...", "code": "MAIN" } // Only for a sub tenant
624
+ },
625
+ "subject": "dept_1" // From the tenant profile
626
+ }
627
+ ```
628
+
629
+ The `soajsinjectobj` header carries the tenant, the key with `commonFields` and the
630
+ config of the resolver service, and the application of the request, so a service with
631
+ `extKeyRequired` accepts the call. It carries no user and no ACL.
632
+
633
+ It answers with a standard SOAJS response:
634
+
635
+ ```javascript
636
+ // Access
637
+ {
638
+ "result": true,
639
+ "data": {
640
+ "access": true,
641
+ "groups": ["member"],
642
+ "groupsConfig": {
643
+ "allowedPackages": { "PROD": ["PROD_USER"] }
644
+ }
645
+ }
646
+ }
647
+
648
+ // No access
649
+ { "result": true, "data": { "access": false } }
650
+ ```
651
+
652
+ | Field | Required | Description |
653
+ |-------|----------|-------------|
654
+ | `access` | yes | `true` to grant access |
655
+ | `groupsConfig.allowedPackages` | yes, with access | Packages per product, the shape urac builds from the user's groups |
656
+ | `groups` | no | Group codes services see on the urac object, `[]` when absent |
657
+
658
+ The answer is the ACL. Nothing caps it, a package the API returns is granted. Only the
659
+ entry of the product of the key is used.
660
+
661
+ It runs on every call under the tenant, keep it fast.
662
+
663
+ ### The resolver
664
+
665
+ ```javascript
666
+ {
667
+ "name": "urac",
668
+ "value": {
669
+ "tenantAccessResolvers": {
670
+ "department": {
671
+ "service": "departments",
672
+ "version": "1",
673
+ "method": "post",
674
+ "api": "/tenant/access",
675
+ "timeout": 2000
676
+ }
677
+ }
678
+ }
679
+ }
680
+ ```
681
+
682
+ | Field | Type | Default | Description |
683
+ |-------|------|---------|-------------|
684
+ | `service` | string | - | Service that answers, must be in the registry |
685
+ | `version` | string | latest | Version of the service |
686
+ | `method` | string | `post` | HTTP method |
687
+ | `api` | string | - | Route on the service, without the service name |
688
+ | `timeout` | number | `2000` | Milliseconds before the call counts as no access |
689
+
690
+ The tenant names the resolver and the registry defines the call, so the API can move
691
+ without touching any tenant. A tenant naming a resolver that is not defined here is
692
+ logged as an error and treated as having none.
693
+
694
+ ### The tenant
695
+
696
+ The tenant profile names the resolver and the subject the API should check, here the
697
+ department:
698
+
699
+ ```javascript
700
+ {
701
+ "profile": {
702
+ "access": {
703
+ "resolver": "department", // A key of tenantAccessResolvers
704
+ "subject": "dept_1" // Passed to the API as is
705
+ }
706
+ }
707
+ }
708
+ ```
709
+
710
+ The profile is loaded with the key, so a change applies once the key cache refreshes.
711
+ `PUT /tenant/profile` of multitenant replaces the whole profile, whatever edits it must
712
+ keep `access`.
713
+
714
+ ### When it is asked
715
+
716
+ For every call made with a token under a tenant that is not the user's home tenant, in
717
+ this order:
718
+
719
+ | Order | Condition | Result |
720
+ |-------|-----------|--------|
721
+ | 1 | The token's `config.allowedTenants` lists the tenant | Invited, urac driver as before, the resolver is not asked |
722
+ | 2 | The tenant profile names a resolver | The resolver is asked |
723
+ | 3 | The resolver says no, fails or times out, or there is no resolver | urac driver as before |
724
+
725
+ It is never asked for the user's home tenant, for a token issued for this tenant, for
726
+ roaming, or for `loginMode: "oauth"` tokens.
727
+
728
+ On access the gateway takes the user from the token, sets its tenant to the tenant of
729
+ the key, its `groups` and `groupsConfig` to the answer, and builds the ACL as for any
730
+ user. The packages come from the provision cache, so urac is not read at all.
731
+
732
+ The fallback catches an invitation made after the token was issued, the token does not
733
+ show it yet. Everyone else gets what they got before, error 146.
734
+
735
+ ### When access is refused
736
+
737
+ Anything but a well formed access answer is no access, and the urac driver decides:
738
+
739
+ | | |
740
+ |---|---|
741
+ | the service is not in the registry, or has no host | logged as an error |
742
+ | non 2xx status, `result: false`, or not JSON | logged as an error |
743
+ | no answer within `timeout` | logged as an error |
744
+ | `access: true` without `groupsConfig.allowedPackages` | logged as an error |
745
+ | `access` is not `true` | not logged, a normal answer |
746
+
747
+ ### Behaviour
748
+
749
+ - **Cost.** One API call per call under the tenant, in place of the user and groups
750
+ reads of urac. Invited users cost what they did before.
751
+ - **Freshness.** Nothing is cached, adding or removing a user in the app applies on
752
+ their next call.
753
+ - **Outage.** While the service is down, users of the resolver get 146 under that
754
+ tenant, each call after waiting up to `timeout`, keep it short. Their home tenant and
755
+ invited users are not affected, except users invited after their token was issued,
756
+ who also wait for the timeout before the fallback lets them in.
757
+ - **Invitations win.** A user both invited and covered by the resolver gets the groups
758
+ of the invitation. If the invitation was made after the token was issued, they get
759
+ the resolver's until their next login.
760
+
761
+ ---
762
+
580
763
  ## Complete Configuration Example
581
764
 
582
765
  ```javascript
@@ -681,6 +864,22 @@ loaded from urac per request and the membership is current.
681
864
  }
682
865
  }
683
866
  }
867
+
868
+ // Separate URAC custom registry entry
869
+ {
870
+ "_id": ObjectId("..."),
871
+ "name": "urac",
872
+ "value": {
873
+ "tenantAccessResolvers": {
874
+ "department": {
875
+ "service": "departments",
876
+ "version": "1",
877
+ "api": "/tenant/access",
878
+ "timeout": 2000
879
+ }
880
+ }
881
+ }
882
+ }
684
883
  ```
685
884
 
686
885
  ---
@@ -698,7 +897,8 @@ Custom registry configurations are processed in this order:
698
897
  | 5 | gotoService/redirectToService | `gotoService.monitor` |
699
898
  | 6 | mt (security checks) | `mt.whitelist`, `oauth.value.pinWrapper`, `oauth.value.pinWhitelist` |
700
899
  | 6a | oauth (called from mt) | `oauth.deviceIdCheck` |
701
- | 6b | mt (inject object) | `membership` |
900
+ | 6b | mt (uracCheck) | `urac.value.tenantAccessResolvers` |
901
+ | 6c | mt (inject object) | `membership` |
702
902
  | 7 | idempotency | `idempotency.model`, `idempotency.services.[name]` |
703
903
  | 8 | traffic | `traffic.model`, `traffic.throttling` |
704
904
  | 9 | cache | `cache.model`, `cache.defaultTTL`, `cache.services.[name]` |
@@ -48,7 +48,8 @@ The Idempotency middleware prevents duplicate write operations by tracking reque
48
48
  │
49
49
  ├─── Found (in_flight) ──▶ 409 Conflict (code: 181)
50
50
  │
51
- └─── Not found ──▶ Lock key → Execute → Store response
51
+ └─── Not found ──▶ Lock key → Execute → Store response (status < 500)
52
+ or release the key
52
53
  ```
53
54
 
54
55
  ## Configuration
@@ -140,12 +141,39 @@ Content-Type: application/json
140
141
  {"caller": "user1", "callee": "user2"}
141
142
  ```
142
143
 
144
+ ## How a Locked Key Ends
145
+
146
+ The key is locked before the request goes on to throttling, caching and the proxy. How the
147
+ request ends decides what happens to it:
148
+
149
+ | The request ends with | The key | A retry with the same key |
150
+ |---|---|---|
151
+ | An answer of the service below 500 (2xx, 3xx, 4xx) | Completed, the answer is stored for the TTL | Gets the stored answer, the service is not called |
152
+ | An answer of the service of 500 or more | Released | Reaches the service again |
153
+ | A proxy error (service down, connection closed) | Released | Reaches the service again |
154
+ | An answer of the gateway before the service: throttling (429), an error, a refusal | Released | Runs again |
155
+ | The client disconnecting before the request reaches the service | Released | Runs again |
156
+ | The client disconnecting while the service is answering | Follows the service answer above | |
157
+
158
+ Only an answer the service actually produced is replayed. A 4xx is the service's answer to that
159
+ request and replaying it is correct. A 5xx, a throttling refusal or any other answer of the gateway
160
+ says nothing about the operation, so the key is released and the client's retry runs instead of
161
+ getting the same refusal for the whole TTL.
162
+
163
+ While the request is running, a duplicate with the same key gets 409, code 181.
164
+
165
+ A service that can fail with a 5xx after part of the work is done must protect that work itself,
166
+ the retry reaches it again.
167
+
168
+ The release only removes a key still in flight and locked by that request: never a completed
169
+ one, and never a lock taken by a retry after the TTL of the first ran out.
170
+
143
171
  ## Response Scenarios
144
172
 
145
173
  ### New Request (First Time)
146
174
 
147
175
  ```
148
- Request processed normally, response cached
176
+ Request processed normally, a service answer below 500 is stored
149
177
  ```
150
178
 
151
179
  ### Replay (Completed)
@@ -235,6 +263,7 @@ Keys are isolated per tenant to prevent cross-tenant collisions.
235
263
  - Only applies to non-GET requests (POST, PUT, DELETE, PATCH)
236
264
  - GET requests always pass through (idempotent by nature)
237
265
  - Keys expire after TTL (default 60 seconds)
266
+ - A key is never left in flight by a request that does not reach the service, see [How a Locked Key Ends](#how-a-locked-key-ends)
238
267
  - Clients should generate a new UUID for each unique operation
239
268
  - Same UUID = same operation (for retries)
240
269
  - MongoDB backend required for horizontal scaling
@@ -285,6 +285,23 @@ PIN login is a two-factor authentication mechanism where users must enter a PIN
285
285
  - Skipped if `obj.skipOAUTH = true` or no bearer token present
286
286
  - Creates `req.soajs.uracDriver` instance
287
287
  - Handles **roaming** across environments (e.g., dashboard token used in dev environment)
288
+ - Asks the **tenant access resolver** when a user switches to a tenant whose profile names one
289
+
290
+ **Tenant Access Resolver:**
291
+
292
+ A tenant can name a resolver in its profile (`profile.access`), an API of an app that answers per
293
+ user whether they have access to the tenant and with what groups, so a group of users, a department
294
+ for example, gets access without being invited one by one.
295
+
296
+ For a call under a tenant that is not the user's home tenant, and that the token's
297
+ `config.allowedTenants` does not list, `mw/mt/urac.js` calls the API defined in
298
+ `registry.custom.urac.value.tenantAccessResolvers` (`mw/mt/tenantAccess.js`). On access the user
299
+ comes from the token with that tenant and the groups of the answer, and urac is not read. No
300
+ access, an error or a timeout falls back to the urac driver as before.
301
+
302
+ See [Custom Registry, Tenant Access Resolver](../configuration-custom-registry.md#15-tenant-access-resolver)
303
+ for the API to build, the registry entry and the tenant profile.
304
+ To add one step by step, see the [Tenant Access Resolver Guide](../tenant-access-resolver.md).
288
305
 
289
306
  **Roaming Support:**
290
307
 
@@ -560,6 +577,7 @@ Service-level configuration that affects MT processing:
560
577
 
561
578
  - `mw/mt/utils.js` - Security check implementations
562
579
  - `mw/mt/urac.js` - URAC driver integration
580
+ - `mw/mt/tenantAccess.js` - Tenant access resolver call
563
581
 
564
582
  ## Usage Notes
565
583
 
@@ -111,6 +111,7 @@ Loads user profile from URAC for authenticated requests.
111
111
  - Resolves user groups
112
112
  - Applies user-specific ACL overrides
113
113
  - Handles network package override for user packages
114
+ - Asks the tenant access resolver for a user switching to a tenant that names one, see [Custom Registry](configuration-custom-registry.md#15-tenant-access-resolver)
114
115
 
115
116
  ## IP Whitelist Configuration
116
117
 
@@ -0,0 +1,81 @@
1
+ # Adding a Tenant Access Resolver
2
+
3
+ A step by step guide to give a group of users, a department of your app for example, access
4
+ to a tenant without inviting them one by one.
5
+
6
+ Every detail lives in [Custom Registry, Tenant Access Resolver](configuration-custom-registry.md#15-tenant-access-resolver),
7
+ this guide only walks through it in order.
8
+
9
+ ## Before you start
10
+
11
+ - Users log in at their home tenant and switch inside the app, the resolver is asked on the
12
+ calls they make under the other tenant. See [When it is asked](configuration-custom-registry.md#when-it-is-asked).
13
+ - Your app is the source of truth for who belongs to what, and its answer becomes the ACL of
14
+ the user under that tenant.
15
+
16
+ ## Step 1: Build the API
17
+
18
+ In a service of your app that is in the registry, add a route that tells the gateway if a user
19
+ has access to a tenant and with what groups.
20
+
21
+ 1. Read the body the gateway sends, `user`, `tenant` and `subject`.
22
+ 2. Look up whether `user` belongs to `subject` in your app.
23
+ 3. Answer with `access` and, when granted, `groups` and `groupsConfig.allowedPackages` for the
24
+ product of the tenant.
25
+
26
+ The request and the response are in [The API to build](configuration-custom-registry.md#the-api-to-build).
27
+
28
+ Keep in mind:
29
+
30
+ - Keep the route out of every package ACL, the gateway calls the service directly.
31
+ - It runs on every call the user makes under the tenant, keep it fast.
32
+ - Anything but a well formed yes is no access, see [When access is refused](configuration-custom-registry.md#when-access-is-refused).
33
+
34
+ ## Step 2: Add the resolver to the custom registry
35
+
36
+ In the environment's custom registry, add or edit the entry named `urac` and add your resolver
37
+ under `tenantAccessResolvers`, giving it a name, the service, the route and a short timeout.
38
+
39
+ The fields are in [The resolver](configuration-custom-registry.md#the-resolver).
40
+
41
+ The name you give it is what tenants refer to, keep it stable. The service, route or timeout can
42
+ change later without touching any tenant.
43
+
44
+ ## Step 3: Point the tenant at the resolver
45
+
46
+ On each tenant the group should reach, set `profile.access` with the resolver name from step 2
47
+ and the `subject` your API checks, the department id for example.
48
+
49
+ See [The tenant](configuration-custom-registry.md#the-tenant).
50
+
51
+ Whatever edits tenant profiles must keep `access`, `PUT /tenant/profile` replaces the whole
52
+ profile.
53
+
54
+ ## Step 4: Check it
55
+
56
+ With a user of the group, logged in at their home tenant:
57
+
58
+ 1. Call an API of the tenant with their token and the tenant's key. It should succeed with the
59
+ packages your API returned.
60
+ 2. Call it with a user outside the group. It should fail with error 146, as before.
61
+ 3. Call it with a user invited to the tenant. Your API is not called, the invitation decides.
62
+
63
+ If a user of the group gets 146, look for `Tenant access resolver` in the gateway logs, every
64
+ failed call is logged with the resolver, the user and the tenant.
65
+
66
+ Changes in your app apply on the next call. Changes to the tenant profile apply once the key
67
+ cache refreshes.
68
+
69
+ ## Before going live
70
+
71
+ Read [Behaviour](configuration-custom-registry.md#behaviour): what each call costs, what users
72
+ see while your service is down, and how invitations and the resolver combine.
73
+
74
+ ## Reference
75
+
76
+ | Topic | Where |
77
+ |-------|-------|
78
+ | Full reference | [Custom Registry, Tenant Access Resolver](configuration-custom-registry.md#15-tenant-access-resolver) |
79
+ | Where it runs in the pipeline | [MT middleware, uracCheck](middleware/mt.md#6-uraccheck) |
80
+ | Code | `mw/mt/urac.js`, `mw/mt/tenantAccess.js` |
81
+ | Example test | `test/integration/UC5-tenant-access/index.js`, a mock resolver |
@@ -11,6 +11,7 @@
11
11
  const get = (p, o) => p.reduce((xs, x) => (xs && xs[x]) ? xs[x] : null, o);
12
12
 
13
13
  const { httpRequestLight, proxyRequestMonitor } = require("../../lib/request.js");
14
+ const idempotency_mw = require("../idempotency/index.js");
14
15
 
15
16
  // Monitor buffer for batch uploads
16
17
  let monitorBuffer = [];
@@ -395,6 +396,11 @@ module.exports = (configuration) => {
395
396
  };
396
397
  }
397
398
 
399
+ //NOTE: from here the outcome of the service decides the key, the release on finish in the
400
+ // idempotency middleware stands down
401
+ if (idempotencyContext) {
402
+ idempotencyContext.proxied = true;
403
+ }
398
404
  proxyRequestMonitor(req, res, requestOptions, extraOptions)
399
405
  .then(() => {
400
406
  // Save to cache if applicable
@@ -408,14 +414,20 @@ module.exports = (configuration) => {
408
414
  cacheContext.model.set(cacheContext.key, cacheData, cacheContext.ttl);
409
415
  }
410
416
  // Complete idempotency if applicable
417
+ //NOTE: only an answer of the service is replayed, a 5xx means it could not answer, release
418
+ // the key so the retry reaches the service again
411
419
  if (idempotencyContext) {
412
- let idempotencyBody = Buffer.concat(idempotencyResponseChunks).toString('utf8');
413
- let idempotencyData = {
414
- statusCode: idempotencyResponseStatusCode,
415
- headers: idempotencyResponseHeaders,
416
- body: idempotencyBody
417
- };
418
- idempotencyContext.model.complete(idempotencyContext.key, idempotencyData, idempotencyContext.ttl);
420
+ if (idempotencyResponseStatusCode < 500) {
421
+ let idempotencyBody = Buffer.concat(idempotencyResponseChunks).toString('utf8');
422
+ let idempotencyData = {
423
+ statusCode: idempotencyResponseStatusCode,
424
+ headers: idempotencyResponseHeaders,
425
+ body: idempotencyBody
426
+ };
427
+ idempotencyContext.model.complete(idempotencyContext.key, idempotencyData, idempotencyContext.ttl);
428
+ } else {
429
+ idempotency_mw.unlock(idempotencyContext.model, idempotencyContext.key, idempotencyContext.lockId, req.soajs.log);
430
+ }
419
431
  }
420
432
  if (monitor && !monitor_service_blacklist && monitor.req_response) {
421
433
  monitoObj.time.res_end = new Date().getTime();
@@ -430,7 +442,7 @@ module.exports = (configuration) => {
430
442
  }
431
443
  // Unlock idempotency on error
432
444
  if (idempotencyContext) {
433
- idempotencyContext.model.unlock(idempotencyContext.key);
445
+ idempotency_mw.unlock(idempotencyContext.model, idempotencyContext.key, idempotencyContext.lockId, req.soajs.log);
434
446
  }
435
447
  if (!req.soajs.controller.monitorEndingReq) {
436
448
  req.soajs.controllerResponse(core.error.getError(135));
@@ -8,6 +8,7 @@
8
8
  * found in the LICENSE file at the root of this repository
9
9
  */
10
10
 
11
+ const crypto = require('crypto');
11
12
  const get = (p, o) => p.reduce((xs, x) => (xs && xs[x]) ? xs[x] : null, o);
12
13
  const memory = require('./model/memory.js');
13
14
  const mongo = require('./model/mongo.js');
@@ -56,6 +57,21 @@ function matchesApi(method, path, apis) {
56
57
  return false;
57
58
  }
58
59
 
60
+ /**
61
+ * Release a key this request locked, only while it is still in flight and still ours
62
+ * @param modelObj memory or mongo
63
+ * @param key
64
+ * @param lockId
65
+ * @param log
66
+ */
67
+ function unlock(modelObj, key, lockId, log) {
68
+ Promise.resolve()
69
+ .then(() => modelObj.unlock(key, lockId))
70
+ .catch((err) => {
71
+ log.error('Idempotency unlock:', err.message);
72
+ });
73
+ }
74
+
59
75
  module.exports = function (configuration) {
60
76
  model.memory.init(configuration.log);
61
77
  model.mongo.init(configuration.log, configuration.gatewayDB);
@@ -131,6 +147,7 @@ module.exports = function (configuration) {
131
147
  'l2': idempotencyKey
132
148
  };
133
149
 
150
+ let lockId = null;
134
151
  const processIdempotency = async () => {
135
152
  try {
136
153
  const existing = await model[modelType].get(key);
@@ -152,7 +169,8 @@ module.exports = function (configuration) {
152
169
  }
153
170
  }
154
171
 
155
- const locked = await model[modelType].lock(key, ttl);
172
+ lockId = crypto.randomUUID();
173
+ const locked = await model[modelType].lock(key, ttl, lockId);
156
174
  if (!locked) {
157
175
  req.soajs.controllerResponse({
158
176
  'status': 409,
@@ -163,18 +181,37 @@ module.exports = function (configuration) {
163
181
  }
164
182
 
165
183
  // Store idempotency context for gotoService to use
166
- req.soajs.idempotencyContext = {
184
+ const context = {
167
185
  key: key,
168
186
  ttl: ttl,
187
+ lockId: lockId,
169
188
  modelType: modelType,
170
189
  model: model[modelType],
171
- log: configuration.log
190
+ log: configuration.log,
191
+ proxied: false
172
192
  };
193
+ req.soajs.idempotencyContext = context;
194
+
195
+ //NOTE: a request answered before it reaches the service, throttled, refused or dropped by
196
+ // the client, must not keep the key in flight until the ttl, release it once the response
197
+ // is over. A proxied request is completed or released by gotoService, never here.
198
+ let released = false;
199
+ const release = () => {
200
+ if (released || context.proxied) {
201
+ return;
202
+ }
203
+ released = true;
204
+ unlock(model[modelType], key, lockId, configuration.log);
205
+ };
206
+ res.on('finish', release);
207
+ res.on('close', release);
173
208
 
174
209
  next();
175
210
  } catch (err) {
176
211
  configuration.log.error('Idempotency error:', err);
177
- model[modelType].unlock(key).catch(() => {});
212
+ if (lockId) {
213
+ unlock(model[modelType], key, lockId, configuration.log);
214
+ }
178
215
  next();
179
216
  }
180
217
  };
@@ -185,3 +222,4 @@ module.exports = function (configuration) {
185
222
 
186
223
  // Export model for external use
187
224
  module.exports.model = model;
225
+ module.exports.unlock = unlock;
@@ -75,7 +75,7 @@ let model = {
75
75
  return entry;
76
76
  },
77
77
 
78
- 'lock': (key, ttl) => {
78
+ 'lock': (key, ttl, lockId) => {
79
79
  if (!dataHolder[key.l1]) {
80
80
  dataHolder[key.l1] = {};
81
81
  }
@@ -85,6 +85,7 @@ let model = {
85
85
  }
86
86
  dataHolder[key.l1][key.l2] = {
87
87
  'status': 'in_flight',
88
+ 'lockId': lockId || null,
88
89
  'lockedAt': Date.now(),
89
90
  'expiresAt': Date.now() + ttl
90
91
  };
@@ -103,8 +104,17 @@ let model = {
103
104
  };
104
105
  },
105
106
 
106
- 'unlock': (key) => {
107
+ //NOTE: only an entry still in flight is removed, never a completed one, and with a lockId only
108
+ // the lock of that request, not one taken by a retry after the ttl
109
+ 'unlock': (key, lockId) => {
107
110
  if (dataHolder[key.l1] && dataHolder[key.l1][key.l2]) {
111
+ const entry = dataHolder[key.l1][key.l2];
112
+ if (entry.status !== 'in_flight') {
113
+ return;
114
+ }
115
+ if (lockId && entry.lockId !== lockId) {
116
+ return;
117
+ }
108
118
  delete dataHolder[key.l1][key.l2];
109
119
  }
110
120
  },