@i4e/invest4edu-access-core 0.32.0 → 0.33.0
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 +128 -126
- package/package.json +57 -56
- package/src/access-config.js +68 -68
- package/src/access-resolver.js +269 -269
- package/src/access-schema.js +174 -174
- package/src/credits.js +128 -128
- package/src/entitlement-schema.js +194 -194
- package/src/entitlement-store.d.ts +99 -99
- package/src/entitlement-store.js +610 -610
- package/src/entitlement.js +300 -300
- package/src/grid-schema.js +231 -231
- package/src/index.js +75 -74
- package/src/proration.js +155 -155
- package/src/reportee-tree.js +129 -129
- package/src/role-capabilities.js +93 -93
- package/src/route-features.js +292 -292
- package/src/route-screen.js +45 -0
- package/src/subscription-lifecycle.js +106 -106
- package/src/tenant-context.js +26 -26
- package/src/tenant-plugin.js +177 -177
- package/src/visible-when.js +108 -108
|
@@ -1,106 +1,106 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Subscription lifecycle — statuses, transitions and event types. SHARED, because code branches
|
|
3
|
-
* on these in both backends and a status list that drifts between them means one side treats a
|
|
4
|
-
* subscription as live while the other has cut access (invariant #11).
|
|
5
|
-
*
|
|
6
|
-
* The machine:
|
|
7
|
-
*
|
|
8
|
-
* trialing → active | cancelled | expired
|
|
9
|
-
* active → past_due | blocked | cancelled
|
|
10
|
-
* past_due → active | blocked | cancelled (payment recovered | gave up | user quit)
|
|
11
|
-
* blocked → active | cancelled (recovered | closed)
|
|
12
|
-
* cancelled / expired → (terminal — a new purchase creates a NEW subscription)
|
|
13
|
-
*
|
|
14
|
-
* Terminal states stay terminal on purpose: "reactivating" a cancelled row would resurrect its
|
|
15
|
-
* history, overrides and period as if nothing happened. A fresh subscription is honest about the
|
|
16
|
-
* gap and keeps the audit trail of the old one intact.
|
|
17
|
-
*
|
|
18
|
-
* Upgrade/downgrade are TRANSITIONS OF PLAN, not of status — a plan change happens on a live
|
|
19
|
-
* subscription and does not appear here.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
export const SUBSCRIPTION_STATUSES = Object.freeze([
|
|
23
|
-
"trialing", "active", "past_due", "blocked", "cancelled", "expired",
|
|
24
|
-
]);
|
|
25
|
-
|
|
26
|
-
/** States in which entitlements resolve. past_due keeps working — cutting access the moment a
|
|
27
|
-
* payment is late loses more than it protects; `blocked` is the deliberate cut-off. */
|
|
28
|
-
export const LIVE_STATUSES = Object.freeze(["trialing", "active", "past_due"]);
|
|
29
|
-
|
|
30
|
-
export const TRANSITIONS = Object.freeze({
|
|
31
|
-
trialing: ["active", "cancelled", "expired"],
|
|
32
|
-
// `expired` because a FIXED-TERM subscription that reaches its end has simply run out. Without
|
|
33
|
-
// it the only exits are `blocked`, which implies a deliberate cut-off, and `cancelled`, which
|
|
34
|
-
// implies the customer chose to leave — neither is true of a 90-day plan reaching day 91.
|
|
35
|
-
active: ["past_due", "blocked", "cancelled", "expired"],
|
|
36
|
-
past_due: ["active", "blocked", "cancelled"],
|
|
37
|
-
blocked: ["active", "cancelled"],
|
|
38
|
-
cancelled: [],
|
|
39
|
-
expired: [],
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
/** `{ ok }` or `{ ok: false, message }` naming the legal moves — the caller shows it verbatim. */
|
|
43
|
-
export function canTransition(from, to) {
|
|
44
|
-
const allowed = TRANSITIONS[from];
|
|
45
|
-
if (!allowed) return { ok: false, message: `${from} is not a subscription status` };
|
|
46
|
-
if (from === to) return { ok: false, message: `already ${from}` };
|
|
47
|
-
if (!allowed.includes(to)) {
|
|
48
|
-
return {
|
|
49
|
-
ok: false,
|
|
50
|
-
message: allowed.length
|
|
51
|
-
? `${from} can only move to: ${allowed.join(", ")}`
|
|
52
|
-
: `${from} is terminal — a new purchase creates a new subscription`,
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
return { ok: true };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Event types — the audit spine AND what the Events Engine receives (`subscription.<type>`).
|
|
60
|
-
* Engagement and monitoring both hang off this list, so an unlisted type is an event nobody can
|
|
61
|
-
* subscribe to: recording one is refused rather than silently accepted.
|
|
62
|
-
*/
|
|
63
|
-
export const SUBSCRIPTION_EVENT_TYPES = Object.freeze([
|
|
64
|
-
"created", "trial_started", "activated", "renewed",
|
|
65
|
-
"plan_changed", "cycle_changed", "period_extended",
|
|
66
|
-
/**
|
|
67
|
-
* A term cut short by hand. Its own type rather than a negative `period_extended`, because the
|
|
68
|
-
* two raise opposite money questions — one leaves us owing product, the other owing money — and
|
|
69
|
-
* a stream where they look alike cannot be read for either.
|
|
70
|
-
*/
|
|
71
|
-
"period_shortened",
|
|
72
|
-
/** Somebody looked at a raised billing question and closed it. */
|
|
73
|
-
"billing_review_cleared",
|
|
74
|
-
"override_set", "override_removed",
|
|
75
|
-
"status_changed", "cancelled", "blocked", "expired",
|
|
76
|
-
"payment_captured", "payment_failed",
|
|
77
|
-
"quota_exceeded",
|
|
78
|
-
// Consumption that happened outside the software — a webinar attended, a VPD session held.
|
|
79
|
-
// There is no request behind it, so this event is the only record of who said it happened.
|
|
80
|
-
"usage_recorded",
|
|
81
|
-
/**
|
|
82
|
-
* Credits handed out by an admin, rather than bought or rolled over.
|
|
83
|
-
*
|
|
84
|
-
* It used to be recorded as `override_set`, which made the history unreadable at exactly the
|
|
85
|
-
* moment it mattered: a one-off top-up and a permanent change to somebody's per-period allowance
|
|
86
|
-
* are different promises, and "who gave this partner 500 credits" cannot be answered by a stream
|
|
87
|
-
* where both look identical.
|
|
88
|
-
*/
|
|
89
|
-
"credits_granted",
|
|
90
|
-
/**
|
|
91
|
-
* A term closed with credits unspent and the plan's policy carried them forward. Worth its own
|
|
92
|
-
* type because a customer WILL ask why their balance is bigger than their allowance, and the
|
|
93
|
-
* answer — which period it came from, how much, and what the cap did — has to be findable.
|
|
94
|
-
*/
|
|
95
|
-
"credits_rolled_over",
|
|
96
|
-
/**
|
|
97
|
-
* The refund conversation, recorded as it happens rather than reconstructed afterwards.
|
|
98
|
-
*
|
|
99
|
-
* A refund is the one flow where WHO ASKED and WHO AGREED both matter later — a credit note
|
|
100
|
-
* shows money went back but not that anyone approved it. `refund_requested` is the customer's
|
|
101
|
-
* ask, `refund_rejected` closes it with a reason, and `refunded` is the money actually moving.
|
|
102
|
-
*/
|
|
103
|
-
"refund_requested", "refund_approved", "refund_rejected", "refunded",
|
|
104
|
-
]);
|
|
105
|
-
|
|
106
|
-
export default { SUBSCRIPTION_STATUSES, LIVE_STATUSES, TRANSITIONS, canTransition, SUBSCRIPTION_EVENT_TYPES };
|
|
1
|
+
/**
|
|
2
|
+
* Subscription lifecycle — statuses, transitions and event types. SHARED, because code branches
|
|
3
|
+
* on these in both backends and a status list that drifts between them means one side treats a
|
|
4
|
+
* subscription as live while the other has cut access (invariant #11).
|
|
5
|
+
*
|
|
6
|
+
* The machine:
|
|
7
|
+
*
|
|
8
|
+
* trialing → active | cancelled | expired
|
|
9
|
+
* active → past_due | blocked | cancelled
|
|
10
|
+
* past_due → active | blocked | cancelled (payment recovered | gave up | user quit)
|
|
11
|
+
* blocked → active | cancelled (recovered | closed)
|
|
12
|
+
* cancelled / expired → (terminal — a new purchase creates a NEW subscription)
|
|
13
|
+
*
|
|
14
|
+
* Terminal states stay terminal on purpose: "reactivating" a cancelled row would resurrect its
|
|
15
|
+
* history, overrides and period as if nothing happened. A fresh subscription is honest about the
|
|
16
|
+
* gap and keeps the audit trail of the old one intact.
|
|
17
|
+
*
|
|
18
|
+
* Upgrade/downgrade are TRANSITIONS OF PLAN, not of status — a plan change happens on a live
|
|
19
|
+
* subscription and does not appear here.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const SUBSCRIPTION_STATUSES = Object.freeze([
|
|
23
|
+
"trialing", "active", "past_due", "blocked", "cancelled", "expired",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/** States in which entitlements resolve. past_due keeps working — cutting access the moment a
|
|
27
|
+
* payment is late loses more than it protects; `blocked` is the deliberate cut-off. */
|
|
28
|
+
export const LIVE_STATUSES = Object.freeze(["trialing", "active", "past_due"]);
|
|
29
|
+
|
|
30
|
+
export const TRANSITIONS = Object.freeze({
|
|
31
|
+
trialing: ["active", "cancelled", "expired"],
|
|
32
|
+
// `expired` because a FIXED-TERM subscription that reaches its end has simply run out. Without
|
|
33
|
+
// it the only exits are `blocked`, which implies a deliberate cut-off, and `cancelled`, which
|
|
34
|
+
// implies the customer chose to leave — neither is true of a 90-day plan reaching day 91.
|
|
35
|
+
active: ["past_due", "blocked", "cancelled", "expired"],
|
|
36
|
+
past_due: ["active", "blocked", "cancelled"],
|
|
37
|
+
blocked: ["active", "cancelled"],
|
|
38
|
+
cancelled: [],
|
|
39
|
+
expired: [],
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** `{ ok }` or `{ ok: false, message }` naming the legal moves — the caller shows it verbatim. */
|
|
43
|
+
export function canTransition(from, to) {
|
|
44
|
+
const allowed = TRANSITIONS[from];
|
|
45
|
+
if (!allowed) return { ok: false, message: `${from} is not a subscription status` };
|
|
46
|
+
if (from === to) return { ok: false, message: `already ${from}` };
|
|
47
|
+
if (!allowed.includes(to)) {
|
|
48
|
+
return {
|
|
49
|
+
ok: false,
|
|
50
|
+
message: allowed.length
|
|
51
|
+
? `${from} can only move to: ${allowed.join(", ")}`
|
|
52
|
+
: `${from} is terminal — a new purchase creates a new subscription`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return { ok: true };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Event types — the audit spine AND what the Events Engine receives (`subscription.<type>`).
|
|
60
|
+
* Engagement and monitoring both hang off this list, so an unlisted type is an event nobody can
|
|
61
|
+
* subscribe to: recording one is refused rather than silently accepted.
|
|
62
|
+
*/
|
|
63
|
+
export const SUBSCRIPTION_EVENT_TYPES = Object.freeze([
|
|
64
|
+
"created", "trial_started", "activated", "renewed",
|
|
65
|
+
"plan_changed", "cycle_changed", "period_extended",
|
|
66
|
+
/**
|
|
67
|
+
* A term cut short by hand. Its own type rather than a negative `period_extended`, because the
|
|
68
|
+
* two raise opposite money questions — one leaves us owing product, the other owing money — and
|
|
69
|
+
* a stream where they look alike cannot be read for either.
|
|
70
|
+
*/
|
|
71
|
+
"period_shortened",
|
|
72
|
+
/** Somebody looked at a raised billing question and closed it. */
|
|
73
|
+
"billing_review_cleared",
|
|
74
|
+
"override_set", "override_removed",
|
|
75
|
+
"status_changed", "cancelled", "blocked", "expired",
|
|
76
|
+
"payment_captured", "payment_failed",
|
|
77
|
+
"quota_exceeded",
|
|
78
|
+
// Consumption that happened outside the software — a webinar attended, a VPD session held.
|
|
79
|
+
// There is no request behind it, so this event is the only record of who said it happened.
|
|
80
|
+
"usage_recorded",
|
|
81
|
+
/**
|
|
82
|
+
* Credits handed out by an admin, rather than bought or rolled over.
|
|
83
|
+
*
|
|
84
|
+
* It used to be recorded as `override_set`, which made the history unreadable at exactly the
|
|
85
|
+
* moment it mattered: a one-off top-up and a permanent change to somebody's per-period allowance
|
|
86
|
+
* are different promises, and "who gave this partner 500 credits" cannot be answered by a stream
|
|
87
|
+
* where both look identical.
|
|
88
|
+
*/
|
|
89
|
+
"credits_granted",
|
|
90
|
+
/**
|
|
91
|
+
* A term closed with credits unspent and the plan's policy carried them forward. Worth its own
|
|
92
|
+
* type because a customer WILL ask why their balance is bigger than their allowance, and the
|
|
93
|
+
* answer — which period it came from, how much, and what the cap did — has to be findable.
|
|
94
|
+
*/
|
|
95
|
+
"credits_rolled_over",
|
|
96
|
+
/**
|
|
97
|
+
* The refund conversation, recorded as it happens rather than reconstructed afterwards.
|
|
98
|
+
*
|
|
99
|
+
* A refund is the one flow where WHO ASKED and WHO AGREED both matter later — a credit note
|
|
100
|
+
* shows money went back but not that anyone approved it. `refund_requested` is the customer's
|
|
101
|
+
* ask, `refund_rejected` closes it with a reason, and `refunded` is the money actually moving.
|
|
102
|
+
*/
|
|
103
|
+
"refund_requested", "refund_approved", "refund_rejected", "refunded",
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
export default { SUBSCRIPTION_STATUSES, LIVE_STATUSES, TRANSITIONS, canTransition, SUBSCRIPTION_EVENT_TYPES };
|
package/src/tenant-context.js
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tenant request-context — @i4e/invest4edu-access-core (Track 3 D1 keystone).
|
|
3
|
-
*
|
|
4
|
-
* AsyncLocalStorage carries the authenticated identity (`account_id`, `userId`, `roles`)
|
|
5
|
-
* for the lifetime of a request. The tenant plugin (tenant-plugin.js) reads `account_id`
|
|
6
|
-
* from here and injects it into every tenant-scoped read.
|
|
7
|
-
*
|
|
8
|
-
* Store shapes:
|
|
9
|
-
* { account_id, userId, roles } — a normal authenticated request (JWT path)
|
|
10
|
-
* { system: true } — a legitimate cross-tenant/non-request context via runAsSystem()
|
|
11
|
-
* (empty) — no identity. Plugin fails closed in `enforce`, warns in `warn`.
|
|
12
|
-
*/
|
|
13
|
-
import { AsyncLocalStorage } from "async_hooks";
|
|
14
|
-
|
|
15
|
-
const als = new AsyncLocalStorage();
|
|
16
|
-
|
|
17
|
-
/** Run `fn` with the given tenant identity in context. */
|
|
18
|
-
export const runWithTenant = (store, fn) => als.run(store || {}, fn);
|
|
19
|
-
|
|
20
|
-
/** Run `fn` in a system context that bypasses tenant injection (scripts, cross-tenant reads). */
|
|
21
|
-
export const runAsSystem = (fn) => als.run({ system: true }, fn);
|
|
22
|
-
|
|
23
|
-
/** Current tenant store (or undefined outside any run scope). */
|
|
24
|
-
export const getTenantStore = () => als.getStore();
|
|
25
|
-
|
|
26
|
-
export default als;
|
|
1
|
+
/**
|
|
2
|
+
* Tenant request-context — @i4e/invest4edu-access-core (Track 3 D1 keystone).
|
|
3
|
+
*
|
|
4
|
+
* AsyncLocalStorage carries the authenticated identity (`account_id`, `userId`, `roles`)
|
|
5
|
+
* for the lifetime of a request. The tenant plugin (tenant-plugin.js) reads `account_id`
|
|
6
|
+
* from here and injects it into every tenant-scoped read.
|
|
7
|
+
*
|
|
8
|
+
* Store shapes:
|
|
9
|
+
* { account_id, userId, roles } — a normal authenticated request (JWT path)
|
|
10
|
+
* { system: true } — a legitimate cross-tenant/non-request context via runAsSystem()
|
|
11
|
+
* (empty) — no identity. Plugin fails closed in `enforce`, warns in `warn`.
|
|
12
|
+
*/
|
|
13
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
14
|
+
|
|
15
|
+
const als = new AsyncLocalStorage();
|
|
16
|
+
|
|
17
|
+
/** Run `fn` with the given tenant identity in context. */
|
|
18
|
+
export const runWithTenant = (store, fn) => als.run(store || {}, fn);
|
|
19
|
+
|
|
20
|
+
/** Run `fn` in a system context that bypasses tenant injection (scripts, cross-tenant reads). */
|
|
21
|
+
export const runAsSystem = (fn) => als.run({ system: true }, fn);
|
|
22
|
+
|
|
23
|
+
/** Current tenant store (or undefined outside any run scope). */
|
|
24
|
+
export const getTenantStore = () => als.getStore();
|
|
25
|
+
|
|
26
|
+
export default als;
|
package/src/tenant-plugin.js
CHANGED
|
@@ -1,177 +1,177 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tenant-isolation Mongoose plugin — @i4e/invest4edu-access-core (Track 3 D1 keystone).
|
|
3
|
-
*
|
|
4
|
-
* Applied to tenant-scoped models. It injects `account_id` from the request's ALS store
|
|
5
|
-
* (tenant-context.js) into the FILTER of every operation that selects existing documents, so a
|
|
6
|
-
* query cannot reach Mongo without the caller's tenant boundary — even if the controller forgot it.
|
|
7
|
-
*
|
|
8
|
-
* ── TWO LADDERS, ON PURPOSE ──────────────────────────────────────────────────────────────────
|
|
9
|
-
* Reads and writes have SEPARATE switches, each defaulting to `off`:
|
|
10
|
-
*
|
|
11
|
-
* reads `TENANT_ENFORCEMENT` / accessconfig.tenantEnforcement
|
|
12
|
-
* writes `TENANT_ENFORCEMENT_WRITES` / accessconfig.tenantEnforcementWrites
|
|
13
|
-
*
|
|
14
|
-
* They are separate because the blast radius is not comparable. A read that gains a filter
|
|
15
|
-
* returns less data; a write that gains a filter silently modifies NOTHING, and the caller is
|
|
16
|
-
* told it succeeded. Nobody promoting reads to `warn` should discover they also armed writes.
|
|
17
|
-
*
|
|
18
|
-
* Each ladder: off → warn (burn-in, watch `[tenant] identity-less`) → enforce.
|
|
19
|
-
*
|
|
20
|
-
* off (DEFAULT) literal no-op; behaviour identical to pre-Track-3. Safe merge baseline.
|
|
21
|
-
* warn READS: inject when identity present; log identity-less reads.
|
|
22
|
-
* WRITES: log identity-less writes; NEVER alter the filter (see below).
|
|
23
|
-
* enforce inject when identity present; THROW on identity-less (fail-closed).
|
|
24
|
-
*
|
|
25
|
-
* The read/write asymmetry at `warn` is deliberate. `warn` means "tell me what enforce would do",
|
|
26
|
-
* and for a destructive operation that promise is only kept by changing nothing. Injecting on a
|
|
27
|
-
* write under a mode named `warn` would silently turn UPDATEs into no-ops in production — the
|
|
28
|
-
* exact failure the burn-in step exists to avoid. The signal you need before promoting is which
|
|
29
|
-
* writes run identity-less, and that is logged.
|
|
30
|
-
*
|
|
31
|
-
* Precedence per ladder: env var (if valid) > DB value (setTenantMode/setTenantWriteMode) > "off".
|
|
32
|
-
* The DB value is the admin-UI toggle (takes effect on the next config refresh, no restart); the
|
|
33
|
-
* env var is the infra-level emergency override.
|
|
34
|
-
*
|
|
35
|
-
* Bypass: `runAsSystem(fn)` or `.setOptions({ skipTenant: true })` for legitimately cross-tenant
|
|
36
|
-
* operations. NOT covered: aggregation pipelines — add an explicit `{ $match: { account_id } }`.
|
|
37
|
-
*
|
|
38
|
-
* Logging is intentionally `console.warn` (not a repo-specific logger) so this file stays
|
|
39
|
-
* byte-identical across both backends. Warnings surface in stdout logs during burn-in.
|
|
40
|
-
*/
|
|
41
|
-
import als from "./tenant-context.js";
|
|
42
|
-
|
|
43
|
-
/** Reads. Governed by the READ ladder. */
|
|
44
|
-
const READ_OPS = ["find", "findOne", "countDocuments", "distinct"];
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Writes that SELECT existing documents by filter. Governed by the WRITE ladder.
|
|
48
|
-
*
|
|
49
|
-
* `save` / `create` / `insertMany` are absent and cannot be added: they carry no filter. Putting
|
|
50
|
-
* `account_id` ON a new document is the caller's job — this plugin only constrains which EXISTING
|
|
51
|
-
* documents an operation may reach. `estimatedDocumentCount` is absent for the same reason: it
|
|
52
|
-
* takes no filter, so there is nothing to scope.
|
|
53
|
-
*/
|
|
54
|
-
const WRITE_OPS = [
|
|
55
|
-
"updateOne",
|
|
56
|
-
"updateMany",
|
|
57
|
-
"replaceOne",
|
|
58
|
-
"deleteOne",
|
|
59
|
-
"deleteMany",
|
|
60
|
-
"findOneAndUpdate",
|
|
61
|
-
"findOneAndReplace",
|
|
62
|
-
"findOneAndDelete",
|
|
63
|
-
];
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Mongoose registers these names as BOTH document and query middleware, and which one you get by
|
|
67
|
-
* default has moved between major versions (the package supports mongoose >= 6). Pin the query
|
|
68
|
-
* form explicitly. The document form would be wrong here anyway — it fires on an already-loaded
|
|
69
|
-
* document, which was fetched through a read hook and is therefore already scoped.
|
|
70
|
-
*/
|
|
71
|
-
const DUAL_HOOKS = new Set(["updateOne", "deleteOne"]);
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* The enforcement ladder, in order. Exported because both backends validate an incoming mode
|
|
75
|
-
* before storing it and render the choices in the admin UI — without this they each keep their
|
|
76
|
-
* own `["off","warn","enforce"]` literal, and a mode added here would be silently unsettable.
|
|
77
|
-
*/
|
|
78
|
-
export const TENANT_MODES = Object.freeze(["off", "warn", "enforce"]);
|
|
79
|
-
|
|
80
|
-
const VALID = new Set(TENANT_MODES);
|
|
81
|
-
|
|
82
|
-
let dbMode = null; // reads
|
|
83
|
-
let dbWriteMode = null; // writes
|
|
84
|
-
|
|
85
|
-
/** Bad values are reported once each, not once per query — this runs on every operation. */
|
|
86
|
-
const warnedBadValues = new Set();
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Parse a mode from env or DB. Returns null for absent OR unrecognised, so the caller falls
|
|
90
|
-
* through to the next source in the precedence chain.
|
|
91
|
-
*
|
|
92
|
-
* Trimming matters more than it looks: `TENANT_ENFORCEMENT="enforce "` pasted into App Service
|
|
93
|
-
* config, or a stray space in the admin UI, used to fail the VALID check and silently downgrade
|
|
94
|
-
* enforcement to `off` while every screen still read "enforce".
|
|
95
|
-
*/
|
|
96
|
-
function normaliseMode(raw, source) {
|
|
97
|
-
const v = raw == null ? "" : String(raw).trim().toLowerCase();
|
|
98
|
-
if (!v) return null;
|
|
99
|
-
if (VALID.has(v)) return v;
|
|
100
|
-
|
|
101
|
-
const seen = `${source}=${v}`;
|
|
102
|
-
if (!warnedBadValues.has(seen)) {
|
|
103
|
-
warnedBadValues.add(seen);
|
|
104
|
-
// eslint-disable-next-line no-console
|
|
105
|
-
console.warn(
|
|
106
|
-
`[tenant] unrecognised enforcement mode ${seen} — expected ${[...VALID].join(" | ")}; ignoring this source`,
|
|
107
|
-
);
|
|
108
|
-
}
|
|
109
|
-
return null;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Called by the backend from the `accessconfig` DB doc so a UI toggle takes effect (~refresh). */
|
|
113
|
-
export function setTenantMode(m) {
|
|
114
|
-
dbMode = normaliseMode(m, "accessconfig.tenantEnforcement");
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Effective READ mode. */
|
|
118
|
-
export function getTenantMode() {
|
|
119
|
-
return normaliseMode(process.env.TENANT_ENFORCEMENT, "TENANT_ENFORCEMENT") || dbMode || "off";
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** Write-ladder counterpart of setTenantMode. */
|
|
123
|
-
export function setTenantWriteMode(m) {
|
|
124
|
-
dbWriteMode = normaliseMode(m, "accessconfig.tenantEnforcementWrites");
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** Effective WRITE mode. Independent of the read mode — never inherits it. */
|
|
128
|
-
export function getTenantWriteMode() {
|
|
129
|
-
return (
|
|
130
|
-
normaliseMode(process.env.TENANT_ENFORCEMENT_WRITES, "TENANT_ENFORCEMENT_WRITES") ||
|
|
131
|
-
dbWriteMode ||
|
|
132
|
-
"off"
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* @param {() => string} getMode the ladder this op answers to
|
|
138
|
-
* @param {string} opName fallback for `this.op`
|
|
139
|
-
* @param {"read"|"write"} kind
|
|
140
|
-
*/
|
|
141
|
-
function makeTenantHook(getMode, opName, kind) {
|
|
142
|
-
return function tenantHook() {
|
|
143
|
-
const mode = getMode();
|
|
144
|
-
if (mode === "off") return; // literal no-op — identical to pre-Track-3
|
|
145
|
-
|
|
146
|
-
const opts = typeof this.getOptions === "function" ? this.getOptions() : this.options || {};
|
|
147
|
-
if (opts && opts.skipTenant) return;
|
|
148
|
-
|
|
149
|
-
const store = als.getStore();
|
|
150
|
-
if (store && store.system) return; // runAsSystem — cross-tenant by design
|
|
151
|
-
|
|
152
|
-
if (store && store.account_id) {
|
|
153
|
-
// A write under `warn` is observed, never altered. See the ladder note at the top.
|
|
154
|
-
if (kind === "write" && mode === "warn") return;
|
|
155
|
-
this.where({ account_id: store.account_id }); // identity is authoritative
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const modelName = (this.model && this.model.modelName) || "unknown";
|
|
160
|
-
const op = this.op || opName;
|
|
161
|
-
if (mode === "enforce") {
|
|
162
|
-
throw new Error(`tenant identity required — no account_id in context for ${modelName}.${op}`);
|
|
163
|
-
}
|
|
164
|
-
// eslint-disable-next-line no-console
|
|
165
|
-
console.warn(`[tenant] identity-less ${kind} ${modelName}.${op} not scoped (warn mode)`);
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
export default function tenantPlugin(schema) {
|
|
170
|
-
READ_OPS.forEach((op) => schema.pre(op, makeTenantHook(getTenantMode, op, "read")));
|
|
171
|
-
|
|
172
|
-
WRITE_OPS.forEach((op) => {
|
|
173
|
-
const hook = makeTenantHook(getTenantWriteMode, op, "write");
|
|
174
|
-
if (DUAL_HOOKS.has(op)) schema.pre(op, { document: false, query: true }, hook);
|
|
175
|
-
else schema.pre(op, hook);
|
|
176
|
-
});
|
|
177
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Tenant-isolation Mongoose plugin — @i4e/invest4edu-access-core (Track 3 D1 keystone).
|
|
3
|
+
*
|
|
4
|
+
* Applied to tenant-scoped models. It injects `account_id` from the request's ALS store
|
|
5
|
+
* (tenant-context.js) into the FILTER of every operation that selects existing documents, so a
|
|
6
|
+
* query cannot reach Mongo without the caller's tenant boundary — even if the controller forgot it.
|
|
7
|
+
*
|
|
8
|
+
* ── TWO LADDERS, ON PURPOSE ──────────────────────────────────────────────────────────────────
|
|
9
|
+
* Reads and writes have SEPARATE switches, each defaulting to `off`:
|
|
10
|
+
*
|
|
11
|
+
* reads `TENANT_ENFORCEMENT` / accessconfig.tenantEnforcement
|
|
12
|
+
* writes `TENANT_ENFORCEMENT_WRITES` / accessconfig.tenantEnforcementWrites
|
|
13
|
+
*
|
|
14
|
+
* They are separate because the blast radius is not comparable. A read that gains a filter
|
|
15
|
+
* returns less data; a write that gains a filter silently modifies NOTHING, and the caller is
|
|
16
|
+
* told it succeeded. Nobody promoting reads to `warn` should discover they also armed writes.
|
|
17
|
+
*
|
|
18
|
+
* Each ladder: off → warn (burn-in, watch `[tenant] identity-less`) → enforce.
|
|
19
|
+
*
|
|
20
|
+
* off (DEFAULT) literal no-op; behaviour identical to pre-Track-3. Safe merge baseline.
|
|
21
|
+
* warn READS: inject when identity present; log identity-less reads.
|
|
22
|
+
* WRITES: log identity-less writes; NEVER alter the filter (see below).
|
|
23
|
+
* enforce inject when identity present; THROW on identity-less (fail-closed).
|
|
24
|
+
*
|
|
25
|
+
* The read/write asymmetry at `warn` is deliberate. `warn` means "tell me what enforce would do",
|
|
26
|
+
* and for a destructive operation that promise is only kept by changing nothing. Injecting on a
|
|
27
|
+
* write under a mode named `warn` would silently turn UPDATEs into no-ops in production — the
|
|
28
|
+
* exact failure the burn-in step exists to avoid. The signal you need before promoting is which
|
|
29
|
+
* writes run identity-less, and that is logged.
|
|
30
|
+
*
|
|
31
|
+
* Precedence per ladder: env var (if valid) > DB value (setTenantMode/setTenantWriteMode) > "off".
|
|
32
|
+
* The DB value is the admin-UI toggle (takes effect on the next config refresh, no restart); the
|
|
33
|
+
* env var is the infra-level emergency override.
|
|
34
|
+
*
|
|
35
|
+
* Bypass: `runAsSystem(fn)` or `.setOptions({ skipTenant: true })` for legitimately cross-tenant
|
|
36
|
+
* operations. NOT covered: aggregation pipelines — add an explicit `{ $match: { account_id } }`.
|
|
37
|
+
*
|
|
38
|
+
* Logging is intentionally `console.warn` (not a repo-specific logger) so this file stays
|
|
39
|
+
* byte-identical across both backends. Warnings surface in stdout logs during burn-in.
|
|
40
|
+
*/
|
|
41
|
+
import als from "./tenant-context.js";
|
|
42
|
+
|
|
43
|
+
/** Reads. Governed by the READ ladder. */
|
|
44
|
+
const READ_OPS = ["find", "findOne", "countDocuments", "distinct"];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Writes that SELECT existing documents by filter. Governed by the WRITE ladder.
|
|
48
|
+
*
|
|
49
|
+
* `save` / `create` / `insertMany` are absent and cannot be added: they carry no filter. Putting
|
|
50
|
+
* `account_id` ON a new document is the caller's job — this plugin only constrains which EXISTING
|
|
51
|
+
* documents an operation may reach. `estimatedDocumentCount` is absent for the same reason: it
|
|
52
|
+
* takes no filter, so there is nothing to scope.
|
|
53
|
+
*/
|
|
54
|
+
const WRITE_OPS = [
|
|
55
|
+
"updateOne",
|
|
56
|
+
"updateMany",
|
|
57
|
+
"replaceOne",
|
|
58
|
+
"deleteOne",
|
|
59
|
+
"deleteMany",
|
|
60
|
+
"findOneAndUpdate",
|
|
61
|
+
"findOneAndReplace",
|
|
62
|
+
"findOneAndDelete",
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Mongoose registers these names as BOTH document and query middleware, and which one you get by
|
|
67
|
+
* default has moved between major versions (the package supports mongoose >= 6). Pin the query
|
|
68
|
+
* form explicitly. The document form would be wrong here anyway — it fires on an already-loaded
|
|
69
|
+
* document, which was fetched through a read hook and is therefore already scoped.
|
|
70
|
+
*/
|
|
71
|
+
const DUAL_HOOKS = new Set(["updateOne", "deleteOne"]);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The enforcement ladder, in order. Exported because both backends validate an incoming mode
|
|
75
|
+
* before storing it and render the choices in the admin UI — without this they each keep their
|
|
76
|
+
* own `["off","warn","enforce"]` literal, and a mode added here would be silently unsettable.
|
|
77
|
+
*/
|
|
78
|
+
export const TENANT_MODES = Object.freeze(["off", "warn", "enforce"]);
|
|
79
|
+
|
|
80
|
+
const VALID = new Set(TENANT_MODES);
|
|
81
|
+
|
|
82
|
+
let dbMode = null; // reads
|
|
83
|
+
let dbWriteMode = null; // writes
|
|
84
|
+
|
|
85
|
+
/** Bad values are reported once each, not once per query — this runs on every operation. */
|
|
86
|
+
const warnedBadValues = new Set();
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parse a mode from env or DB. Returns null for absent OR unrecognised, so the caller falls
|
|
90
|
+
* through to the next source in the precedence chain.
|
|
91
|
+
*
|
|
92
|
+
* Trimming matters more than it looks: `TENANT_ENFORCEMENT="enforce "` pasted into App Service
|
|
93
|
+
* config, or a stray space in the admin UI, used to fail the VALID check and silently downgrade
|
|
94
|
+
* enforcement to `off` while every screen still read "enforce".
|
|
95
|
+
*/
|
|
96
|
+
function normaliseMode(raw, source) {
|
|
97
|
+
const v = raw == null ? "" : String(raw).trim().toLowerCase();
|
|
98
|
+
if (!v) return null;
|
|
99
|
+
if (VALID.has(v)) return v;
|
|
100
|
+
|
|
101
|
+
const seen = `${source}=${v}`;
|
|
102
|
+
if (!warnedBadValues.has(seen)) {
|
|
103
|
+
warnedBadValues.add(seen);
|
|
104
|
+
// eslint-disable-next-line no-console
|
|
105
|
+
console.warn(
|
|
106
|
+
`[tenant] unrecognised enforcement mode ${seen} — expected ${[...VALID].join(" | ")}; ignoring this source`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Called by the backend from the `accessconfig` DB doc so a UI toggle takes effect (~refresh). */
|
|
113
|
+
export function setTenantMode(m) {
|
|
114
|
+
dbMode = normaliseMode(m, "accessconfig.tenantEnforcement");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Effective READ mode. */
|
|
118
|
+
export function getTenantMode() {
|
|
119
|
+
return normaliseMode(process.env.TENANT_ENFORCEMENT, "TENANT_ENFORCEMENT") || dbMode || "off";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Write-ladder counterpart of setTenantMode. */
|
|
123
|
+
export function setTenantWriteMode(m) {
|
|
124
|
+
dbWriteMode = normaliseMode(m, "accessconfig.tenantEnforcementWrites");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Effective WRITE mode. Independent of the read mode — never inherits it. */
|
|
128
|
+
export function getTenantWriteMode() {
|
|
129
|
+
return (
|
|
130
|
+
normaliseMode(process.env.TENANT_ENFORCEMENT_WRITES, "TENANT_ENFORCEMENT_WRITES") ||
|
|
131
|
+
dbWriteMode ||
|
|
132
|
+
"off"
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* @param {() => string} getMode the ladder this op answers to
|
|
138
|
+
* @param {string} opName fallback for `this.op`
|
|
139
|
+
* @param {"read"|"write"} kind
|
|
140
|
+
*/
|
|
141
|
+
function makeTenantHook(getMode, opName, kind) {
|
|
142
|
+
return function tenantHook() {
|
|
143
|
+
const mode = getMode();
|
|
144
|
+
if (mode === "off") return; // literal no-op — identical to pre-Track-3
|
|
145
|
+
|
|
146
|
+
const opts = typeof this.getOptions === "function" ? this.getOptions() : this.options || {};
|
|
147
|
+
if (opts && opts.skipTenant) return;
|
|
148
|
+
|
|
149
|
+
const store = als.getStore();
|
|
150
|
+
if (store && store.system) return; // runAsSystem — cross-tenant by design
|
|
151
|
+
|
|
152
|
+
if (store && store.account_id) {
|
|
153
|
+
// A write under `warn` is observed, never altered. See the ladder note at the top.
|
|
154
|
+
if (kind === "write" && mode === "warn") return;
|
|
155
|
+
this.where({ account_id: store.account_id }); // identity is authoritative
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const modelName = (this.model && this.model.modelName) || "unknown";
|
|
160
|
+
const op = this.op || opName;
|
|
161
|
+
if (mode === "enforce") {
|
|
162
|
+
throw new Error(`tenant identity required — no account_id in context for ${modelName}.${op}`);
|
|
163
|
+
}
|
|
164
|
+
// eslint-disable-next-line no-console
|
|
165
|
+
console.warn(`[tenant] identity-less ${kind} ${modelName}.${op} not scoped (warn mode)`);
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export default function tenantPlugin(schema) {
|
|
170
|
+
READ_OPS.forEach((op) => schema.pre(op, makeTenantHook(getTenantMode, op, "read")));
|
|
171
|
+
|
|
172
|
+
WRITE_OPS.forEach((op) => {
|
|
173
|
+
const hook = makeTenantHook(getTenantWriteMode, op, "write");
|
|
174
|
+
if (DUAL_HOOKS.has(op)) schema.pre(op, { document: false, query: true }, hook);
|
|
175
|
+
else schema.pre(op, hook);
|
|
176
|
+
});
|
|
177
|
+
}
|