@i4e/invest4edu-access-core
v0.43.4
Published
Shared access-control primitives for NeoFindesk: tenant keystone, role capabilities, reportee tree, entity/role vocabulary, mobile app identity, feature flags, and the unified access engine (registry schema, snapshot resolver, visibleWhen).
Maintainers
Readme
@i4e/invest4edu-access-core
Shared tenant-isolation / access-control primitives for the NeoFindesk backends
(nfd-api-node v1 + nfd-api-node-v2). The D1 keystone of the access-management epic:
central, unforgettable account_id scoping.
Both backends are ESM, so this package ships plain ESM source — no build step.
0.32.0 — BREAKING, and it fails silently
resolveReporteeUserIds no longer reads employees.reporting_manager as a user id. It
reads it only as the manager's employee _id, which is the shape BRI-973 migrates the field
to. This is a breaking change on a 0.x minor, so read the two notes below before upgrading a
consumer.
1. Against un-migrated data it returns zero reportees, not an error. The dual-shape widening that carried the rollout is gone. A manager whose rows have not been migrated resolves to a self-only scope, and because this function feeds record visibility in nfd-api-node and the lead access scope in nfd-api-node-v2, that reads as "this manager has no reportees" — nothing thrown, nothing logged, just a smaller answer.
So the deploy order is migrate → verify → bump, not the other way round. Run
nfd-api-node's migrations/verify-reporting-manager-to-employee-id.js and get a clean
verdict before the first instance carrying 0.32.0 serves traffic. In a rolling deploy that
window is real, so the migration must be complete rather than merely started.
Under npm's 0.x caret rules ^0.31.0 will not resolve to 0.32.0, so no consumer is dragged
across by an install — the ordering risk is entirely a human one.
2. Callers must pass the FULL employee list. An _id reference only resolves if the row
carrying it is present, so a pre-filtered list ({ status: 1 }, an account scope, anything)
drops link rows — and what you lose is not the filtered person but everyone beneath them.
Filter the result instead. resolveReporteeUserIds warns once when handed a list in which no
row carries an _id at all, which is the one case where the answer is confidently wrong
rather than merely narrow.
0.32.0 also moves the entity/role vocabulary — ENTITY, ENTITY_STATUS,
isEntityLoginAllowed, conflictingEntityFor, ROLE_TYPE_PRIORITY,
CLIENT_APP_ROLE_NAMES — into this package. It was previously written out three times
(nfd-api-node, nfd-api-node-v2, nfdui-nextjs) and held together by parity tests.
Install
npm install @i4e/invest4edu-access-coreUsage
1. Carry identity per request (in the auth middleware, JWT path only):
import als from "@i4e/invest4edu-access-core/tenant-context";
// after verifying the JWT:
return als.run({ account_id, userId, roles }, () => next());2. Enforce on tenant-scoped models:
import { tenantPlugin } from "@i4e/invest4edu-access-core";
schema.plugin(tenantPlugin); // before mongoose.model(...)The plugin injects account_id from the request's ALS store into the filter of every
operation that selects existing documents:
| | ops | switch |
|---|---|---|
| reads | find findOne countDocuments distinct | TENANT_ENFORCEMENT / accessconfig.tenantEnforcement |
| writes | updateOne updateMany replaceOne deleteOne deleteMany findOneAndUpdate findOneAndReplace findOneAndDelete | TENANT_ENFORCEMENT_WRITES / accessconfig.tenantEnforcementWrites |
Each switch is an independent ladder — off → warn → enforce — and both default to off,
so installing the plugin is a literal no-op until you arm it. Reads and writes are separate
because the blast radius is not comparable: a read that gains a filter returns less data, while a
write that gains a filter silently modifies nothing and still reports success. Arming reads
must never arm writes by surprise.
| mode | identity present | identity absent |
|---|---|---|
| off (default) | no-op | no-op |
| warn | reads: inject account_id · writes: unchanged | log [tenant] identity-less …, do not scope |
| enforce | inject account_id | throw (fail-closed) |
warn deliberately leaves writes alone. warn means "tell me what enforce would do", and for a
destructive operation that promise is only kept by changing nothing — injecting a filter under a
mode named warn would turn live UPDATEs into silent no-ops, the exact failure burn-in exists to
catch. What you need before promoting is which writes run identity-less, and that is logged.
Precedence per ladder: env var (if valid) → DB value → off. An unrecognised value is reported
once and ignored rather than treated as off, so a typo cannot silently disarm enforcement.
3. Bypass for legitimately cross-tenant operations (caches, migrations, scripts):
import { runAsSystem } from "@i4e/invest4edu-access-core";
await runAsSystem(() => Employee.find({ status: 1 })); // whole scope
Model.find(q).setOptions({ skipTenant: true }); // one queryExports
als(default of/tenant-context) — the AsyncLocalStorage instancerunWithTenant(store, fn),runAsSystem(fn),getTenantStore()tenantPlugin(default of/tenant-plugin) — the Mongoose pluginsetTenantMode(m)/getTenantMode()— the read laddersetTenantWriteMode(m)/getTenantWriteMode()— the write ladderresolveReporteeUserIds(roots, employees, opts)(/reportee-tree) — self + all reporteesENTITY,ENTITY_STATUS,isEntityLoginAllowed,entityInactiveMessage,checkPortalEntityAccess(/entity-status) — which entities permit a loginconflictingEntityFor,entityConflictMessage,CONFLICTING_ENTITIES(/user-entity-link) — which hats may coexist on one personROLE_TYPE_PRIORITY,CLIENT_APP_ROLE_NAMES,pickPortalRoleName(/portal-roles) — which role names a portal sessionMOBILE_LOGIN_SOURCES,CLIENT_APP_LOGIN_SOURCES,PARTNER_APP_HATS,APP_BUNDLE_ID_PATTERN,appBundleIdOf(/mobile-app-identity) — which mobile app a login came from, which hats it flags, and the bundle id its session keepsclientProfilenamespace (/client-profile) — the two-profile client model. Settings (resolveClientProfileSettings,MODES, collection names), sides and tagging (sideKeyOf,profileTypeFor,orderSideFor,primaryLockFor,primaryFromLead), access level (clientAccessFor,callerClassOf,linkedViewOf), the side filter (buildSideFilter) and the personal-details field policy (buildFieldPolicyIndex,evaluateFieldChanges,effectivePolicyFor,changedFields). Every behaviour is read from aclientprofilesettingsdocument and each has anoff | audit | enforcemode, defaulting tooff. Business identifiers (distributor codes, role names, product codes) have EMPTY defaults and come from each environment's settings document. Design and tracker: https://claude.ai/artifact/JrCTutgQkp32r3NeU2uvmk
Not covered
Aggregation pipelines — add an explicit { $match: { account_id } } stage.
Inserts (save, create, insertMany) — and they cannot be. They carry no filter, so there
is nothing to constrain; putting account_id on a new document is the caller's job. The plugin
only governs which existing documents an operation may reach.
estimatedDocumentCount takes no filter either, so it is not hooked.
Compatibility
Node ≥ 18 (AsyncLocalStorage). mongoose ≥ 6 is an optional peer (only the plugin needs it).
