@i4e/invest4edu-access-core 0.28.0 → 0.30.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 CHANGED
@@ -29,15 +29,35 @@ import { tenantPlugin } from "@i4e/invest4edu-access-core";
29
29
  schema.plugin(tenantPlugin); // before mongoose.model(...)
30
30
  ```
31
31
 
32
- The plugin injects `account_id` from the request's ALS store into every
33
- `find` / `findOne` / `countDocuments`. Mode via `TENANT_ENFORCEMENT`:
32
+ The plugin injects `account_id` from the request's ALS store into the **filter** of every
33
+ operation that selects existing documents:
34
+
35
+ | | ops | switch |
36
+ |---|---|---|
37
+ | reads | `find` `findOne` `countDocuments` `distinct` | `TENANT_ENFORCEMENT` / `accessconfig.tenantEnforcement` |
38
+ | writes | `updateOne` `updateMany` `replaceOne` `deleteOne` `deleteMany` `findOneAndUpdate` `findOneAndReplace` `findOneAndDelete` | `TENANT_ENFORCEMENT_WRITES` / `accessconfig.tenantEnforcementWrites` |
39
+
40
+ Each switch is an independent ladder — `off` → `warn` → `enforce` — and **both default to `off`**,
41
+ so installing the plugin is a literal no-op until you arm it. Reads and writes are separate
42
+ because the blast radius is not comparable: a read that gains a filter returns less data, while a
43
+ write that gains a filter silently modifies **nothing** and still reports success. Arming reads
44
+ must never arm writes by surprise.
34
45
 
35
46
  | mode | identity present | identity absent |
36
47
  |---|---|---|
37
- | `warn` (default) | inject `account_id` | log `[tenant] identity-less …`, do **not** scope |
48
+ | `off` (default) | no-op | no-op |
49
+ | `warn` | reads: inject `account_id` · writes: **unchanged** | log `[tenant] identity-less …`, do **not** scope |
38
50
  | `enforce` | inject `account_id` | **throw** (fail-closed) |
39
51
 
40
- **3. Bypass for legitimately cross-tenant reads** (caches, migrations, scripts):
52
+ `warn` deliberately leaves writes alone. `warn` means "tell me what `enforce` would do", and for a
53
+ destructive operation that promise is only kept by changing nothing — injecting a filter under a
54
+ mode named `warn` would turn live `UPDATE`s into silent no-ops, the exact failure burn-in exists to
55
+ catch. What you need before promoting is which writes run identity-less, and that is logged.
56
+
57
+ Precedence per ladder: env var (if valid) → DB value → `off`. An unrecognised value is reported
58
+ once and ignored rather than treated as `off`, so a typo cannot silently disarm enforcement.
59
+
60
+ **3. Bypass for legitimately cross-tenant operations** (caches, migrations, scripts):
41
61
 
42
62
  ```js
43
63
  import { runAsSystem } from "@i4e/invest4edu-access-core";
@@ -50,11 +70,17 @@ Model.find(q).setOptions({ skipTenant: true }); // one query
50
70
  - `als` (default of `/tenant-context`) — the AsyncLocalStorage instance
51
71
  - `runWithTenant(store, fn)`, `runAsSystem(fn)`, `getTenantStore()`
52
72
  - `tenantPlugin` (default of `/tenant-plugin`) — the Mongoose plugin
73
+ - `setTenantMode(m)` / `getTenantMode()` — the read ladder
74
+ - `setTenantWriteMode(m)` / `getTenantWriteMode()` — the write ladder
53
75
 
54
76
  ## Not covered
55
- Aggregation pipelines (add an explicit `{ $match: { account_id } }` stage) and write
56
- ops — reads are the C0 scope. Visibility/delegation/role-classification land in later
57
- minor versions (Track 3 C1).
77
+ **Aggregation pipelines** — add an explicit `{ $match: { account_id } }` stage.
78
+
79
+ **Inserts** (`save`, `create`, `insertMany`) — and they cannot be. They carry no filter, so there
80
+ is nothing to constrain; putting `account_id` *on* a new document is the caller's job. The plugin
81
+ only governs which **existing** documents an operation may reach.
82
+
83
+ `estimatedDocumentCount` takes no filter either, so it is not hooked.
58
84
 
59
85
  ## Compatibility
60
86
  Node ≥ 18 (AsyncLocalStorage). `mongoose` ≥ 6 is an optional peer (only the plugin needs it).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@i4e/invest4edu-access-core",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "Shared access-control primitives for NeoFindesk: tenant keystone, role capabilities, reportee tree, feature flags, and the unified access engine (registry schema, snapshot resolver, visibleWhen).",
5
5
  "type": "module",
6
6
  "exports": {
@@ -23,7 +23,7 @@
23
23
  "./credits": "./src/credits.js"
24
24
  },
25
25
  "scripts": {
26
- "test": "node --test test/"
26
+ "test": "node --test test/*.test.mjs test/*.test.js"
27
27
  },
28
28
  "files": [
29
29
  "src",
@@ -210,8 +210,28 @@ export function createEntitlementStore({
210
210
  ? { ...creditRows[0], quota: result.quota, overage_policy: result.overage_policy || creditRows[0].overage_policy }
211
211
  : null;
212
212
 
213
+ /**
214
+ * A CEILING survives credits mode.
215
+ *
216
+ * `limit` shapes a response — "your list shows 5" — and is read on every request without
217
+ * ever being spent. A PRICE is what a use costs. They are orthogonal, and the two live on
218
+ * different fields for exactly that reason.
219
+ *
220
+ * Without this line the credits branch returned the wallet's numbers and no `limit` at
221
+ * all, so a consumer reading `ent.limit` (NFD AI's stock-tips shaper does) saw nothing
222
+ * and applied no ceiling. A free plan promising five stock ideas silently served every
223
+ * one of them — the paywall looked configured and was not.
224
+ *
225
+ * Same UNSET-vs-0 care as count mode: null means "no ceiling", 0 means "show nothing".
226
+ */
227
+ const rawCeiling = svcRow.quota;
228
+ const ceiling = rawCeiling === null || rawCeiling === undefined || rawCeiling === ""
229
+ ? null
230
+ : Number(rawCeiling);
231
+
213
232
  return {
214
233
  ...result,
234
+ limit: ceiling !== null && Number.isFinite(ceiling) && ceiling >= 0 ? ceiling : null,
215
235
  mode: "credits",
216
236
  carriedSubjectType: subjectType,
217
237
  carriedSubjectId: subjectId,
package/src/index.js CHANGED
@@ -3,7 +3,14 @@
3
3
  * Tenant isolation (D1 keystone) + role capabilities (G7).
4
4
  */
5
5
  export { default as als, runWithTenant, runAsSystem, getTenantStore } from "./tenant-context.js";
6
- export { default as tenantPlugin, setTenantMode, getTenantMode } from "./tenant-plugin.js";
6
+ export {
7
+ default as tenantPlugin,
8
+ setTenantMode,
9
+ getTenantMode,
10
+ setTenantWriteMode,
11
+ getTenantWriteMode,
12
+ TENANT_MODES,
13
+ } from "./tenant-plugin.js";
7
14
  export {
8
15
  CAPABILITY_FLAGS,
9
16
  DEFAULT_ROLE_CAPABILITIES,
@@ -1,69 +1,177 @@
1
1
  /**
2
2
  * Tenant-isolation Mongoose plugin — @i4e/invest4edu-access-core (Track 3 D1 keystone).
3
3
  *
4
- * Applied to tenant-scoped models. On every read (`find`/`findOne`/`countDocuments`) it
5
- * injects `account_id` from the request's ALS store (tenant-context.js), so a query cannot
6
- * reach Mongo without the caller's tenant boundary — even if the controller forgot it.
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
7
  *
8
- * Modes (env `TENANT_ENFORCEMENT`, default `off`):
9
- * - `off` (DEFAULT) → literal no-op; behaviour identical to pre-Track-3. Safe merge baseline.
10
- * - `warn` → inject `account_id` when identity present; log identity-less reads.
11
- * - `enforce` → inject when present; THROW on identity-less reads (fail-closed).
12
- * Rollout ladder: off → warn (burn-in, watch `[tenant] identity-less`) → enforce.
8
+ * ── TWO LADDERS, ON PURPOSE ──────────────────────────────────────────────────────────────────
9
+ * Reads and writes have SEPARATE switches, each defaulting to `off`:
13
10
  *
14
- * Bypass: `runAsSystem(fn)` or `.setOptions({ skipTenant: true })` for legitimately
15
- * cross-tenant reads. NOT covered: aggregation pipelines and write ops (reads are C0 scope).
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 } }`.
16
37
  *
17
38
  * Logging is intentionally `console.warn` (not a repo-specific logger) so this file stays
18
39
  * byte-identical across both backends. Warnings surface in stdout logs during burn-in.
19
40
  */
20
41
  import als from "./tenant-context.js";
21
42
 
22
- const READ_OPS = ["find", "findOne", "countDocuments"];
23
- const VALID = new Set(["off", "warn", "enforce"]);
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);
24
81
 
25
- // Enforcement mode is resolvable from TWO places, so it can be flipped from the admin UI
26
- // (DB) WITHOUT a restart, while the env var stays the infra-level emergency override.
27
- // Precedence: env TENANT_ENFORCEMENT (if valid) > DB value (setTenantMode) > "off"
28
- let dbMode = null;
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
+ }
29
111
 
30
112
  /** Called by the backend from the `accessconfig` DB doc so a UI toggle takes effect (~refresh). */
31
113
  export function setTenantMode(m) {
32
- const v = m ? String(m).toLowerCase() : "";
33
- dbMode = VALID.has(v) ? v : null;
114
+ dbMode = normaliseMode(m, "accessconfig.tenantEnforcement");
34
115
  }
35
116
 
36
- /** Effective mode. */
117
+ /** Effective READ mode. */
37
118
  export function getTenantMode() {
38
- const env = (process.env.TENANT_ENFORCEMENT || "").toLowerCase();
39
- if (VALID.has(env)) return env; // explicit env wins (emergency / infra)
40
- return dbMode || "off";
119
+ return normaliseMode(process.env.TENANT_ENFORCEMENT, "TENANT_ENFORCEMENT") || dbMode || "off";
41
120
  }
42
121
 
43
- const mode = getTenantMode;
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
+ }
44
135
 
45
- function tenantHook() {
46
- if (mode() === "off") return; // literal no-op — identical to pre-Track-3
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
47
145
 
48
- const opts = typeof this.getOptions === "function" ? this.getOptions() : this.options || {};
49
- if (opts && opts.skipTenant) return;
146
+ const opts = typeof this.getOptions === "function" ? this.getOptions() : this.options || {};
147
+ if (opts && opts.skipTenant) return;
50
148
 
51
- const store = als.getStore();
52
- if (store && store.system) return; // runAsSystem — cross-tenant by design
149
+ const store = als.getStore();
150
+ if (store && store.system) return; // runAsSystem — cross-tenant by design
53
151
 
54
- if (store && store.account_id) {
55
- this.where({ account_id: store.account_id }); // identity is authoritative
56
- return;
57
- }
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
+ }
58
158
 
59
- const modelName = (this.model && this.model.modelName) || "unknown";
60
- if (mode() === "enforce") {
61
- throw new Error(`tenant identity required — no account_id in context for ${modelName}.${this.op}`);
62
- }
63
- // eslint-disable-next-line no-console
64
- console.warn(`[tenant] identity-less ${modelName}.${this.op} not scoped (warn mode)`);
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
+ };
65
167
  }
66
168
 
67
169
  export default function tenantPlugin(schema) {
68
- READ_OPS.forEach((op) => schema.pre(op, tenantHook));
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
+ });
69
177
  }