@i4e/invest4edu-access-core 0.3.0 → 0.4.1

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/package.json CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "@i4e/invest4edu-access-core",
3
- "version": "0.3.0",
4
- "description": "Shared tenant-isolation / access-control primitives (Track 3 D1 keystone + role capabilities + reportee tree) for NeoFindesk backends.",
3
+ "version": "0.4.1",
4
+ "description": "Shared access-control primitives (Track 3: tenant keystone, role capabilities, reportee tree, feature flags) for NeoFindesk backends.",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./src/index.js",
8
8
  "./tenant-context": "./src/tenant-context.js",
9
9
  "./tenant-plugin": "./src/tenant-plugin.js",
10
10
  "./role-capabilities": "./src/role-capabilities.js",
11
- "./reportee-tree": "./src/reportee-tree.js"
11
+ "./reportee-tree": "./src/reportee-tree.js",
12
+ "./access-config": "./src/access-config.js"
12
13
  },
13
14
  "files": [
14
15
  "src",
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Access feature flags — @i4e/invest4edu-access-core (Track 3 master kill-switch).
3
+ *
4
+ * A single place to toggle the newer, riskier access behaviours on/off WITHOUT a code deploy,
5
+ * so a rollout can ship "off" (legacy behaviour) and be flipped on — or killed instantly if it
6
+ * misbehaves. Precedence (lowest → highest):
7
+ *
8
+ * DEFAULT_ACCESS_FLAGS < DB (`accessconfig` collection) < env var
9
+ *
10
+ * DB toggles propagate in ~60s via the backend helper's background refresh (edit in the admin
11
+ * UI, no restart). The env var is the emergency kill: set `ACCESS_FLAG_<UPPER_SNAKE>=off` on the
12
+ * App Service and restart to force a flag regardless of DB.
13
+ *
14
+ * ALL defaults are the SAFE/legacy value — turning a flag on is an explicit, reversible act.
15
+ */
16
+
17
+ export const DEFAULT_ACCESS_FLAGS = Object.freeze({
18
+ // #1 — v2 client-lead reads use the canonical 5-field visibility engine (else legacy lead-scope).
19
+ v2LeadFiveFieldVisibility: false,
20
+ // M1 — send grant/revoke emails via the comms service.
21
+ delegationEmailNotifications: false,
22
+ // H3 — write an access-event when giver-widening actually broadened a delegate's result.
23
+ delegationReadAudit: false,
24
+ // H1 — enable the "acting-as" session path (x-acting-as-giver).
25
+ delegationActingAs: false,
26
+ });
27
+
28
+ const ENV_PREFIX = "ACCESS_FLAG_";
29
+
30
+ /** camelCase flag key → env var name, e.g. v2LeadFiveFieldVisibility → ACCESS_FLAG_V2_LEAD_FIVE_FIELD_VISIBILITY */
31
+ export function flagEnvName(key) {
32
+ return ENV_PREFIX + key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase();
33
+ }
34
+
35
+ export const ACCESS_FLAG_ENV_NAMES = Object.freeze(
36
+ Object.fromEntries(Object.keys(DEFAULT_ACCESS_FLAGS).map((k) => [k, flagEnvName(k)])),
37
+ );
38
+
39
+ function toBool(v) {
40
+ if (typeof v === "boolean") return v;
41
+ if (v == null || v === "") return undefined;
42
+ return /^(1|true|on|yes|enabled)$/i.test(String(v).trim());
43
+ }
44
+
45
+ /** Read any recognised flags from a process.env-shaped object. Unset vars are omitted. */
46
+ export function readEnvFlags(env = {}) {
47
+ const out = {};
48
+ for (const key of Object.keys(DEFAULT_ACCESS_FLAGS)) {
49
+ const b = toBool(env[flagEnvName(key)]);
50
+ if (b !== undefined) out[key] = b;
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /**
56
+ * Merge sources into the effective flag set. Only known keys are honoured.
57
+ * @param {{ dbFlags?: object, envFlags?: object }} sources
58
+ */
59
+ export function resolveFlags({ dbFlags = {}, envFlags = {} } = {}) {
60
+ const out = { ...DEFAULT_ACCESS_FLAGS };
61
+ for (const key of Object.keys(DEFAULT_ACCESS_FLAGS)) {
62
+ const d = toBool(dbFlags ? dbFlags[key] : undefined);
63
+ if (d !== undefined) out[key] = d;
64
+ const e = toBool(envFlags ? envFlags[key] : undefined);
65
+ if (e !== undefined) out[key] = e; // env wins — emergency kill
66
+ }
67
+ return out;
68
+ }
package/src/index.js CHANGED
@@ -11,3 +11,10 @@ export {
11
11
  resolveCapabilities,
12
12
  } from "./role-capabilities.js";
13
13
  export { resolveReporteeUserIds } from "./reportee-tree.js";
14
+ export {
15
+ DEFAULT_ACCESS_FLAGS,
16
+ ACCESS_FLAG_ENV_NAMES,
17
+ flagEnvName,
18
+ readEnvFlags,
19
+ resolveFlags,
20
+ } from "./access-config.js";
@@ -5,9 +5,11 @@
5
5
  * injects `account_id` from the request's ALS store (tenant-context.js), so a query cannot
6
6
  * reach Mongo without the caller's tenant boundary — even if the controller forgot it.
7
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)
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.
11
13
  *
12
14
  * Bypass: `runAsSystem(fn)` or `.setOptions({ skipTenant: true })` for legitimately
13
15
  * cross-tenant reads. NOT covered: aggregation pipelines and write ops (reads are C0 scope).
@@ -18,9 +20,11 @@
18
20
  import als from "./tenant-context.js";
19
21
 
20
22
  const READ_OPS = ["find", "findOne", "countDocuments"];
21
- const mode = () => (process.env.TENANT_ENFORCEMENT || "warn").toLowerCase();
23
+ const mode = () => (process.env.TENANT_ENFORCEMENT || "off").toLowerCase();
22
24
 
23
25
  function tenantHook() {
26
+ if (mode() === "off") return; // literal no-op — identical to pre-Track-3
27
+
24
28
  const opts = typeof this.getOptions === "function" ? this.getOptions() : this.options || {};
25
29
  if (opts && opts.skipTenant) return;
26
30