Multi-Tenant SaaS Testing: Data Isolation, RBAC, and Tenant Boundary Tests
Multi-tenant SaaS applications share infrastructure across customers while keeping their data completely isolated. The testing challenge is proving that isolation works: tenant A cannot read, write, or affect tenant B's data, regardless of how they try.
The Core Risk
Cross-tenant data leakage is the most critical bug class in multi-tenant systems. It can be:
- Accidental: missing tenant filter in a database query
- Horizontal privilege escalation: tenant A guessing tenant B's resource IDs
- Context propagation bugs: tenant context from request A leaking into request B in async code
Test Setup: Multiple Tenant Fixtures
Always test with at least two tenants in scope:
// test fixtures
const tenantA = { id: 'tenant-a', slug: 'company-a' }
const tenantB = { id: 'tenant-b', slug: 'company-b' }
const userA = { id: 'user-a1', tenantId: 'tenant-a', role: 'admin' }
const userB = { id: 'user-b1', tenantId: 'tenant-b', role: 'admin' }Data Isolation Tests
The most important tests verify that data from one tenant is never visible to another.
describe('Data isolation', () => {
beforeEach(async () => {
// Create data for both tenants
await db.projects.create({ id: 'proj-a1', tenantId: 'tenant-a', name: 'Alpha Project' })
await db.projects.create({ id: 'proj-b1', tenantId: 'tenant-b', name: 'Beta Project' })
})
test('tenant A cannot list tenant B projects', async () => {
const response = await request(app)
.get('/api/projects')
.set('Authorization', `Bearer ${tokenForUser(userA)`)
const projects = response.body.data
expect(projects).toHaveLength(1)
expect(projects[0].name).toBe('Alpha Project')
expect(projects.some(p => p.tenantId === 'tenant-b')).toBe(false)
})
test('tenant A cannot access tenant B project by ID', async () => {
const response = await request(app)
.get('/api/projects/proj-b1') // B's project
.set('Authorization', `Bearer ${tokenForUser(userA)`)
// Should return 404, not 403 (don't reveal existence)
expect(response.status).toBe(404)
})
test('tenant A cannot update tenant B project', async () => {
const response = await request(app)
.patch('/api/projects/proj-b1')
.set('Authorization', `Bearer ${tokenForUser(userA)`)
.send({ name: 'Hacked' })
expect(response.status).toBe(404)
const unchanged = await db.projects.findById('proj-b1')
expect(unchanged.name).toBe('Beta Project')
})
test('tenant A cannot delete tenant B project', async () => {
const response = await request(app)
.delete('/api/projects/proj-b1')
.set('Authorization', `Bearer ${tokenForUser(userA)`)
expect(response.status).toBe(404)
const stillExists = await db.projects.findById('proj-b1')
expect(stillExists).toBeTruthy()
})
})RBAC Tests
Role-based access control within a tenant:
const adminUser = { ...userA, role: 'admin' }
const memberUser = { id: 'user-a2', tenantId: 'tenant-a', role: 'member' }
const viewerUser = { id: 'user-a3', tenantId: 'tenant-a', role: 'viewer' }
describe('RBAC within tenant', () => {
test('admin can delete projects', async () => {
const response = await request(app)
.delete('/api/projects/proj-a1')
.set('Authorization', `Bearer ${tokenForUser(adminUser)`)
expect(response.status).toBe(200)
})
test('member cannot delete projects', async () => {
const response = await request(app)
.delete('/api/projects/proj-a1')
.set('Authorization', `Bearer ${tokenForUser(memberUser)`)
expect(response.status).toBe(403)
})
test('viewer cannot create projects', async () => {
const response = await request(app)
.post('/api/projects')
.set('Authorization', `Bearer ${tokenForUser(viewerUser)`)
.send({ name: 'New Project' })
expect(response.status).toBe(403)
})
test('viewer can list projects', async () => {
const response = await request(app)
.get('/api/projects')
.set('Authorization', `Bearer ${tokenForUser(viewerUser)`)
expect(response.status).toBe(200)
})
})Tenant Context Propagation Tests
Async code (background jobs, event handlers) can accidentally propagate tenant context from one request to another:
test('tenant context does not leak between concurrent requests', async () => {
const results = await Promise.all([
request(app).get('/api/projects').set('Authorization', `Bearer ${tokenForUser(userA)}`),
request(app).get('/api/projects').set('Authorization', `Bearer ${tokenForUser(userB)}`),
])
const [responseA, responseB] = results
// Each response should only contain their own tenant's data
const projectsA = responseA.body.data
const projectsB = responseB.body.data
expect(projectsA.every(p => p.tenantId === 'tenant-a')).toBe(true)
expect(projectsB.every(p => p.tenantId === 'tenant-b')).toBe(true)
})Database Row-Level Security Tests
If using PostgreSQL RLS:
test('RLS prevents direct DB access across tenants', async () => {
// Simulate direct DB query with tenant A's context
await db.raw('SET app.tenant_id = ?', ['tenant-a'])
const projects = await db('projects').select('*')
expect(projects.every(p => p.tenant_id === 'tenant-a')).toBe(true)
expect(projects.some(p => p.tenant_id === 'tenant-b')).toBe(false)
})Billing Isolation Tests
describe('Billing isolation', () => {
test('tenant A usage does not affect tenant B billing', async () => {
// Create 10 resources for tenant A (may hit quota)
for (let i = 0; i < 10; i++) {
await request(app)
.post('/api/projects')
.set('Authorization', `Bearer ${tokenForUser(userA)`)
.send({ name: `Project ${i}` })
}
// Tenant B should still be able to create resources
const response = await request(app)
.post('/api/projects')
.set('Authorization', `Bearer ${tokenForUser(userB)`)
.send({ name: 'B Project' })
expect(response.status).toBe(201)
})
test('invoice only contains tenant A charges', async () => {
await createChargeForTenant('tenant-a', 100)
await createChargeForTenant('tenant-b', 200)
const invoice = await getInvoiceForTenant('tenant-a')
expect(invoice.total).toBe(100)
expect(invoice.items.every(i => i.tenantId === 'tenant-a')).toBe(true)
})
})Tenant Enumeration Attack Tests
Prevent tenants from discovering each other's existence:
test('cannot enumerate tenant slugs', async () => {
// Tenant B trying to access tenant A's subdomain
const response = await request(app)
.get('/api/tenant-info')
.set('Host', 'company-a.myapp.com') // A's subdomain
.set('Authorization', `Bearer ${tokenForUser(userB)`)
expect(response.status).toBe(403)
})
test('error messages do not reveal tenant existence', async () => {
const response = await request(app)
.get('/api/projects/proj-b1')
.set('Authorization', `Bearer ${tokenForUser(userA)`)
// Should say "not found", not "forbidden" (which reveals the resource exists)
expect(response.status).toBe(404)
expect(response.body.error).not.toContain('forbidden')
expect(response.body.error).not.toContain('access denied')
})API Rate Limiting Per Tenant
test('rate limit applies per tenant, not globally', async () => {
// Exhaust tenant A's rate limit
const requests = Array.from({ length: 100 }, () =>
request(app).get('/api/projects').set('Authorization', `Bearer ${tokenForUser(userA)`)
)
await Promise.all(requests)
// Tenant A should be rate limited
const limitedResponse = await request(app)
.get('/api/projects')
.set('Authorization', `Bearer ${tokenForUser(userA)`)
expect(limitedResponse.status).toBe(429)
// Tenant B should NOT be affected
const bResponse = await request(app)
.get('/api/projects')
.set('Authorization', `Bearer ${tokenForUser(userB)`)
expect(bResponse.status).toBe(200)
})Test Matrix
Every endpoint needs testing across this matrix:
| User | Accessing Own Data | Accessing Other Tenant Data |
|---|---|---|
| Admin | ✓ should work | ✗ should 404 |
| Member | ✓ if permitted | ✗ should 404 |
| Viewer | ✓ read-only | ✗ should 404 |
| Unauthenticated | ✗ should 401 | ✗ should 401 |
Summary
Multi-tenant testing requires deliberately attempting cross-tenant operations and asserting they fail. Create fixtures for at least two tenants in every test file. Test all CRUD operations with the wrong tenant's credentials. Verify that error messages reveal nothing about tenant existence. Test that rate limits and quotas are scoped per tenant, not globally. Any test that only uses one tenant is incomplete.