Multi-Tenancy
This guide covers the multi-tenant architecture, organization isolation, and tenant middleware in Cerberus IAM.
Overview
Cerberus is designed as a multi-tenant IAM platform where each organization is completely isolated:
- Organization - The tenant root entity
- Data Isolation - Each organization's data is strictly separated
- Resource Scoping - All resources belong to an organization
- Shared Infrastructure - Single database, multiple organizations
Organization Model
model Organisation {
id String @id @default(uuid())
name String @unique
slug String @unique
email String @unique
phone String?
website String?
ownerId String? @unique
status OrganisationStatus @default(trial)
sessionLifetime Int @default(3600)
sessionIdleTimeout Int @default(1800)
requireMfa Boolean @default(false)
passwordPolicy Json?
tokenLifetimePolicy Json?
branding Json?
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
owner User? @relation("OrganisationOwner")
users User[]
teams Team[]
roles Role[]
clients Client[]
apiKeys ApiKey[]
webhooks WebhookEndpoint[]
auditLogs AuditLog[]
sessions Session[]
}Organization Status
enum OrganisationStatus {
trial // Free trial period
active // Paid/active subscription
suspended // Payment issue or policy violation
cancelled // Cancelled by user
}Effects by Status:
trial: Full access with limits (e.g., user count, duration)active: Full accesssuspended: Read-only access, no new loginscancelled: No access, soft-deleted
Organization Settings
Session Configuration:
{
"sessionLifetime": 3600, // 1 hour
"sessionIdleTimeout": 1800 // 30 minutes
}Security Policies:
{
"requireMfa": true,
"allowedMfaMethods": ["totp"],
"passwordPolicy": {
"minLength": 12,
"requireUppercase": true,
"requireLowercase": true,
"requireNumber": true,
"requireSpecial": true,
"preventReuse": 5,
"maxAge": 90
}
}Token Lifetimes:
{
"tokenLifetimePolicy": {
"accessTokenLifetime": 900, // 15 minutes
"refreshTokenLifetime": 604800, // 7 days
"idTokenLifetime": 3600 // 1 hour
}
}Branding:
{
"branding": {
"primaryColor": "#007bff",
"logoUrl": "https://cdn.acme.com/logo.png",
"faviconUrl": "https://cdn.acme.com/favicon.ico"
}
}Tenant Middleware
Tenant Context
The tenant middleware extracts organization context from the request:
import { tenantMiddleware } from '@/middleware/tenant';
router.use('/v1/admin', tenantMiddleware);
// Now req.tenant is available
router.get('/v1/admin/users', (req, res) => {
const { tenant } = req;
console.log(tenant.id); // Organization ID
console.log(tenant.slug); // Organization slug
console.log(tenant.organisation); // Full organisation record
});How It Works
export async function tenantMiddleware(req, res, next) {
// Extract organization slug from header
const orgSlug = req.headers['X-Org-Domain'];
if (!orgSlug) {
return badRequest('Missing X-Org-Domain header');
}
// Load organization
const organisation = await prisma.organisation.findUnique({
where: { slug: orgSlug, deletedAt: null },
});
if (!organisation) {
return notFound(`Organisation '${orgSlug}' not found`);
}
// Attach to request
req.tenant = {
id: organisation.id,
slug: organisation.slug,
organisation,
};
next();
}TypeScript Extensions
declare global {
namespace Express {
interface Request {
tenant?: {
id: string;
slug: string;
organisation: Organisation;
};
}
}
}Data Isolation Patterns
Always Scope Queries
Rule: Every query must filter by organisationId:
// Bad: Global query (security vulnerability!)
const users = await prisma.user.findMany();
// Good: Organization-scoped
const users = await prisma.user.findMany({
where: { organisationId: req.tenant.id },
});Create with Organization
// Always set organisationId on create
const user = await prisma.user.create({
data: {
organisationId: req.tenant.id,
email: '[email protected]',
name: 'John Doe',
// ... other fields
},
});Update with Verification
// Verify resource belongs to organization before update
const user = await prisma.user.findFirst({
where: {
id: userId,
organisationId: req.tenant.id, // Important!
},
});
if (!user) {
return notFound('User not found');
}
const updated = await prisma.user.update({
where: { id: user.id },
data: { name: 'New Name' },
});Delete with Verification
// Verify before delete
const client = await prisma.client.findFirst({
where: {
id: clientId,
organisationId: req.tenant.id,
},
});
if (!client) {
return notFound('Client not found');
}
await prisma.client.delete({
where: { id: client.id },
});Multi-Tenant Routes
Admin Routes (Tenant-Scoped)
// Require tenant context for admin routes
router.use(
'/v1/admin',
tenantMiddleware, // Extract tenant
authenticateSession, // Verify authentication
);
router.get('/v1/admin/users', async (req, res) => {
// req.tenant automatically available
const users = await prisma.user.findMany({
where: { organisationId: req.tenant.id },
});
res.json(users);
});OAuth2 Routes (Client Determines Tenant)
// OAuth2 routes don't use tenant middleware
// Organization determined by client
router.post('/oauth2/token', async (req, res) => {
const { client_id } = req.body;
const client = await prisma.client.findUnique({
where: { clientId: client_id },
include: { organisation: true },
});
// Tenant context from client
const organisationId = client.organisationId;
});Cross-Organization Operations
Allowed Cases
Some operations span organizations (with care):
1. System Administration
// Super admin managing all organizations
router.get('/system/organisations', authenticateSuperAdmin, async (req, res) => {
const orgs = await prisma.organisation.findMany();
res.json(orgs);
});2. User Invitation (Email Lookup)
// Check if user exists in ANY organization
const existingUser = await prisma.user.findUnique({
where: { email: '[email protected]' },
include: { organisation: true },
});
if (existingUser) {
// User already exists in another organization
// Decide: allow multi-org membership or reject
}3. Global Resources (Permissions, Scopes)
// Permissions are global, not organization-scoped
const permissions = await prisma.permission.findMany();Forbidden Cases
Never allow these without explicit authorization:
1. Cross-Organization Data Access
// Bad: User from Org A accessing Org B's data
const user = await prisma.user.findFirst({
where: {
id: userId,
// Missing: organisationId check!
},
});2. Cross-Organization Role Assignment
// Bad: Assigning Org A's role to Org B's user
// Always verify role.organisationId === user.organisationIdOrganization Lifecycle
Create Organization
// POST /v1/organisations
{
"name": "Acme Corporation",
"slug": "acme",
"email": "[email protected]",
"ownerEmail": "[email protected]"
}Process:
- Create organization
- Create owner user
- Assign owner to organization
- Create default roles
- Send welcome email
Update Organization
// PATCH /v1/admin/organisation
{
"name": "New Name",
"sessionLifetime": 7200,
"requireMfa": true
}Requires: organisation:manage permission
Suspend Organization
// PATCH /v1/system/organisations/:id/suspend
{
"reason": "Payment failure"
}Effects:
- Set
status = 'suspended' - Invalidate all sessions
- Block new logins
- Allow read-only admin access
Delete Organization (Soft)
// DELETE /v1/system/organisations/:idProcess:
- Set
deletedAt = now() - Set
status = 'cancelled' - Revoke all sessions
- Revoke all tokens
- Schedule data export (GDPR compliance)
- Schedule permanent deletion (after retention period)
Multi-Tenant API Design
Request Headers
GET /v1/admin/users HTTP/1.1
Host: api.cerberus.local
X-Org-Domain: acme
Cookie: cerb_sid=...Headers:
X-Org-Domain- Organization slug (required for admin routes)Cookie- Session cookie (authentication)
Response Format
Include organization context when helpful:
{
"data": {
"id": "user_123",
"name": "John Doe",
"organisationId": "org_acme"
},
"meta": {
"organisation": {
"id": "org_acme",
"name": "Acme Corporation",
"slug": "acme"
}
}
}Error Responses
{
"type": "https://api.cerberus-iam.com/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Organisation 'invalid-slug' not found"
}Database Schema Patterns
Tenant-Scoped Tables
model User {
id String @id
organisationId String // Always include!
organisation Organisation @relation(fields: [organisationId])
@@index([organisationId]) // Always index!
@@unique([organisationId, email]) // Unique per org
}Characteristics:
organisationIdforeign key- Index on
organisationId - Unique constraints scoped to organization
Global Tables
model Permission {
id String @id
slug String @unique
// No organisationId
}Examples:
- Permissions
- Scopes
- JWKs (signing keys)
- System configuration
Performance Optimization
Database Indexes
Always index organisationId:
model User {
@@index([organisationId])
@@index([organisationId, email])
@@index([organisationId, deletedAt])
}Query Patterns
Use Composite Indexes:
// Good: Uses composite index
await prisma.user.findFirst({
where: {
organisationId: orgId,
email: '[email protected]',
},
});Avoid N+1 Queries:
// Bad: N+1 query
for (const user of users) {
user.organisation = await prisma.organisation.findUnique({
where: { id: user.organisationId },
});
}
// Good: Include in original query
const users = await prisma.user.findMany({
where: { organisationId: orgId },
include: { organisation: true },
});Security Considerations
1. Always Validate Tenant Context
// Verify session org matches tenant org
if (req.user.organisationId !== req.tenant.id) {
return forbidden('Organization mismatch');
}2. Prevent Tenant Enumeration
// Bad: Reveals org exists
if (!organisation) {
return notFound('Organisation not found');
}
// Good: Same response for invalid and unauthorized
return notFound('Resource not found');3. Rate Limit Per Organization
// Key by organization + IP
const rateLimitKey = `${req.tenant.id}:${req.ip}`;4. Audit Cross-Tenant Operations
// Log when org context changes
if (previousOrgId !== currentOrgId) {
await auditLog.create({
eventType: 'organisation.switched',
metadata: { from: previousOrgId, to: currentOrgId },
});
}Troubleshooting
Missing X-Org-Domain Header
Error: "Missing X-Org-Domain header"
Solutions:
- Add header to all admin API requests
- Configure API client to include header
- Use tenant middleware only on scoped routes
Wrong Organization Data
Problem: Seeing another organization's data
Solutions:
- Verify
organisationIdfilter in all queries - Check tenant middleware is applied
- Review query logs for missing filters
Performance Issues
Problem: Slow queries in multi-tenant setup
Solutions:
- Add indexes on
organisationId - Use composite indexes for common queries
- Consider partitioning large tables
- Monitor slow query log
Next Steps
- Authorization - Organization-scoped permissions
- Database - Multi-tenant schema patterns
- Production - Multi-tenant deployment