Multi-Tenant SaaS Testing: Data Isolation, RBAC, and Tenant Boundary Tests

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.

Read more