soajs.controller 4.3.14 → 4.3.15

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.
@@ -27,6 +27,9 @@ registry.custom
27
27
  │ │ └── [service] # Service-specific settings
28
28
  │ ├── maintenanceMode # Gateway maintenance mode
29
29
  │ ├── lastSeen # User activity tracking
30
+ │ ├── membership # Membership on the urac object, by product
31
+ │ │ ├── active # Whether membership is resolved at all
32
+ │ │ └── [main product] # Its sub products
30
33
  │ ├── oauth
31
34
  │ │ └── deviceIdCheck # Turn the deviceId check off
32
35
  │ └── gotoService
@@ -494,6 +497,86 @@ is read by the oauth middleware itself.
494
497
 
495
498
  ---
496
499
 
500
+ ## 14. Membership
501
+
502
+ **Path:** `registry.custom.gateway.value.membership`
503
+
504
+ **Middleware:** `mw/mt/index.js`, resolved by `lib/membership.js`
505
+
506
+ Puts the caller's membership on the urac object of every call, so a service can read it
507
+ without a lookup.
508
+
509
+ A membership is held per main product on the user record in the urac `users` collection,
510
+ and only its name is stored. What the name grants is resolved from the catalog, it is
511
+ never copied onto the user:
512
+
513
+ ```javascript
514
+ {
515
+ "memberships": [
516
+ { "product": "AVAPP", "membership": "whale" }
517
+ ]
518
+ }
519
+ ```
520
+
521
+ The gateway already resolves both halves of the match on every call. The access token
522
+ gives the user record, which carries `memberships`, and the API key gives the product the
523
+ call is made under. Resolution is the match between them: if the product of the request
524
+ is a configured main product or one of its sub products, and the user holds an entry for
525
+ that main product, the membership name is injected.
526
+
527
+ ```javascript
528
+ {
529
+ "membership": {
530
+ "active": true, // Resolve membership at all
531
+ "AVAPP": ["CLUBS", "CORPS", "SECRT", "GRPCH"] // Main product and its sub products
532
+ }
533
+ }
534
+ ```
535
+
536
+ | Field | Type | Default | Description |
537
+ |-------|------|---------|-------------|
538
+ | `active` | boolean | `false` | Whether the gateway resolves membership at all |
539
+ | `[main product]` | string[] | - | A product a membership is held in, valued by the sub products that resolve to it |
540
+
541
+ Every key other than `active` is read as a main product, so more than one can be
542
+ configured and they stay independent. A main product always resolves to itself, listing
543
+ sub products is optional.
544
+
545
+ ### What the service sees
546
+
547
+ ```javascript
548
+ req.soajs.urac.membership // "whale"
549
+ ```
550
+
551
+ Only the resolved name travels. The raw `memberships` array is never injected, so a
552
+ service cannot read a membership held for a product the request was not made under.
553
+
554
+ ### It fails closed
555
+
556
+ Nothing is injected, and no service sees a membership, when any of these hold:
557
+
558
+ | | |
559
+ |---|---|
560
+ | the block is absent, or `active` is not `true` | membership is off |
561
+ | no main product is configured | nothing is resolvable, equivalent to off |
562
+ | the request product is neither a main product nor a sub product | out of scope |
563
+ | the user holds no entry for the resolved main product | no membership |
564
+ | the service does not request urac (`urac: false`) | there is no urac object to put it on |
565
+
566
+ Product codes are matched exactly. They are uppercase by convention, and normalizing
567
+ here would hide a miscased entry in the registry rather than surface it.
568
+
569
+ ### Freshness
570
+
571
+ The user record behind this comes from the access token when
572
+ `serviceConfig.oauth.getUserFromToken` is on, when the login mode is `oauth`, or when
573
+ roaming. In those cases `memberships` is a snapshot taken at login, and a membership
574
+ changed afterwards is not seen until a new token is issued. Refreshing does not help,
575
+ the refresh grant copies the stored user forward unchanged. Otherwise the record is
576
+ loaded from urac per request and the membership is current.
577
+
578
+ ---
579
+
497
580
  ## Complete Configuration Example
498
581
 
499
582
  ```javascript
@@ -615,6 +698,7 @@ Custom registry configurations are processed in this order:
615
698
  | 5 | gotoService/redirectToService | `gotoService.monitor` |
616
699
  | 6 | mt (security checks) | `mt.whitelist`, `oauth.value.pinWrapper`, `oauth.value.pinWhitelist` |
617
700
  | 6a | oauth (called from mt) | `oauth.deviceIdCheck` |
701
+ | 6b | mt (inject object) | `membership` |
618
702
  | 7 | idempotency | `idempotency.model`, `idempotency.services.[name]` |
619
703
  | 8 | traffic | `traffic.model`, `traffic.throttling` |
620
704
  | 9 | cache | `cache.model`, `cache.defaultTTL`, `cache.services.[name]` |
@@ -515,6 +515,7 @@ The `soajsinjectobj` header contains:
515
515
  "lastName": "Doe",
516
516
  "email": "user@example.com",
517
517
  "groups": ["group1"],
518
+ "membership": "whale", // If resolved, see custom registry `membership`
518
519
  "tenant": { "id": "...", "code": "..." },
519
520
  "profile": { ... }, // If urac_Profile enabled
520
521
  "acl": { ... }, // If urac_ACL enabled
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @license
5
+ * Copyright SOAJS All Rights Reserved.
6
+ *
7
+ * Use of this source code is governed by an Apache license that can be
8
+ * found in the LICENSE file at the root of this repository
9
+ */
10
+
11
+ /**
12
+ * Resolve the membership the caller holds for the product this request is made under.
13
+ *
14
+ * A membership is held per main product on the user record, and only its name is
15
+ * stored. What the name grants is resolved from the catalog, it is never copied
16
+ * onto the user:
17
+ *
18
+ * "memberships": [{ "product": "AVAPP", "membership": "whale" }]
19
+ *
20
+ * A request carries the product of the key it was made with. That product is either
21
+ * the main product itself or one of its sub products, and both resolve to the main
22
+ * product, so a call made under CLUBS reads the AVAPP membership.
23
+ *
24
+ * Configured at registry.custom.gateway.value.membership, keyed by main product:
25
+ *
26
+ * {
27
+ * "active": true,
28
+ * "AVAPP": ["CLUBS", "CORPS", "SECRT", "GRPCH"]
29
+ * }
30
+ *
31
+ * NOTE: this fails closed. An absent or inactive block, an unconfigured product, or
32
+ * a user with no entry for the resolved main product all resolve to null, and
33
+ * nothing is injected, so no service sees a membership it should not.
34
+ *
35
+ * @param config the custom registry membership block
36
+ * @param product the product the request is made under, ex: keyObj.application.product
37
+ * @param memberships the memberships array off the user record
38
+ * @returns {string|null} the membership name, or null when it does not resolve
39
+ */
40
+ function resolveMembership(config, product, memberships) {
41
+ if (!config || !config.active) {
42
+ return null;
43
+ }
44
+ if (!product || !Array.isArray(memberships) || memberships.length === 0) {
45
+ return null;
46
+ }
47
+
48
+ //NOTE: product codes are matched exactly, they are uppercase by convention and
49
+ // normalizing here would hide a miscased entry in the registry
50
+ let mainProduct = null;
51
+ let configuredProducts = Object.keys(config).filter((key) => {
52
+ return key !== "active";
53
+ });
54
+ for (let i = 0; i < configuredProducts.length; i++) {
55
+ let key = configuredProducts[i];
56
+ if (key === product || (Array.isArray(config[key]) && config[key].includes(product))) {
57
+ mainProduct = key;
58
+ break;
59
+ }
60
+ }
61
+ if (!mainProduct) {
62
+ return null;
63
+ }
64
+
65
+ for (let i = 0; i < memberships.length; i++) {
66
+ let entry = memberships[i];
67
+ if (entry && entry.product === mainProduct && entry.membership) {
68
+ return entry.membership;
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+
74
+ module.exports = { resolveMembership };
package/mw/mt/index.js CHANGED
@@ -14,6 +14,9 @@ const utils = require("./utils");
14
14
 
15
15
 
16
16
  const { encodeHeaderValue } = require("../../lib/header.js");
17
+ const { resolveMembership } = require("../../lib/membership.js");
18
+
19
+ const get = (p, o) => p.reduce((xs, x) => (xs && xs[x]) ? xs[x] : null, o);
17
20
 
18
21
  /**
19
22
  *
@@ -266,6 +269,15 @@ module.exports = (configuration) => {
266
269
  }
267
270
  };
268
271
 
272
+ //NOTE: only the resolved name travels, never the raw per product
273
+ // array, so a service cannot read a membership held for
274
+ // a product this request was not made under
275
+ let membershipConfig = get(["soajs", "registry", "custom", "gateway", "value", "membership"], req);
276
+ let membership = resolveMembership(membershipConfig, keyObj.application.product, uracObj.memberships);
277
+ if (membership) {
278
+ injectObj.urac.membership = membership;
279
+ }
280
+
269
281
  injectObj.param = injectObj.param || {};
270
282
  injectObj.param.urac_Profile = serviceParam.urac_Profile;
271
283
  injectObj.param.urac_ACL = serviceParam.urac_ACL;
package/mw/mt/urac.js CHANGED
@@ -167,6 +167,13 @@ Urac.prototype.getProfile = function (_ALL) {
167
167
  "tenant": _self.userRecord.tenant
168
168
  };
169
169
 
170
+ //NOTE: the raw per product array. The gateway resolves the one entry that applies
171
+ // to this request and injects only that, see lib/membership.js. Set only when
172
+ // the record carries it, so a user with no memberships keeps the shape it had.
173
+ if (_self.userRecord.memberships) {
174
+ urac.memberships = _self.userRecord.memberships;
175
+ }
176
+
170
177
  if (_self.userRecord.socialLogin) {
171
178
  urac.socialLogin = {
172
179
  "strategy": _self.userRecord.socialLogin.strategy,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "soajs.controller",
3
3
  "description": "soajs multi tenant API gateway",
4
- "version": "4.3.14",
4
+ "version": "4.3.15",
5
5
  "author": {
6
6
  "name": "soajs team",
7
7
  "email": "team@soajs.org"
@@ -11,6 +11,7 @@ describe("Starting Gateway Unit test", () => {
11
11
  require("./lib/http.js");
12
12
  require("./lib/ip.js");
13
13
  require("./lib/logOnce.js");
14
+ require("./lib/membership.js");
14
15
  require("./lib/param.js");
15
16
  require("./lib/parseURL.js");
16
17
 
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * @license
5
+ * Copyright SOAJS All Rights Reserved.
6
+ *
7
+ * Use of this source code is governed by an Apache license that can be
8
+ * found in the LICENSE file at the root of this repository
9
+ */
10
+
11
+ const assert = require('assert');
12
+ const helper = require("../../helper.js");
13
+
14
+ const { resolveMembership } = helper.requireModule('./lib/membership.js');
15
+
16
+ describe("Unit test for: lib - membership", () => {
17
+
18
+ const CONFIG = {
19
+ "active": true,
20
+ "AVAPP": ["CLUBS", "CORPS", "SECRT", "GRPCH"]
21
+ };
22
+ const MEMBERSHIPS = [{ "product": "AVAPP", "membership": "whale" }];
23
+
24
+ describe("resolving", () => {
25
+
26
+ it("resolves when the request is made under the main product", (done) => {
27
+ assert.strictEqual(resolveMembership(CONFIG, "AVAPP", MEMBERSHIPS), "whale");
28
+ done();
29
+ });
30
+
31
+ it("resolves when the request is made under a sub product", (done) => {
32
+ ["CLUBS", "CORPS", "SECRT", "GRPCH"].forEach((sub) => {
33
+ assert.strictEqual(resolveMembership(CONFIG, sub, MEMBERSHIPS), "whale", "failed for " + sub);
34
+ });
35
+ done();
36
+ });
37
+
38
+ it("resolves the main product itself when it has no sub products", (done) => {
39
+ assert.strictEqual(resolveMembership({ "active": true, "AVAPP": [] }, "AVAPP", MEMBERSHIPS), "whale");
40
+ done();
41
+ });
42
+
43
+ it("picks the entry for the resolved main product, not the first one", (done) => {
44
+ let memberships = [
45
+ { "product": "OTHER", "membership": "minnow" },
46
+ { "product": "AVAPP", "membership": "whale" }
47
+ ];
48
+ assert.strictEqual(resolveMembership(CONFIG, "CLUBS", memberships), "whale");
49
+ done();
50
+ });
51
+
52
+ it("keeps several main products independent", (done) => {
53
+ let config = {
54
+ "active": true,
55
+ "AVAPP": ["CLUBS"],
56
+ "BVAPP": ["BOATS"]
57
+ };
58
+ let memberships = [
59
+ { "product": "AVAPP", "membership": "whale" },
60
+ { "product": "BVAPP", "membership": "dolphin" }
61
+ ];
62
+ assert.strictEqual(resolveMembership(config, "CLUBS", memberships), "whale");
63
+ assert.strictEqual(resolveMembership(config, "BOATS", memberships), "dolphin");
64
+ done();
65
+ });
66
+ });
67
+
68
+ describe("failing closed", () => {
69
+
70
+ it("resolves nothing when the block is absent", (done) => {
71
+ assert.strictEqual(resolveMembership(null, "AVAPP", MEMBERSHIPS), null);
72
+ assert.strictEqual(resolveMembership(undefined, "AVAPP", MEMBERSHIPS), null);
73
+ done();
74
+ });
75
+
76
+ it("resolves nothing when active is not set", (done) => {
77
+ assert.strictEqual(resolveMembership({ "AVAPP": ["CLUBS"] }, "AVAPP", MEMBERSHIPS), null);
78
+ done();
79
+ });
80
+
81
+ it("resolves nothing when active is false", (done) => {
82
+ assert.strictEqual(resolveMembership({ "active": false, "AVAPP": ["CLUBS"] }, "CLUBS", MEMBERSHIPS), null);
83
+ done();
84
+ });
85
+
86
+ it("resolves nothing when no product is configured", (done) => {
87
+ assert.strictEqual(resolveMembership({ "active": true }, "AVAPP", MEMBERSHIPS), null);
88
+ done();
89
+ });
90
+
91
+ it("resolves nothing for a product that is neither main nor sub", (done) => {
92
+ assert.strictEqual(resolveMembership(CONFIG, "UNRELATED", MEMBERSHIPS), null);
93
+ done();
94
+ });
95
+
96
+ it("resolves nothing when the user holds no entry for the main product", (done) => {
97
+ let memberships = [{ "product": "OTHER", "membership": "minnow" }];
98
+ assert.strictEqual(resolveMembership(CONFIG, "CLUBS", memberships), null);
99
+ done();
100
+ });
101
+
102
+ it("resolves nothing when the user has no memberships", (done) => {
103
+ assert.strictEqual(resolveMembership(CONFIG, "AVAPP", null), null);
104
+ assert.strictEqual(resolveMembership(CONFIG, "AVAPP", []), null);
105
+ assert.strictEqual(resolveMembership(CONFIG, "AVAPP", undefined), null);
106
+ done();
107
+ });
108
+
109
+ it("resolves nothing when the request carries no product", (done) => {
110
+ assert.strictEqual(resolveMembership(CONFIG, null, MEMBERSHIPS), null);
111
+ done();
112
+ });
113
+
114
+ it("matches product codes exactly, a miscased code does not resolve", (done) => {
115
+ assert.strictEqual(resolveMembership(CONFIG, "clubs", MEMBERSHIPS), null);
116
+ assert.strictEqual(resolveMembership(CONFIG, "AVAPP", [{ "product": "avapp", "membership": "whale" }]), null);
117
+ done();
118
+ });
119
+ });
120
+
121
+ describe("malformed input", () => {
122
+
123
+ it("survives a memberships array holding junk", (done) => {
124
+ let memberships = [null, "whale", { "product": "AVAPP" }, { "membership": "whale" }];
125
+ assert.strictEqual(resolveMembership(CONFIG, "AVAPP", memberships), null);
126
+ done();
127
+ });
128
+
129
+ it("skips an entry with no membership name and keeps looking", (done) => {
130
+ let memberships = [{ "product": "AVAPP" }, { "product": "AVAPP", "membership": "whale" }];
131
+ assert.strictEqual(resolveMembership(CONFIG, "AVAPP", memberships), "whale");
132
+ done();
133
+ });
134
+
135
+ it("survives a sub product list that is not an array", (done) => {
136
+ assert.strictEqual(resolveMembership({ "active": true, "AVAPP": "CLUBS" }, "CLUBS", MEMBERSHIPS), null);
137
+ assert.strictEqual(resolveMembership({ "active": true, "AVAPP": "CLUBS" }, "AVAPP", MEMBERSHIPS), "whale");
138
+ done();
139
+ });
140
+
141
+ it("does not treat active as a product", (done) => {
142
+ assert.strictEqual(resolveMembership({ "active": true }, "active", [{ "product": "active", "membership": "x" }]), null);
143
+ done();
144
+ });
145
+ });
146
+ });