Multi-tenancy

@happyvertical/smrt-tenancy isolates each tenant's data without threading a tenant ID through every query. You mark a class as tenant-scoped, run your request inside a tenant context, and a query interceptor adds the WHERE tenant_id = … filter automatically. For Postgres, you can add row-level security as a database-enforced second layer.

Two layers of isolation

SMRT separates application-level scoping from database-level enforcement:

LayerProvided byWhat it does
Query interceptorsmrt-tenancyRewrites every list/get/save/delete for a tenant-scoped class to filter and auto-populate tenantId from the active context.
Postgres RLSsmrt-usersGenerates CREATE POLICY statements from your permission catalog so the database rejects cross-tenant rows even if app code has a bug.

1. Mark a class as tenant-scoped

Enable tenancy once at startup with enableTenancy(). Then annotate each tenant-owned class with @TenantScoped() and its tenant column with the @tenantId() property decorator.

typescript
import { enableTenancy, TenantScoped, tenantId } from '@happyvertical/smrt-tenancy';
import { smrt, SmrtObject } from '@happyvertical/smrt-core';

// Once, at app startup
enableTenancy();

@smrt()
@TenantScoped({ mode: 'required' })
class Document extends SmrtObject {
  @tenantId()
  tenantId: string = '';

  title: string = '';
  body: string = '';
}

@TenantScoped takes one option, mode:

ModeBehavior
'required' (default)Every operation must run inside a tenant context. Missing context throws — fail-closed.
'optional'Works with or without a context. With no tenant, rows with a null tenant ID act as global records.

For an optional-tenancy class, make the field nullable so global rows are representable:

typescript
@smrt()
@TenantScoped({ mode: 'optional' })
class Template extends SmrtObject {
  @tenantId({ nullable: true })
  tenantId: string | null = null; // null = shared/global template

  name: string = '';
}

2. Run inside a tenant context

Wrap a unit of work in withTenant(). Inside the callback, every query against a tenant-scoped collection is automatically filtered, and creates auto-populate the tenant ID.

typescript
import { withTenant } from '@happyvertical/smrt-tenancy';

await withTenant({ tenantId: 'tenant-123' }, async () => {
  // Auto-filtered: WHERE tenant_id = 'tenant-123' AND status = 'active'
  const docs = await documents.list({ where: { status: 'active' } });

  // tenantId is set for you — no need to pass it
  const doc = await documents.create({ title: 'Q3 report' });
  console.log(doc.tenantId); // 'tenant-123'
});

Read the active context, or assert it, anywhere inside the callback:

typescript
import { getTenantId, requireTenantId } from '@happyvertical/smrt-tenancy';

const maybe = getTenantId();      // string | null
const id = requireTenantId();     // string, or throws if no context

3. Enforce with Postgres RLS (optional)

On Postgres, @happyvertical/smrt-users can generate and apply row-level security policies directly from your permission catalog, so the database itself rejects cross-tenant access.

typescript
import {
  syncPermissionCatalog,
  generatePostgresPermissionSql,
  applyPostgresPermissionPolicies
} from '@happyvertical/smrt-users';

const db = { db: { type: 'postgres' as const, url: process.env.DATABASE_URL! } };

await syncPermissionCatalog(db);

// Preview the policy SQL (targets that will get policies, and skips)
const preview = generatePostgresPermissionSql(db);
console.log(preview.targets, preview.skipped);

// Apply: ALTER TABLE … ENABLE ROW LEVEL SECURITY + CREATE POLICY …
await applyPostgresPermissionPolicies(db);

Automatic policy generation applies only to objects that are:

  • tenant-scoped with mode: 'required',
  • backed by a real Postgres table, and
  • mapped to a single tenant field.

Optional-tenancy and global tables are skipped (returned in result.skipped) rather than getting unsafe policies. The generated CRUD policies map to permission slugs: SELECT → read, INSERT → create, UPDATE → update, DELETE → delete.

Testing tenant-scoped code

The tenancy package ships test helpers so isolation behaves the same under Vitest. Enable the interceptors in a setup file, then drive tests inside withTenant().

typescript
import { setupTestTenancy, resetTenancy, withTenant } from '@happyvertical/smrt-tenancy';

// In your test setup file
setupTestTenancy({ enableInterceptors: true });

// In a test
afterEach(() => resetTenancy());

it('auto-populates tenantId', async () => {
  await withTenant({ tenantId: 'test-tenant' }, async () => {
    const doc = await documents.create({ title: 'Widget' });
    expect(doc.tenantId).toBe('test-tenant');
  });
});

Related

Verified against SMRT v0.29.34.