Skip to main content

Multi-Tenant Middleware

Tenant context extraction and isolation for SaaS applications.

Installation​

npm install @saga-bus/middleware-tenant

Basic Usage​

import { createTenantMiddleware, getTenantId } from '@saga-bus/middleware-tenant';

const bus = createBus({
transport,
store,
sagas: [{ definition: orderSaga }],
middleware: [
createTenantMiddleware({
extractTenantId: (context) => context.headers['x-tenant-id'],
}),
],
});

Configuration​

OptionTypeDefaultDescription
extractTenantIdfunctionRequiredExtract tenant from context
requiredbooleantrueRequire tenant ID
validateTenantfunction-Validate tenant exists
onMissingfunctionthrowHandle missing tenant
propagatebooleantruePropagate to published messages

Full Configuration Example​

import { createTenantMiddleware, getTenantId, setTenantContext } from '@saga-bus/middleware-tenant';

const tenantMiddleware = createTenantMiddleware({
// Extract tenant from message headers
extractTenantId: (context) => {
return context.headers['x-tenant-id']
|| context.message.tenantId;
},

// Tenant is required
required: true,

// Validate tenant exists
validateTenant: async (tenantId) => {
const tenant = await tenantService.findById(tenantId);
if (!tenant) {
throw new Error(`Invalid tenant: ${tenantId}`);
}
if (!tenant.isActive) {
throw new Error(`Tenant suspended: ${tenantId}`);
}
return tenant;
},

// Handle missing tenant
onMissing: (context) => {
logger.error('Missing tenant ID', { messageType: context.messageType });
throw new TenantRequiredError();
},

// Propagate tenant to published messages
propagate: true,
});

Accessing Tenant Context​

In Saga Handlers​

import { getTenantId, getTenant } from '@saga-bus/middleware-tenant';

const orderSaga = defineSaga<OrderState>({
name: 'OrderSaga',
})
.handle('OrderSubmitted', async (context) => {
// Get tenant ID
const tenantId = getTenantId();

// Get full tenant object (if validateTenant returns it)
const tenant = getTenant();

// Use tenant-specific database
const db = getDatabaseForTenant(tenantId);

// Use tenant-specific configuration
const config = await getConfigForTenant(tenantId);
});

In Services​

import { getTenantId } from '@saga-bus/middleware-tenant';

class OrderService {
async createOrder(data: CreateOrderData) {
const tenantId = getTenantId();

return this.orderRepo.create({
...data,
tenantId,
});
}
}

Tenant Isolation Strategies​

Database Per Tenant​

import { getTenantId } from '@saga-bus/middleware-tenant';

function getDatabaseConnection() {
const tenantId = getTenantId();
return connectionPool.get(`tenant_${tenantId}`);
}

Schema Per Tenant​

import { getTenantId } from '@saga-bus/middleware-tenant';

function getSchemaName() {
const tenantId = getTenantId();
return `tenant_${tenantId}`;
}

// In queries
const orders = await db.query(
`SELECT * FROM ${getSchemaName()}.orders WHERE id = $1`,
[orderId]
);

Row-Level Security​

import { getTenantId } from '@saga-bus/middleware-tenant';

class OrderRepository {
async findAll() {
const tenantId = getTenantId();
return this.db.query(
'SELECT * FROM orders WHERE tenant_id = $1',
[tenantId]
);
}
}

Message Propagation​

Tenant ID automatically propagates to published messages:

// When processing message with tenant "acme"
await context.publish({
type: 'PaymentRequested',
orderId: '123',
});

// Published message automatically includes:
// headers: { 'x-tenant-id': 'acme' }

Extraction Strategies​

From Headers​

extractTenantId: (context) => context.headers['x-tenant-id']

From Message Body​

extractTenantId: (context) => context.message.tenantId

From Correlation ID​

// If correlation ID format is: tenant:order-123
extractTenantId: (context) => {
const [tenantId] = context.correlationId.split(':');
return tenantId;
}

From JWT Token​

import jwt from 'jsonwebtoken';

extractTenantId: (context) => {
const token = context.headers['authorization']?.replace('Bearer ', '');
if (!token) return null;
const decoded = jwt.verify(token, process.env.JWT_SECRET);
return decoded.tenantId;
}

Error Handling​

Missing Tenant​

createTenantMiddleware({
extractTenantId: (ctx) => ctx.headers['x-tenant-id'],
required: true,
onMissing: (context) => {
// Option 1: Throw error
throw new TenantRequiredError();

// Option 2: Use default tenant
return 'default';

// Option 3: Skip processing
return { action: 'skip' };
},
});

Invalid Tenant​

createTenantMiddleware({
extractTenantId: (ctx) => ctx.headers['x-tenant-id'],
validateTenant: async (tenantId) => {
const tenant = await tenantService.findById(tenantId);
if (!tenant) {
throw new InvalidTenantError(tenantId);
}
return tenant;
},
});

Testing​

import { setTenantContext, clearTenantContext } from '@saga-bus/middleware-tenant';

describe('OrderService', () => {
beforeEach(() => {
setTenantContext('test-tenant');
});

afterEach(() => {
clearTenantContext();
});

it('creates order with tenant', async () => {
const order = await orderService.createOrder({ ... });
expect(order.tenantId).toBe('test-tenant');
});
});

Best Practices​

Always Validate Tenants​

validateTenant: async (tenantId) => {
const tenant = await cache.getOrFetch(
`tenant:${tenantId}`,
() => tenantService.findById(tenantId)
);
if (!tenant || !tenant.isActive) {
throw new InvalidTenantError(tenantId);
}
return tenant;
}

Use Tenant-Aware Logging​

import { getTenantId } from '@saga-bus/middleware-tenant';

logger.info('Order created', {
orderId: order.id,
tenantId: getTenantId(),
});

Propagate to All External Calls​

import { getTenantId } from '@saga-bus/middleware-tenant';

const response = await fetch(externalApi, {
headers: {
'X-Tenant-ID': getTenantId(),
},
});

See Also​