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.
- package/.github/workflows/ci.yml +4 -1
- package/.github/workflows/release.yml +4 -1
- package/ARCHITECTURE.md +1 -0
- package/docs/configuration-custom-registry.md +206 -6
- package/docs/middleware/idempotency.md +31 -2
- package/docs/middleware/mt.md +18 -0
- package/docs/multi-tenant-security.md +1 -0
- package/docs/tenant-access-resolver.md +81 -0
- package/mw/gotoService/redirectToService.js +20 -8
- package/mw/idempotency/index.js +42 -4
- package/mw/idempotency/model/memory.js +12 -2
- package/mw/idempotency/model/mongo.js +10 -3
- package/mw/mt/tenantAccess.js +173 -0
- package/mw/mt/urac.js +61 -12
- package/package.json +1 -1
- package/test/data/provision/customRegistry/gateway.js +32 -0
- package/test/data/provision/customRegistry/urac.js +22 -0
- package/test/data/provision/environment/dev.js +9 -0
- package/test/data/provision/hosts/departments.js +12 -0
- package/test/data/provision/hosts/idempotent.js +12 -0
- package/test/data/provision/marketplace/departments.js +38 -0
- package/test/data/provision/marketplace/idempotent.js +37 -0
- package/test/data/provision/oauth/token/department_member_token.js +72 -0
- package/test/data/provision/oauth/token/department_outsider_token.js +72 -0
- package/test/data/provision/products/dsbrd.js +25 -0
- package/test/data/provision/tenants/dbtn.js +6 -0
- package/test/integration/UC5-tenant-access/index.js +153 -0
- package/test/integration/UC6-idempotency/index.js +197 -0
- package/test/integration/index.js +8 -0
- package/test/unit/index.js +2 -0
- package/test/unit/mw/idempotency/index.js +122 -0
- package/test/unit/mw/idempotency/memory.js +36 -4
- package/test/unit/mw/idempotency/mongo.js +62 -0
- package/test/unit/mw/mt/tenantAccess.js +338 -0
- package/test/unit/mw/mt/urac.js +229 -1
package/.github/workflows/ci.yml
CHANGED
|
@@ -17,7 +17,10 @@ jobs:
|
|
|
17
17
|
|
|
18
18
|
services:
|
|
19
19
|
mongodb:
|
|
20
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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 (
|
|
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,
|
|
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
|
package/docs/middleware/mt.md
CHANGED
|
@@ -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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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.
|
|
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));
|
package/mw/idempotency/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
},
|