Skip to main content

Multi-tenancy

WIP

Which records carry a tenant, signing in and resolving sessions under one, and the routes a multi-tenant app writes itself

One engine serves every tenant. Identities are global: one person, one row, one email across all of them. What carries a tenant is what they hold in it:

  • Credentials. A password, TOTP secret, passkey or API key belongs to the tenant it was created under.
  • Sessions. A session records the tenant it was signed in to.

Signing in to a tenant

await auth.passwords.set(ada.id, password, auth.cfg.stores.credentials, { tenantId: 'acme' })

const { sid, session } = await auth.flows.signIn({ providerId: 'password', input: { email, password }, tenantId: 'acme' })
// session.tenantId === 'acme'

A sign-in under globex does not find acme's password and answers AUTH_INVALID_CREDENTIALS.

Resolving under a tenant

const resolved = await auth.resolveSession(req, { expectedTenantId: 'acme' }).orNull()

A session from another tenant resolves as absent. The CSRF guards take the same option: csrfGuard(auth, req, { expectedTenantId }), expressCsrf(auth, { expectedTenantId }).

Leaving the tenant out

A call without a tenant is unscoped, not scoped to "no tenant":

  • A sign-in without tenantId finds a credential in any tenant, and its session records none.
  • A resolve without expectedTenantId accepts a session from any tenant.
  • auth.sessions.listForIdentity(id) lists the person's sessions in every tenant. Pass { tenantId } to list one.

So a multi-tenant app passes the tenant, from the host or path, to every flow its routes call and to its guards; Fetch runtimes has a sign-in route to start from.

The ambient tenant

withTenant(tenantId, fn) binds a tenant for everything fn awaits, currentTenant() reads it, and resolveTenant(explicit) answers the explicit one when given and the bound one otherwise. They are for your own code. No engine call reads the bound tenant, so each still needs its tenantId passed.

import { resolveTenant, withTenant } from '@gentleduck/auth/core'

app.use((req, _res, next) => withTenant(req.hostname.split('.')[0], next))

// In a handler below it:
const { tenantId } = resolveTenant()
await auth.flows.signIn({ providerId: 'password', input, tenantId })

Organisations inside a tenant, with members and roles, are the orgs facet.