Lesson 15 / 32

Testing Tenant Isolation

Write automated tests that seed two tenants and prove that reads, writes, searches and exports never cross the tenant boundary.

By · 15 years in web development

Prove the negative

Most tests check that a feature works. Isolation tests check that something does not happen: tenant A must never see, change or infer tenant B's data. The basic recipe is to seed at least two tenants with overlapping-looking data (same project names, consecutive ids), act as tenant A, and assert that B's records are invisible through every path: list endpoints, get-by-id, search, exports, file downloads, webhooks and admin screens. Also test writes: updating or deleting B's record id while signed in as A must fail, preferably with not found rather than forbidden, so you do not confirm that the id exists. Run these tests in CI against the real database engine with the application's restricted role, and add one whenever a new endpoint or data store appears. Some teams also run periodic production checks with synthetic canary tenants.

A minimal cross-tenant test

Plain asserts so it runs anywhere; in a real project this would be a pytest test using fixtures for both tenants.

import sqlite3

def make_db():
    db = sqlite3.connect(":memory:")
    db.execute("CREATE TABLE notes (tenant_id TEXT, id INTEGER, body TEXT)")
    db.executemany("INSERT INTO notes VALUES (?, ?, ?)",
                   [("acme", 1, "acme secret"), ("zen", 2, "zen secret")])
    return db

def get_note(db, tenant_id, note_id):
    row = db.execute("SELECT body FROM notes WHERE tenant_id = ? AND id = ?",
                     (tenant_id, note_id)).fetchone()
    return row[0] if row else None

def test_cannot_read_other_tenant():
    db = make_db()
    assert get_note(db, "acme", 1) == "acme secret"
    assert get_note(db, "acme", 2) is None    # zen's note must be invisible
    assert get_note(db, "zen", 1) is None

test_cannot_read_other_tenant()
print("cross-tenant isolation test passed")

Output:

cross-tenant isolation test passed

Return 404, not 403

If tenant A requests /invoices/981 and that invoice belongs to tenant B, answering forbidden tells A that invoice 981 exists. Answer not found, exactly as you would for an id that does not exist at all.

Quick check: Which test best guards against cross-tenant leaks in a new search endpoint?

  • Check that search returns results within 200 ms
  • Search as tenant A for a term that exists only in tenant B's data and assert zero results
  • Search as an admin superuser and assert all results appear
  • Check that the search endpoint returns valid JSON
Answer

Search as tenant A for a term that exists only in tenant B's data and assert zero results — An isolation test seeds data in another tenant and proves it cannot be reached. Speed and JSON shape tests do not detect leaks, and superusers bypass the very rules you need to test.