s-m-r-t

@happyvertical/smrt-tenancy

Production-ready multi-tenancy framework with automatic tenant isolation, AsyncLocalStorage context propagation, and framework adapters.

v0.19.0Multi-TenancyESM

Overview

The @happyvertical/smrt-tenancy package (NEW in v0.19.0) provides automatic tenant isolation for SaaS applications built on SMRT-core.

Key Features

  • Automatic Query Filtering: WHERE tenantId = current added automatically
  • AsyncLocalStorage Context: Propagates tenant through async operations
  • Decorator-Based: @TenantScoped marks classes for isolation
  • Framework Adapters: SvelteKit, Express, CLI support
  • Super-Admin Bypass: Controlled cross-tenant access
  • Raw SQL Policy: Prevents accidental data leaks
  • Testing Utilities: Complete test suite helpers

Architecture

Request Flow
├─ Framework Adapter (SvelteKit/Express)
│  └─ Resolve tenant ID from source
│     └─ enterTenantContext()
│        └─ AsyncLocalStorage.run(context)
│           └─ SMRT Operation (list/get/save)
│              └─ TenantInterceptor
│                 ├─ Check: Class tenant-scoped?
│                 ├─ Check: Context available?
│                 ├─ Auto-filter: Add tenantId
│                 ├─ Auto-populate: Set tenantId
│                 └─ Validate: Isolation rules
			

Installation

bash
npm install @happyvertical/smrt-tenancy

Setup (3 Steps)

1. Enable Tenancy Globally

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

enableTenancy({
  rawQueryPolicy: 'throw',  // Prevent raw SQL
  onMissingContext: (className, operation) => {
    console.error('Missing context: ' + className + ' ' + operation);
  }
});

2. Mark Classes as Tenant-Scoped

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

@smrt()
@TenantScoped()
class Document extends SmrtObject {
  tenantId = tenantId();  // Framework manages this
  title: string = '';
  content: string = '';
}

3. Setup Framework Middleware

typescript
// SvelteKit (hooks.server.ts)
import { createSvelteKitHandle } from '@happyvertical/smrt-tenancy/adapters';

export const handle = createSvelteKitHandle({
  resolveTenantId: async (event) => {
    const host = event.request.headers.get('host');
    return host?.split('.')[0];  // tenant.example.com
  }
});

// Express
import { createExpressMiddleware } from '@happyvertical/smrt-tenancy/adapters';

app.use(createExpressMiddleware({
  resolveTenantId: (req) => req.headers['x-tenant-id'] as string
}));

Quick Start

1. Define Tenant-Scoped Model

typescript
@smrt()
@TenantScoped()
class Project extends SmrtObject {
  tenantId = tenantId();
  name: string = '';
  owner: string = '';
}

2. Automatic Filtering

typescript
// In route handler (context established by middleware)
app.get('/api/projects', async (req, res) => {
  // Query automatically filtered by tenant
  const projects = await projectCollection.list({
    where: { status: 'active' }
  });
  // Actual SQL: WHERE tenantId = 'current' AND status = 'active'

  res.json(projects);
});

3. Automatic Population

typescript
// Create new project
app.post('/api/projects', async (req, res) => {
  const project = await projectCollection.create({
    name: req.body.name,
    // tenantId auto-populated from context
  });

  res.json(project);
});

4. Isolation Validation

typescript
// Delete validates tenant ownership
app.delete('/api/projects/:id', async (req, res) => {
  const project = await projectCollection.get(req.params.id);
  // Throws TenantIsolationError if wrong tenant
  await project.delete();

  res.json({ deleted: true });
});

Core Concepts

1. Tenant Context

AsyncLocalStorage-based context flows through async operations:

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

// Non-throwing getter
const tenantId = getTenantId();  // string | undefined

// Throwing getter (for required cases)
const tenantId = requireTenantId();  // throws if undefined

// Manual context setting
await withTenant({ tenantId: 'tenant-123' }, async () => {
  const projects = await projectCollection.list({});
});

2. Isolation Modes

Required Mode (default)

typescript
@TenantScoped({ mode: 'required' })
class SecretData extends SmrtObject {
  tenantId = tenantId();
}

// This throws TenantContextError
await secretDataCollection.list({});  // No context!

Optional Mode

typescript
@TenantScoped({ mode: 'optional' })
class PublicConfig extends SmrtObject {
  tenantId = tenantId({ nullable: true });
}

// Both work
await configCollection.list({});  // All records
await withTenant({ tenantId: 't1' }, () =>
  configCollection.list({})
);  // Filtered

3. Super-Admin Bypass

Controlled cross-tenant access for privileged operations:

typescript
@TenantScoped({ allowSuperAdminBypass: true })
class AuditLog extends SmrtObject {
  tenantId = tenantId();
}

// Admin viewing all logs
await withSuperAdminBypass(async () => {
  const allLogs = await auditLogCollection.list({});
  // No tenant filtering
});

4. Raw SQL Policy

Prevents accidental data leaks via raw queries:

typescript
enableTenancy({
  rawQueryPolicy: 'throw'  // Default
});

// This throws TenantIsolationError
await projectCollection.query({
  sql: 'SELECT * FROM projects',
  params: []
});

// Explicitly allowed
await projectCollection.query({
  sql: 'SELECT * FROM projects WHERE status = ?',
  params: ['active'],
  allowRawOnTenantScoped: true  // Override
});

Testing

typescript
import {
  createTestTenantContext,
  setupTestTenancy,
  assertTenantContextRequired
} from '@happyvertical/smrt-tenancy/testing';

describe('Project isolation', () => {
  beforeAll(() => {
    setupTestTenancy({ enableInterceptors: true });
  });

  it('should isolate projects by tenant', async () => {
    // Create in tenant A
    const projectA = await createTestTenantContext(
      { tenantId: 'tenant-a' },
      async () => {
        return projectCollection.create({ name: 'Project A' });
      }
    );

    // Verify not visible in tenant B
    await createTestTenantContext(
      { tenantId: 'tenant-b' },
      async () => {
        const found = await projectCollection.get(projectA.id);
        expect(found).toBeNull(); // Isolated!
      }
    );
  });

  it('should require context', async () => {
    await assertTenantContextRequired(() =>
      projectCollection.list({})
    );
  });
});

Integration with Other Modules

smrt-users (RBAC)

typescript
const handle = createSvelteKitHandle({
  resolveTenantId: async (event) => {
    const auth = await getAuth(event);
    return auth?.tenantId;
  },

  resolvePermissions: async (event, tenantId, userId) => {
    const perms = await getPermissions(userId, tenantId);
    return new Set(perms);
  },

  isSuperAdmin: async (event, tenantId, userId) => {
    const user = await getUser(userId);
    return user?.roles?.includes('admin') ?? false;
  }
});

// Use in business logic
import { getCurrentTenant } from '@happyvertical/smrt-tenancy';

function requirePermission(permission: string): boolean {
  const context = getCurrentTenant();
  return context?.permissions?.has(permission) ?? false;
}

Message Queues

typescript
// Capture context when queueing
export async function scheduleJob(data: unknown) {
  const tenantId = requireTenantId();

  await queue.enqueue({
    type: 'process_data',
    data,
    metadata: { tenantId }  // Capture!
  });
}

// Restore context in worker
async function processJob(job: Job) {
  await withTenant(
    { tenantId: job.metadata.tenantId },
    async () => {
      await handleJob(job);
    }
  );
}

Best Practices

1. Always Use Auto-Filtering

typescript
// Good
@TenantScoped()
class Document extends SmrtObject {
  tenantId = tenantId();  // Auto-filters
}

// Risky
@TenantScoped({ autoFilter: false })
class Document extends SmrtObject {
  tenantId = tenantId();  // Manual filtering error-prone
}

2. Block Raw SQL by Default

typescript
enableTenancy({
  rawQueryPolicy: 'throw'  // Prevent leaks
});

3. Validate at Middleware Entry

typescript
createExpressMiddleware({
  resolveTenantId: async (req) => {
    const tenantId = req.headers['x-tenant-id'];

    // Verify tenant exists
    const tenant = await getTenant(tenantId as string);
    if (!tenant) {
      throw new Error('Invalid tenant');
    }

    return tenantId;
  }
});

4. Index tenantId Field

sql
CREATE INDEX idx_documents_tenant_id
ON documents(tenant_id);

CREATE INDEX idx_documents_tenant_status
ON documents(tenant_id, status);

5. Audit Cross-Tenant Access

typescript
enableTenancy({
  onIsolationViolation: (className, expected, actual) => {
    logger.warn('ISOLATION_VIOLATION', {
      className,
      expectedTenant: expected,
      attemptedTenant: actual,
      timestamp: new Date()
    });
  }
});

Troubleshooting

TenantContextError: No context available

Solution: Ensure middleware is registered before routes

typescript
// Verify middleware registered
app.use(createExpressMiddleware({...}));  // BEFORE routes

// Verify in handler
console.log(getTenantId());  // Should not be undefined

TenantIsolationError: Cross-tenant access

Solution: Enable bypass if needed for admins

typescript
@TenantScoped({ allowSuperAdminBypass: true })
class AuditLog extends SmrtObject {
  tenantId = tenantId();
}

// Verify bypass is active
isSuperAdmin: async (event) => {
  return user?.isAdmin ?? false;
}

Raw SQL query failed

Solution: Use list()/get() or explicit bypass

typescript
// Instead of raw SQL
await collection.query({ sql: 'SELECT...' });

// Use collection methods
await collection.list({ where: {...} });

// Or explicit bypass
await collection.query({
  sql: 'SELECT...',
  allowRawOnTenantScoped: true
});

Context lost in background jobs

Solution: Capture tenantId in job metadata

typescript
// When queueing
const job = {
  type: 'process',
  metadata: {
    tenantId: requireTenantId()  // Capture!
  }
};

// When processing
await withTenant(
  { tenantId: job.metadata.tenantId },
  async () => {
    // Context restored
  }
);

API Reference

Context Functions

FunctionReturnsDescription
getTenantId()string | undefinedGet current tenant ID
requireTenantId()stringGet tenant ID, throw if undefined
getCurrentTenant()TenantContextData | undefinedGet full context
withTenant(ctx, fn)Promise<T>Run fn in tenant context
withSuperAdminBypass(fn)Promise<T>Run fn with bypass enabled
withSystemContext(fn)Promise<T>Run fn without tenant context

Decorator Options

OptionTypeDefaultDescription
mode'required' | 'optional''required'Context requirement
fieldstring'tenantId'Tenant ID field name
autoFilterbooleantrueAuto-add tenant to queries
autoPopulatebooleantrueAuto-set tenant on save
allowSuperAdminBypassbooleanfalseEnable bypass for this class

Framework Adapters

  • createSvelteKitHandle(options) - SvelteKit middleware
  • createExpressMiddleware(options) - Express middleware
  • createCliContext(options) - CLI context manager

Testing Utilities

  • setupTestTenancy(options?) - Enable tenancy for tests
  • createTestTenantContext(ctx, fn) - Run fn in test context
  • assertTenantContextRequired(fn) - Assert error thrown
  • resetTenancy() - Clear tenancy state