@i4e/invest4edu-access-core 0.28.0 → 0.29.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 +33 -7
- package/package.json +2 -2
- package/src/index.js +8 -1
- package/src/tenant-plugin.js +148 -40
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
|
-
|
|
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
|
-
| `
|
|
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
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
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.
|
|
3
|
+
"version": "0.29.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",
|
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 {
|
|
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,
|
package/src/tenant-plugin.js
CHANGED
|
@@ -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.
|
|
5
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
23
|
-
const
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
146
|
+
const opts = typeof this.getOptions === "function" ? this.getOptions() : this.options || {};
|
|
147
|
+
if (opts && opts.skipTenant) return;
|
|
50
148
|
|
|
51
|
-
|
|
52
|
-
|
|
149
|
+
const store = als.getStore();
|
|
150
|
+
if (store && store.system) return; // runAsSystem — cross-tenant by design
|
|
53
151
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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,
|
|
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
|
}
|