@i4e/invest4edu-access-core 0.1.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 ADDED
@@ -0,0 +1,60 @@
1
+ # @i4e/invest4edu-access-core
2
+
3
+ Shared tenant-isolation / access-control primitives for the NeoFindesk backends
4
+ (nfd-api-node v1 + nfd-api-node-v2). The **D1 keystone** of the access-management epic:
5
+ central, unforgettable `account_id` scoping.
6
+
7
+ Both backends are ESM, so this package ships plain ESM source — no build step.
8
+
9
+ ## Install
10
+
11
+ ```
12
+ npm install @i4e/invest4edu-access-core
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ **1. Carry identity per request** (in the auth middleware, JWT path only):
18
+
19
+ ```js
20
+ import als from "@i4e/invest4edu-access-core/tenant-context";
21
+ // after verifying the JWT:
22
+ return als.run({ account_id, userId, roles }, () => next());
23
+ ```
24
+
25
+ **2. Enforce on tenant-scoped models:**
26
+
27
+ ```js
28
+ import { tenantPlugin } from "@i4e/invest4edu-access-core";
29
+ schema.plugin(tenantPlugin); // before mongoose.model(...)
30
+ ```
31
+
32
+ The plugin injects `account_id` from the request's ALS store into every
33
+ `find` / `findOne` / `countDocuments`. Mode via `TENANT_ENFORCEMENT`:
34
+
35
+ | mode | identity present | identity absent |
36
+ |---|---|---|
37
+ | `warn` (default) | inject `account_id` | log `[tenant] identity-less …`, do **not** scope |
38
+ | `enforce` | inject `account_id` | **throw** (fail-closed) |
39
+
40
+ **3. Bypass for legitimately cross-tenant reads** (caches, migrations, scripts):
41
+
42
+ ```js
43
+ import { runAsSystem } from "@i4e/invest4edu-access-core";
44
+ await runAsSystem(() => Employee.find({ status: 1 })); // whole scope
45
+ Model.find(q).setOptions({ skipTenant: true }); // one query
46
+ ```
47
+
48
+ ## Exports
49
+
50
+ - `als` (default of `/tenant-context`) — the AsyncLocalStorage instance
51
+ - `runWithTenant(store, fn)`, `runAsSystem(fn)`, `getTenantStore()`
52
+ - `tenantPlugin` (default of `/tenant-plugin`) — the Mongoose plugin
53
+
54
+ ## 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).
58
+
59
+ ## Compatibility
60
+ Node ≥ 18 (AsyncLocalStorage). `mongoose` ≥ 6 is an optional peer (only the plugin needs it).
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@i4e/invest4edu-access-core",
3
+ "version": "0.1.0",
4
+ "description": "Shared tenant-isolation / access-control primitives (Track 3 D1 keystone) for NeoFindesk backends.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.js",
8
+ "./tenant-context": "./src/tenant-context.js",
9
+ "./tenant-plugin": "./src/tenant-plugin.js"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "sideEffects": false,
19
+ "keywords": [
20
+ "nfd",
21
+ "access-control",
22
+ "tenant-isolation",
23
+ "mongoose",
24
+ "async-local-storage"
25
+ ],
26
+ "peerDependencies": {
27
+ "mongoose": ">=6"
28
+ },
29
+ "peerDependenciesMeta": {
30
+ "mongoose": {
31
+ "optional": true
32
+ }
33
+ },
34
+ "license": "UNLICENSED",
35
+ "private": false
36
+ }
package/src/index.js ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Access-core barrel (Track 3 C1). CANONICAL SHARED — keep identical in both backends.
3
+ * The tenant-enforcement primitives authored once and vendored into v1 + v2.
4
+ */
5
+ export { default as als, runWithTenant, runAsSystem, getTenantStore } from "./tenant-context.js";
6
+ export { default as tenantPlugin } from "./tenant-plugin.js";
@@ -0,0 +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;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Tenant-isolation Mongoose plugin — @i4e/invest4edu-access-core (Track 3 D1 keystone).
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.
7
+ *
8
+ * Modes (env `TENANT_ENFORCEMENT`, default `warn`):
9
+ * - identity present → inject `account_id` unconditionally (BOTH modes)
10
+ * - identity absent → `enforce`: THROW (fail-closed) · `warn`: log + do not scope (burn-in)
11
+ *
12
+ * Bypass: `runAsSystem(fn)` or `.setOptions({ skipTenant: true })` for legitimately
13
+ * cross-tenant reads. NOT covered: aggregation pipelines and write ops (reads are C0 scope).
14
+ *
15
+ * Logging is intentionally `console.warn` (not a repo-specific logger) so this file stays
16
+ * byte-identical across both backends. Warnings surface in stdout logs during burn-in.
17
+ */
18
+ import als from "./tenant-context.js";
19
+
20
+ const READ_OPS = ["find", "findOne", "countDocuments"];
21
+ const mode = () => (process.env.TENANT_ENFORCEMENT || "warn").toLowerCase();
22
+
23
+ function tenantHook() {
24
+ const opts = typeof this.getOptions === "function" ? this.getOptions() : this.options || {};
25
+ if (opts && opts.skipTenant) return;
26
+
27
+ const store = als.getStore();
28
+ if (store && store.system) return; // runAsSystem — cross-tenant by design
29
+
30
+ if (store && store.account_id) {
31
+ this.where({ account_id: store.account_id }); // identity is authoritative
32
+ return;
33
+ }
34
+
35
+ const modelName = (this.model && this.model.modelName) || "unknown";
36
+ if (mode() === "enforce") {
37
+ throw new Error(`tenant identity required — no account_id in context for ${modelName}.${this.op}`);
38
+ }
39
+ // eslint-disable-next-line no-console
40
+ console.warn(`[tenant] identity-less ${modelName}.${this.op} not scoped (warn mode)`);
41
+ }
42
+
43
+ export default function tenantPlugin(schema) {
44
+ READ_OPS.forEach((op) => schema.pre(op, tenantHook));
45
+ }