Multi-tenancy
WIPauthWithTenant + authCurrentTenant - AsyncLocalStorage-backed tenant scope that flows through every facet, store, and event without threading AuthTenantContext by hand.
duck-auth threads a AuthTenantContext through every facet, store, and
event so a multi-tenant app can reuse the same AuthEngine across
tenants without leaking state between them. The two ways to scope a
call are:
- Explicit - pass
AuthTenantContextas the last arg on every store and facet call. - Ambient - wrap a request in
authWithTenant(tenantId, fn); every nested call seesauthCurrentTenant()resolve to the same value.
Ambient is usually right for HTTP / job runners; explicit is right for cross-tenant admin flows where one call legitimately spans several tenants.
import { authWithTenant, authCurrentTenant, authResolveTenant } from '@gentleduck/auth/core'
authWithTenant
Wrap a function (sync or async) so every awaitable inside it
observes the supplied tenantId:
import { authWithTenant } from '@gentleduck/auth/core'
app.use((req, _res, next) => {
const tenantId = extractTenantFromSubdomain(req.hostname)
authWithTenant(tenantId, () => next())
})
Pass undefined to enter the global / no-tenant scope explicitly -
useful in tests and CLI tools where the lack of a tenant should be
audit-visible rather than implicit.
// Daily job runner
for (const tenant of tenants) {
await authWithTenant(tenant.id, () => runDailyJobsFor(tenant))
}
Every facet call inside the closure (auth.identities.create,
auth.sessions.list, auth.flows.signIn, etc.) resolves the
ambient tenant before reaching the store - no manual threading
required.
authCurrentTenant
Read the active scope. Returns undefined when no authWithTenant
wrapper is active, otherwise the AuthTenantContext bound by the
nearest wrapper.
import { authCurrentTenant } from '@gentleduck/auth/core'
function logWithTenant(message: string) {
const ctx = authCurrentTenant()
logger.info({ tenantId: ctx?.tenantId, message })
}
Library code should prefer authResolveTenant (below) which also
considers a caller-supplied explicit AuthTenantContext.
authResolveTenant
Pick the effective AuthTenantContext for a single call. Explicit
caller-supplied context wins over the ALS ambient - so
authWithTenant is a default, not a fence. Callers can still scope a
single call across a different tenant on purpose.
import { authResolveTenant } from '@gentleduck/auth/core'
async function readIdentityForAdmin(id: string, explicit?: AuthTenantContext) {
const ctx = authResolveTenant(explicit)
return store.identities.findById(id, ctx)
}
Adapters use this pattern: they accept an explicit AuthTenantContext on
the public surface but fall back to the ALS ambient when none is
passed. The user-supplied stores triple plugged into AuthEngine.stores
must do the same to stay compatible with the ambient flow.
Storage layer wiring
Stores must accept a AuthTenantContext last-arg on every method that
returns rows. The shipping memory / redis / SQL adapters do this
already:
findById(id: string, tenantId: string | undefined): Promise<Identity | null>
A null/undefined tenantId selects "global identities" - rows whose
tenant_id column is NULL. A non-null value selects rows for that
tenant plus the globals (so an embedded service account row that
sits at the root scope works inside any tenant). Adapters that want
strict isolation (no global fallback) can implement the contract
themselves; see adapter custom guide.
Patterns
Subdomain-keyed tenants
import { authWithTenant } from '@gentleduck/auth/core'
import { auth } from './auth'
app.use((req, res, next) => {
const tenantId = req.hostname.split('.')[0] // 'acme' from 'acme.app.example'
authWithTenant(tenantId, () => next())
})
// All routes downstream see auth.sessions.create() etc. scoped to `acme`
Header-keyed tenants
app.use((req, _res, next) => {
const tenantId = req.headers['x-tenant-id'] as string | undefined
authWithTenant(tenantId, () => next())
})
JWT-claim tenant (no middleware)
When a JWT already carries the tenant claim, push it into the ALS
right after resolveSession:
const resolved = await auth.resolveSession({ headers: req.headers })
await authWithTenant(resolved?.session.tenantId, () => handler(req, res))
Cross-tenant admin call
When an admin route legitimately needs to query a different tenant,
pass an explicit AuthTenantContext and bypass the ambient:
app.get('/admin/audit', async (req, res) => {
await authWithTenant('superadmin', async () => {
// Inside the admin scope, but we want to read tenant-A's identities:
const identity = await auth.identities.findById(req.query.id, { tenantId: 'tenant-a' })
res.json(identity)
})
})
authResolveTenant({ tenantId: 'tenant-a' }) resolves to tenant-a
regardless of the surrounding ALS scope.
Strict-mode boot check
strict({ env: 'production' }) does not enforce multi-tenancy by
itself - it's a single-vs-multi mode decision your app makes. When a
preset adds tenancy enforcement (some compliance configs do), the
check verifies that every facet call resolves a non-null
AuthTenantContext so a request that bypassed the ambient is rejected
rather than silently leaking globals.
See also
- Orgs facet - the higher-level org / membership / role-scope facet on top of the tenant primitive.
- Sessions - sessions carry
tenantIdin the row so cross-tenant resolve is impossible by construction.