@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 +60 -0
- package/package.json +36 -0
- package/src/index.js +6 -0
- package/src/tenant-context.js +26 -0
- package/src/tenant-plugin.js +45 -0
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
|
+
}
|