Ensures tenant isolation at query and policy level using Row Level Security, automated testing, and security audits. Prevents data leakage between tenants. Use for "multi-tenancy", "tenant isolation", "RLS", or "data security".
Ensures tenant isolation at query and policy level using Row Level Security, automated testing, and security audits. Prevents data leakage between tenants. Use for "multi-tenancy", "tenant isolation", "RLS", or "data security".
Multi-tenant Safety Checker
Ensure complete tenant isolation and prevent data leakage.
Row Level Security (RLS)
PostgreSQL RLS Setup
-- Enable RLS on tablesALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
-- Create policy for users tableCREATE POLICY tenant_isolation_policy ON users
USING (tenant_id = current_setting('app.tenant_id')::INTEGER);
-- Create policy for orders tableCREATE POLICY tenant_isolation_policy ON orders
USING (tenant_id = current_setting('app.tenant_id')::INTEGER);
-- Create policy for products tableCREATE POLICY tenant_isolation_policy ON products
USING (tenant_id = current_setting()::);
users FORCE LEVEL SECURITY;
orders FORCE LEVEL SECURITY;
products FORCE LEVEL SECURITY;
'app.tenant_id'
INTEGER
-- Force RLS even for table owners
ALTER TABLE
ROW
ALTER TABLE
ROW
ALTER TABLE
ROW
Application-Level Tenant Context
// middleware/tenant-context.tsimport { PrismaClient } from"@prisma/client";
exportclassTenantContext {
constructor(privateprisma: PrismaClient) {}
asyncsetTenant(tenantId: number): Promise<void> {
awaitthis.prisma.$executeRaw`
SET LOCAL app.tenant_id = ${tenantId}
`;
}
async withTenant<T>(
tenantId: number,
callback: () =>Promise<T>
): Promise<T> {
returnthis.prisma.$transaction(async (tx) => {
// Set tenant context for this transactionawait tx.$executeRaw`SET LOCAL app.tenant_id = ${tenantId}`;
// Execute queries within tenant contextreturncallback();
});
}
}
// Usage in API route
app.get("/api/orders", async (req, res) => {
const tenantId = req.user.tenantId;
const orders = await tenantContext.withTenant(tenantId, async () => {
return prisma.order.findMany(); // Automatically filtered by RLS
});
res.json(orders);
});
Tenant Isolation Checklist
# Multi-tenant Security Checklist## Database Level- [ ] All tables have `tenant_id` column
- [ ] `tenant_id` is NOT NULL on all tables
- [ ] Foreign keys include tenant_id checks
- [ ] Row Level Security enabled on all tables
- [ ] RLS policies created for all tables
- [ ] RLS enforced even for table owners
- [ ] Composite indexes include tenant_id
## Application Level- [ ] Tenant context set on every request
- [ ] Tenant ID validated from JWT/session
- [ ] No raw SQL without tenant filter
- [ ] All queries include tenant_id (if no RLS)
- [ ] API endpoints validate tenant access
- [ ] File uploads scoped to tenant
- [ ] Background jobs include tenant context
## Testing
- [ ] Cross-tenant query tests
- [ ] RLS bypass attempt tests
- [ ] SQL injection with tenant bypass tests
- [ ] Automated regression tests
- [ ] Regular security audits
Automated Security Tests
// tests/tenant-isolation.test.tsimport { PrismaClient } from"@prisma/client";
describe("Tenant Isolation", () => {
letprisma: PrismaClient;
lettenant1Id: number;
lettenant2Id: number;
beforeAll(async () => {
prisma = newPrismaClient();
// Create test tenantsconst tenant1 = await prisma.tenant.create({
data: { name: "Tenant 1" },
});
const tenant2 = await prisma.tenant.create({
data: { name: "Tenant 2" },
});
tenant1Id = tenant1.id;
tenant2Id = tenant2.id;
// Create test dataawait prisma.user.create({
data: {
email: "user1@tenant1.com",
tenantId: tenant1Id,
},
});
await prisma.user.create({
data: {
email: "user2@tenant2.com",
tenantId: tenant2Id,
},
});
});
it("should not access data from other tenants", async () => {
// Set tenant context to Tenant 1await prisma.$executeRaw`SET app.tenant_id = ${tenant1Id}`;
// Query usersconst users = await prisma.user.findMany();
// Should only see Tenant 1 usersexpect(users.length).toBe(1);
expect(users[0].email).toBe("user1@tenant1.com");
// Should NOT see Tenant 2 usersexpect(users.find((u) => u.email === "user2@tenant2.com")).toBeUndefined();
});
it("should prevent cross-tenant updates", async () => {
await prisma.$executeRaw`SET app.tenant_id = ${tenant1Id}`;
// Try to update Tenant 2 user (should fail silently with RLS)const tenant2User = await prisma.user.findFirst({
where: { email: "user2@tenant2.com" },
});
// Should not find user from other tenantexpect(tenant2User).toBeNull();
});
it("should prevent cross-tenant deletes", async () => {
await prisma.$executeRaw`SET app.tenant_id = ${tenant1Id}`;
// Try to delete Tenant 2 userconst result = await prisma.user.deleteMany({
where: { tenantId: tenant2Id },
});
// Should delete 0 rows (RLS prevents access)expect(result.count).toBe(0);
// Verify user still existsawait prisma.$executeRaw`SET app.tenant_id = ${tenant2Id}`;
const user = await prisma.user.findFirst({
where: { email: "user2@tenant2.com" },
});
expect(user).not.toBeNull();
});
it("should handle transaction rollback correctly", async () => {
try {
await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SET LOCAL app.tenant_id = ${tenant1Id}`;
// Create userawait tx.user.create({
data: {
email: "test@tenant1.com",
tenantId: tenant1Id,
},
});
// Force errorthrownewError("Rollback test");
});
} catch (error) {
// Transaction rolled back
}
// User should not existawait prisma.$executeRaw`SET app.tenant_id = ${tenant1Id}`;
const user = await prisma.user.findFirst({
where: { email: "test@tenant1.com" },
});
expect(user).toBeNull();
});
});
RLS Audit Script
// scripts/audit-rls.tsasyncfunctionauditRLS() {
const tables = await prisma.$queryRaw<any[]>`
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public'
AND tablename != '_prisma_migrations'
`;
console.log("🔍 Auditing Row Level Security...\n");
for (const { tablename } of tables) {
// Check if table has tenant_idconst columns = await prisma.$queryRaw<any[]>`
SELECT column_name
FROM information_schema.columns
WHERE table_name = ${tablename}
AND column_name = 'tenant_id'
`;
if (columns.length === 0) {
console.log(`❌ ${tablename}: Missing tenant_id column`);
continue;
}
// Check if RLS is enabledconst rlsStatus = await prisma.$queryRaw<any[]>`
SELECT relname, relrowsecurity, relforcerowsecurity
FROM pg_class
WHERE relname = ${tablename}
`;
if (!rlsStatus[0]?.relrowsecurity) {
console.log(`❌ ${tablename}: RLS not enabled`);
continue;
}
if (!rlsStatus[0]?.relforcerowsecurity) {
console.log(`⚠️ ${tablename}: RLS not forced (owners can bypass)`);
}
// Check if policy existsconst policies = await prisma.$queryRaw<any[]>`
SELECT policyname, qual
FROM pg_policies
WHERE tablename = ${tablename}
`;
if (policies.length === 0) {
console.log(`❌ ${tablename}: No RLS policies defined`);
} else {
console.log(
`✅ ${tablename}: RLS configured (${policies.length} policies)`
);
}
}
}
Composite Indexes for Performance
-- Composite indexes with tenant_id firstCREATE INDEX idx_orders_tenant_user ON orders(tenant_id, user_id);
CREATE INDEX idx_orders_tenant_created ON orders(tenant_id, created_at DESC);
CREATE INDEX idx_products_tenant_category ON products(tenant_id, category);
-- This ensures queries filtered by tenant_id are fast-- SELECT * FROM orders WHERE tenant_id = 1 AND user_id = 123; -- Uses index