Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

tenantkit

Multi-tenancy for Node, framework-agnostic. A tenant context built on AsyncLocalStorage plus automatic, fail-closed query scoping for Mongoose. Single-database, row-level isolation.

The idea is borrowed from Tenancy for Laravel and rebuilt from scratch for Node and TypeScript. The core has zero dependencies. The Mongoose adapter is optional and loaded from a separate entrypoint.

ESM-only. This package ships ES modules. Use it from an ESM project ("type": "module" or .mjs). It cannot be require()d from CommonJS.

Why

In a single-database SaaS, every tenant shares the same collections. Isolation usually means remembering to add { ownerId } to every query. Forget one and you leak another tenant's data.

tenantkit makes the scope implicit and fail-closed: set the tenant once per request, and every scoped query filters automatically. Writes are stamped with the active tenant, and the active tenant always wins over a value sent by the client. A query that runs with no tenant context throws instead of returning everyone's rows.

Architecture

flowchart TD
    A[HTTP request] --> B["tenantMiddleware(resolve)"]
    B -->|await resolve req| C{tenant id?}
    C -->|yes| D["runWithTenant(id, () => next())"]
    C -->|"no (required: true)"| E["next(MissingTenantContextError)"]
    D --> F[("AsyncLocalStorage<br/>tenant context")]
    F --> G[route handler]
    G --> H["Model.find / create / update / aggregate / bulkWrite"]
    H --> I["tenantScopePlugin pre-hooks"]
    I --> J["resolveTenantScope() reads the context"]
    J -->|tenant present| K["inject filter / $match<br/>force-stamp writes"]
    J -->|"no context (fail-closed)"| L["throw MissingTenantContextError"]
    K --> M[("MongoDB<br/>scoped to the tenant")]
Loading

Two layers:

  • Core (@flavio.martil/tenantkit, zero dependencies): the AsyncLocalStorage tenant context and an identification middleware. Reusable in any Node app.
  • Mongoose adapter (@flavio.martil/tenantkit/mongoose): a schema plugin that reads the context inside Mongoose pre-hooks and scopes reads, writes, aggregations and bulk writes.

Install

bun add @flavio.martil/tenantkit
# or: npm install @flavio.martil/tenantkit

Mongoose is an optional peer dependency, only needed for the adapter.

Quick start (Mongoose)

  1. Set the tenant per request. The resolver may be sync or async. By default the middleware is fail-closed: if it cannot resolve a tenant, it forwards MissingTenantContextError to your error handler.
import { tenantMiddleware } from "@flavio.martil/tenantkit";

app.use(tenantMiddleware((req) => req.user?.ownerId));

// async resolver and a custom miss handler
app.use(
  tenantMiddleware(async (req) => resolveTenantFromApiKey(req.headers["x-api-key"]), {
    onMissing: (_req, res) => res.status(401).end(),
  })
);

// public routes that legitimately have no tenant
app.use("/public", tenantMiddleware(() => undefined, { required: false }));
  1. Register the plugin on the models you want scoped.
import { tenantScopePlugin } from "@flavio.martil/tenantkit/mongoose";

caseSchema.plugin(tenantScopePlugin, { tenantKey: "ownerId", ref: "User" });

// string / UUID / slug tenant ids
orgSchema.plugin(tenantScopePlugin, { tenantKey: "orgId", idType: "string" });
  1. Query normally. The filter and the stamp are applied for you.
await Case.find();                 // scoped to the current tenant
await Case.create({ title: "x" }); // stamped with the current tenant
await Case.create({ ownerId: otherTenant, title: "x" }); // ownerId is overwritten with the active tenant

Tenant ids

runWithTenant accepts a string or anything with a meaningful toString() (a Mongoose ObjectId works) and stores the string form. Plain objects and arrays are rejected with InvalidTenantIdError, so a value coming from untrusted input such as ?tenant[$ne]= cannot turn into a NoSQL operator.

With the default idType: "objectId", a 24-hex tenant id is cast to an ObjectId before filtering so it matches an ObjectId field. For string/UUID/slug ids, pass idType: "string".

Escape hatches

Work that runs outside a request (cron jobs, webhooks, seeds, migrations) has no tenant. Pick one:

import { runWithTenant, runWithoutTenant } from "@flavio.martil/tenantkit";

// run a job as a specific tenant
await runWithTenant(ownerId, () => processTenant(ownerId));

// run an admin task across all tenants, scoping disabled.
// await INSIDE the callback so the context covers query execution.
await runWithoutTenant(() => Case.find().exec());

Or let a specific model run unscoped when there is no context:

auditLogSchema.plugin(tenantScopePlugin, { allowUnscoped: true });

Scope is read at query execution time, bound to the async context active when the query runs. Hand work to a queue or an interval that started outside runWithTenant and you must re-establish the context inside the worker.

Scoped operations

Auto-scoped reads and deletes: find, findOne, findOneAndUpdate, findOneAndDelete, findOneAndReplace, countDocuments, distinct, exists, updateOne, updateMany, deleteOne, deleteMany, replaceOne, aggregate, and bulkWrite.

Auto-stamped writes: save, create, insertMany, plus the replacement document on replaceOne / findOneAndReplace. The active tenant overwrites any tenant key in the payload, and update operations cannot reassign the tenant key (it is forced back and stripped from $unset / $rename).

What is NOT scoped (threat model)

These bypass Mongoose middleware by design and stay your responsibility. Keep manual tenant filters on them.

  • Raw driver access: Model.collection.find/updateMany/deleteMany, Model.db.collection(...). Skips every plugin hook. Never run untrusted input through the raw driver.
  • Aggregation across collections: $lookup, $graphLookup, $unionWith sub-pipelines read foreign collections unscoped. Add a tenant $match inside each sub-pipeline.
  • Aggregation write stages: $out and $merge write to a target collection unscoped.
  • estimatedDocumentCount(): takes no filter, returns the collection-wide count.

API

Core (@flavio.martil/tenantkit):

  • runWithTenant(tenantId, fn) runs fn with the tenant in context.
  • runWithoutTenant(fn) runs fn with scoping disabled.
  • getTenantId() / requireTenantId() / hasTenantContext() / isUnscoped().
  • tenantMiddleware(resolve, options?), options: { required?: boolean (default true); onMissing?: (req, res, next) => void }.
  • MissingTenantContextError, InvalidTenantIdError.

Mongoose adapter (@flavio.martil/tenantkit/mongoose):

  • tenantScopePlugin(schema, options).
  • options: { tenantKey?: string (default "tenantId"); allowUnscoped?: boolean; addField?: boolean; ref?: string; idType?: "objectId" | "string" }.
  • Pure helpers exported for custom hooks: resolveTenantScope, castTenantId, forceStamp, sanitizeUpdate, scopeBulkOps, injectAggregateMatch.

Credits

Concept from Tenancy for Laravel. Hardened against a consolidated review (Claude, GPT-5.4 and GLM) that closed cross-tenant write injection, bulk-write fail-open, tenant-key reassignment and NoSQL operator injection.

License

MIT

About

Framework-agnostic multi-tenancy for Node: tenant context + automatic Mongoose query scoping. Single-database, row-level, fail-closed.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages