@dreamtree-org/tenancy
v0.3.0
Published
The multi-tenant and RBAC authorization core for every Dreamtree product: company resolution, per-company database pools, sessions, TOTP, password recovery, provisioning, branch guard, unified RBAC policy engine, React client and platform settings, with N
Maintainers
Readme
@dreamtree-org/tenancy
The multi-tenant and RBAC authorization core for every Dreamtree product: combines company resolution, per-company database pools, sessions, TOTP, password recovery, provisioning, the branch guard and platform settings with the unified RBAC policy engine (@dreamtree-org/tenancy/authz) and React permission gating (@dreamtree-org/tenancy/react).
- One database per company, one control-plane ("platform") database. Single-DB apps can also use the standalone RBAC engine and KORM adapter via
@dreamtree-org/tenancy/authz. - Fails closed: unknown models, unregistered verbs, explicit deny statements (which override even superadmin), other branches' rows and suspended companies all get refused.
- Hierarchical scope ranking (
all > org > team > own), wildcard pattern matching (invoice.*.all), and automatic row-level KORMWHEREsynthesis (accessibleBy,mergeWhere,applyScopeFilter). - Ships ESM + CJS across 5 entry points:
.,/authz,/react,/next, and/express.
Install
npm install @dreamtree-org/tenancy knex mysql2 @node-rs/argon2 @dreamtree-org/korm-js
# optional: ioredis (shared throttle counters), next (the /next adapter), react (the /react client)60-second start
// lib/tenancy.js — once per app
const { createTenancy, BASE_SETTING_DEFINITIONS, BASE_SETTING_FAMILIES } = require('@dreamtree-org/tenancy');
const catalog = require('./permissionCatalog');
module.exports = createTenancy({
appKey: 'acme', // tenant_metadata key, Redis prefix
prefix: 'ac', // ac_session, ac_csrf, ac_ constraint names
platformDbUrl: process.env.PLATFORM_DATABASE_URL,
tenantSchema: { version: 'ac-1', korm: require('./schema/sync.json') },
permissions: { catalog: catalog.PERMISSIONS, defaultRoles: catalog.DEFAULT_ROLES, canPerform: catalog.canPerform },
screens: require('./screenManifest').SCREEN_MANIFEST,
settings: { definitions: [...BASE_SETTING_DEFINITIONS], families: BASE_SETTING_FAMILIES },
branchPolicy: { branchOwned: ['Invoice'], sharedMaster: ['Customer'], child: { InvoiceLine: { parent: 'Invoice', fk: 'invoice_id' } } },
});const tenancy = require('./lib/tenancy');
const company = await tenancy.resolve('acme'); // slug, or a verified hostname
const { tenantDb, korm } = await tenancy.db(company.id); // pooled, identity-verified
const session = await tenancy.sessions.createSession(tenantDb, { userId, activeBranchId, credentialVersion: 1, permissionVersion: 1 });
await tenancy.provision({ companyId: company.id }); // enqueue; a worker runs tenancy.retryProvisioning(jobId)The secret key ring, peppers and session secret default to the environment (TENANT_SECRET_KEYS_JSON + TENANT_SECRET_ACTIVE_KEY_ID, PASSWORD_PEPPER, MPIN_PEPPER, SESSION_SECRET, REDIS_URL). They're read the first time they're used, never at import.
What you get
| Property | What it is |
|---|---|
| resolve(locator) | Company row by slug or verified custom domain, or null |
| db(companyId) | { knex, korm, tenantDb }: one pool per company, single-flight, verified against tenant_metadata |
| withCompany(id, fn) | Run fn with the company's knex bound (jobs, scripts) |
| provision(args) / retryProvisioning(jobId) | Leased, checkpointed, idempotent provisioning jobs |
| sessions | Opaque sessions as digests, version binding, idle/absolute expiry, throttles (Redis or DB) |
| identity | Argon2id + pepper passwords, MPIN, users, memberships, effective permissions |
| totp / recovery / mfa | TOTP with recovery codes and lock order; password reset tokens; the login MFA ticket |
| secrets | AES-256-GCM envelopes bound by AAD, key rotation, streaming envelope |
| connections | Pool manager: eviction, serverless release, invalidateCompany |
| tenantSchema / appSchema / seed / syncLock / migrator | Company DDL, additive upgrades with preflight, catalogue seed, advisory sync lock |
| branchPolicy / guard / authz | Branch classification reader, the KORM request guard, KORM rules and evaluate() |
| platform.{db, schema, settings, secrets, admins, companyAdmins} | Control-plane pool, platform DDL, settings service, superadmins |
| binding | openTenantDbScope, runWithTenantDb, currentKnex (AsyncLocalStorage, shared by every copy) |
Every service is also exported as a factory (createSessionService, createBranchGuard, …) with the same function signatures the products used. That's how a product can switch one module at a time.
Config reference
| Key | Type | Default | Meaning |
|---|---|---|---|
| appKey | string | "app" | Product key: tenant_metadata.application_key, Redis prefix, password blocklist word |
| prefix | string | "dt" | Short prefix: ${prefix}_session, ${prefix}_csrf, ${prefix}-mfa:v1:, constraint and lock names, globals |
| platformDbUrl / platformDb | string | fn / db | PLATFORM_DATABASE_URL | Control-plane connection, or your own { all, get, run, exec, tx } |
| secretKey / secretKeys | string / { activeKeyId, keys } | env key ring | Encryption keys (32 bytes, base64) |
| environment | string | DEPLOYMENT_ENV or "development" | AAD environment label (never NODE_ENV) |
| passwordPepper, mpinPepper, sessionSecret | string | fn | env | Server-held secrets |
| redisUrl, redisPrefix, redis | string / string / client | REDIS_URL, ${appKey}: | Optional shared throttle store |
| tenantSchema | { version, korm, appRoot?, extraDdl?, extraUpgrades?, app? } | required for pools/schema | KORM schema, version stamp, product tables and registries |
| permissions | { catalog, keys?, defaultRoles, canPerform? } | required for authz/seed | Permission catalogue. Without canPerform, pass isKnownModel + resolvePermission |
| screens | array | [] | Screen manifest seeded into each company |
| settings | { definitions, families?, defaults?, rules?, adapterKeyPattern? } | required for settings | Registry. Spread BASE_SETTING_* for the shared keys |
| branchPolicy | classification or a built policy | empty | branchOwned, sharedMaster, companyConfig, child, childRefs, readOnly, fkModel, frozenFlags, publicFigures, maxPageSizeEnv |
| database | hooks | env-driven | connectionExtras, sslOptions, pool(role), isServerless, onConnectionError, registerReleaser, knexLog, sessionHook, withSessionTimeout, beforeKorm |
| provisioning | { steps: { managed, external }, transitionCompany? } | lifecycle default | Step tables (the product's database layout) |
| platform | { extraDdl, extraColumns, seed } | none | Product platform tables and seeds |
| passwordPolicy | { blockedWords } | fn | [appKey] | Extra blocked words, or your own (pw, ctx) => violations |
| logger, scrub | fn | console + DSN scrub | (name) => ({ debug, info, warn, error }) and (text) => text |
| sessions | { cookieName?, limits? } | ${prefix}_session, product defaults | Cookie name and idle/absolute limits |
| syncLockPrefix, streamMagic, adminRoleKey | string | ${prefix}_sync:, "DTS1", "tenantadmin" | Names kept for compatibility with existing data |
Sections that hold product data (tenantSchema, permissions, screens, settings, branchPolicy, database, provisioning, platform) may be functions. That lets a product wire lazily and avoid require cycles.
Recipes
Next.js (App Router). Add the package to serverExternalPackages, then:
import { createNextTenancy } from '@dreamtree-org/tenancy/next';
import tenancy from '@/lib/tenancy';
export const { tenantRoute, publicRoute, getTenantContext, issueLoginSession, csrf } = createNextTenancy(tenancy, {
errorMappers: [dbBusyResponse], // optional extra error → response mapping
formatIssues: (e) => z.treeifyError(e), // optional 400 details shape
// optional: your error envelope for every 401 / 403 / 409 / 429 / 500 the glue answers
respond: ({ status, code, message, details, headers }) =>
Response.json({ error: { code, message, details } }, { status, headers }),
});
export const POST = tenantRoute(async ({ ctx, body }) => ({ ok: true }), { body: schema, permission: 'floor_plan.read.own' });requirePermission (and tenantRoute's permission) ranks scopes: a holder of floor_plan.read.branch passes a floor_plan.read.own check (own < branch < company < all, plus the core own < team < org < all ladder and wildcards). Pass permissionSatisfies(held, required) to replace the rule; holdsPermission is the default, exported from /next.
Each piece is also a separate factory (createCsrf, createRequestContext, createLoginSessions, createIpRateLimit, createRouteKit) that takes its dependencies as functions. A product can pass its own modules that way, and its tests can keep mocking them by path.
Express.
const { tenancyMiddleware, requirePermission } = require('@dreamtree-org/tenancy/express');
app.use(tenancyMiddleware(tenancy)); // req.tenant, company knex bound
app.post('/lists', requirePermission('lists.edit'), handler);
app.get('/plans', requirePermission('floor_plan.read.own'), handler); // .branch / .company holders pass tooPolicy defaults (fail closed). tenancy.authz.registerKormRules refuses every BRANCH_OWNED model when the context has no activeBranchId. tenancy.authz.evaluate does not let ctx.isAdmin hold permissions unless the app opts in, per call (evaluate({ ..., isAdminBypass: true })) or for the policy (createTenancy({ permissions: { isAdminBypass: true } })).
Standalone RBAC & KORM Policy Engine (@dreamtree-org/tenancy/authz).
import {
rbac,
evaluate,
permissionSatisfies,
accessibleBy,
applyScopeFilter,
composeWithDenies,
} from '@dreamtree-org/tenancy/authz';
// Embedded KORM RBAC facade (single-DB or per-tenant DB):
const authz = await rbac({ db: knexInstance, dbClient: 'mysql', roles: SEED_ROLES });
await authz.can({ id: userId }, 'invoice.read.own', { resourceOwnerId: row.created_by });
// Row-level KORM WHERE narrowing:
const { where } = accessibleBy(statements, 'read', 'project', { userId, branch_id: branchId });
request.body.where = applyScopeFilter(request.body.where, where);React Client (@dreamtree-org/tenancy/react).
import { RbacProvider, Can, useCan, hasPermission, colonFormat } from '@dreamtree-org/tenancy/react';
// Wrap app (controlled mode or store mode):
<RbacProvider permissions={user.permissions} isSuperAdmin={user.isAdmin}>
<Can permission="invoice.create.own">
<NewInvoiceButton />
</Can>
</RbacProvider>What it deliberately does NOT do
- Own your domain. Permission catalogues, screen manifests, settings keys, branch classification and KORM models stay in the product.
- Create databases. The managed-database layout (provisioner principal, migrator/runtime users, append-only grants and triggers) is product infrastructure. You pass the step tables in, and the package runs them as a leased, resumable job.
- Send mail or SMS. Recovery returns the raw token; delivering it is the caller's job.
- Run migrations that drop or rename. Every schema pass is additive. Changing an existing CHECK or FK needs a versioned migration.
- Pick a fallback company. No request ever lands in "the default database".
