cross-tenant-data-leak-tests
Workflow-driven skill that plans and implements the cross-tenant leak-test suite - from surface inventory to the runtime CI gate a multi-tenant codebase must pass on every PR. The planning section inventories tenant-bearing surfaces (tables, APIs, object storage, search, queues, caches), classifies each by isolation model (silo / pool / bridge, per references/isolation-models.md), and derives the OWASP WSTG-ATHZ-02 coverage matrix. The battery defines the canonical test patterns (read-other-tenant-by-id, list-leak, spoofed-tenant-id-in-body, JWT-replay, FK-cross-tenant, unique-collision side channel, object-storage IDOR, search-index-direct-query, async-job-context-reload, cache-key-collision), the 404-vs-403 disclosure trade-off, the Postgres-RLS-direct patterns, and the CI integration (non-superuser non-BYPASSRLS role, fail the build on any leak). Use when designing or implementing a tenant-isolation test suite, adding the CI gate to an existing project, or investigating a leak finding.
Install with skills.sh (any agent)
npx skills add testland/qa --skill cross-tenant-data-leak-testscross-tenant-data-leak-tests
Overview
Plan first, then implement. The planning section below produces the surface × pattern coverage matrix; the numbered steps produce the executing tests - the actual code that fails the build when isolation breaks.
The contract:
When to use
Planning the leak-test suite
Every product has a different surface area, so the suite is built from an inventory, not a pre-canned set of tests.
Inventory tenant-bearing surfaces
Walk the codebase and enumerate every surface that should be tenant-scoped:
| Surface category | Examples | How to find |
|---|---|---|
| Database tables | tables with tenant_id column | grep -r "tenant_id" --include="*.sql" ; ORM model field annotations |
| API endpoints | routes returning tenant data | route registrations grep |
| Object storage | buckets / prefixes per tenant | IaC for buckets, lifecycle config |
| Search indices | tenant-routed Elasticsearch / Algolia | index-naming scheme |
| Async messages | tenant_id in message attributes / payload | message-class definitions |
| Caches | Redis keys with tenant_id prefix | cache-client wrappers |
| Logs / metrics | log lines containing tenant_id | log-emit grep |
| Background jobs | Sidekiq / Celery tasks taking tenant_id | task definitions |
| Reports / exports | tenant-scoped reports | export endpoints |
| Webhooks / outbound | tenant-routed external calls | webhook configuration |
Classify each surface by isolation model - silo / pool / bridge / vertically-partitioned, per references/isolation-models.md. The test surface depends on the lowest isolation level in the stack: pool surfaces get the canonical battery below, bridge surfaces get cross-database-routing tests, silo surfaces get tenant-to-deployment routing tests (and the shared management surface still gets the pool battery).
Enumerate attack patterns per surface
Per OWASP WSTG-ATHZ-02 (owasp.org/www-project-web-security-testing-guide/v42/4-Web_Application_Security_Testing/05-Authorization_Testing/02-Testing_for_Bypassing_Authorization_Schema (opens in new window)), three primary scenarios: horizontal escalation (tenant A accesses tenant B's data at identical privilege), vertical escalation (non-admin reaches admin-only resources), and IDOR / BOLA (direct-reference attack on any ID-bearing endpoint). Layer the tenant-specific patterns (spoofed-body tenant_id, cross-tenant FK / unique-constraint, JWT replay, storage path traversal, unfiltered search, async-job context, cache-key collision) on top - the full test-per-pattern catalog is in references/attack-patterns.md.
Generate test cases and fixtures
One or more test cases per (surface, pattern) cell, named test_<surface>_<pattern>_<expected>(). Each test fixtures two disjoint tenants with users of identical privilege (tenant_a, tenant_b, tenant_a_user, tenant_b_user, tenant_a_admin, tenant_a_resource, tenant_b_resource), performs the cross-access attempt, and asserts denial. Pick the runner by stack (pytest / Jest+Supertest / JUnit 5+Testcontainers / Go httptest / RSpec); ready-made skeletons are in references/framework-skeletons.md.
Track coverage
| horiz | vert | IDOR | jwt | fk | cache | log
documents | X | X | X | X | - | X | -
attachments | X | - | X | X | - | - | -
search_index | X | - | X | X | - | - | -
audit_log | X | - | - | - | - | - | XGenerate the matrix from the surface inventory × the pattern list. Empty cells are coverage gaps the PR must justify. The planning output - surface inventory, coverage matrix, suite skeleton, and a test-directory README - is committed to the project repo; the steps below implement the battery.
Step 1 - Choose the test runner and DB connection role
For Postgres-backed apps, this is the most-common-bug step:
| Connection used in test | Result |
|---|---|
| Superuser | Bypasses RLS - tests pass, prod leaks. Do not use. |
Role with BYPASSRLS attribute | Same as superuser. Do not use. |
Role that owns the tenant table (and table not FORCEd) | Bypasses RLS. Do not use unless FORCE ROW LEVEL SECURITY is set. |
| Plain application role (no BYPASSRLS, not owner) | Correct - same role prod uses. |
Per rls-reference, verify with:
SELECT rolname, rolsuper, rolbypassrls FROM pg_roles
WHERE rolname = current_user;
-- Expect: rolsuper=f, rolbypassrls=fFor Django, set DATABASES['default']['USER'] to the app role in the test settings. For Rails, config/database.yml test section. For Spring Boot, spring.datasource.username in application-test.yml.
Step 2 - The canonical battery
These tests should exist for every tenant-bearing API surface.
Test 1 - Read-other-tenant-by-id
def test_get_other_tenant_resource_returns_404(
self, client, tenant_a_user, tenant_b_resource
):
client.force_login(tenant_a_user)
resp = client.get(f"/api/documents/{tenant_b_resource.id}/")
assert resp.status_code == 404 # not 403 - avoid existence disclosureConvention: return 404, not 403, for resources the requester can't access in another tenant. 403 leaks existence (tenant A learns tenant B has resource with this ID). The trade-off: debugging slightly harder. Document the project's choice.
Test 2 - List-leak
def test_list_does_not_include_other_tenant_resources(
self, client, tenant_a_user, tenant_b_resource
):
client.force_login(tenant_a_user)
resp = client.get("/api/documents/")
assert resp.status_code == 200
ids = {d["id"] for d in resp.json()["results"]}
assert tenant_b_resource.id not in idsEven with RLS enforcing visibility, application-layer caches can leak. Test against fresh queries.
Test 3 - Spoofed-tenant-id-in-body
def test_tenant_id_in_body_ignored_or_rejected(
self, client, tenant_a_user, tenant_b
):
client.force_login(tenant_a_user)
resp = client.post("/api/documents/", json={
"tenant_id": str(tenant_b.id),
"body": "leak"
})
if resp.status_code == 201:
created = resp.json()
assert created["tenant_id"] != str(tenant_b.id), \
"Server accepted tenant_id from body - must derive from session"Test 4 - JWT replay across tenants
def test_jwt_for_tenant_a_rejected_on_tenant_b_path(
self, client, tenant_a_user, tenant_b_resource
):
token = sign_jwt(tenant_a_user)
resp = client.get(
f"/api/documents/{tenant_b_resource.id}/",
HTTP_AUTHORIZATION=f"Bearer {token}"
)
assert resp.status_code in (401, 404)If the API has tenant-scoped paths (/api/tenants/<id>/...), also test that tenant A's JWT cannot be used with tenant B's path even with a valid signature - the tenant_id claim must be checked against the path.
Test 5 - FK cross-tenant
def test_cannot_create_fk_referencing_other_tenant(
self, client, tenant_a_user, tenant_b_resource
):
client.force_login(tenant_a_user)
# tenant_b_resource exists, but A shouldn't reference it
resp = client.post("/api/comments/", json={
"document_id": str(tenant_b_resource.id),
"body": "comment on other tenant's doc"
})
assert resp.status_code in (400, 404)This tests FK-based leak via reference: the FK constraint bypasses RLS per rls-reference, so the FK must be validated at application layer too.
Test 6 - Unique-collision side channel
def test_unique_violation_does_not_disclose_other_tenant_existence(
self, client, tenant_a_user, tenant_b_resource_with_slug
):
client.force_login(tenant_a_user)
# Try to create with same slug as tenant B
resp = client.post("/api/documents/", json={
"slug": tenant_b_resource_with_slug.slug,
"body": "x"
})
# Should succeed (RLS scopes the unique check to tenant A)
# or fail with a non-disclosing error if unique is global
if resp.status_code in (409, 422):
assert "tenant_b" not in resp.text.lower()
assert tenant_b_resource_with_slug.id not in resp.textPer rls-reference: "Foreign key constraint checks, Unique constraint checks, TRUNCATE, and REFERENCES privilege checks bypass RLS." Solution: make slug unique per tenant: UNIQUE (tenant_id, slug).
Test 7 - Object-storage IDOR
def test_storage_presigned_url_path_traversal_denied(
self, client, tenant_a_user, tenant_b_resource
):
client.force_login(tenant_a_user)
presigned = client.get(
f"/api/documents/{tenant_a_user.tenant_id}/file/"
).json()["url"]
# Modify the URL to point at tenant B's prefix
leaked_url = presigned.replace(
str(tenant_a_user.tenant_id),
str(tenant_b_resource.tenant_id)
)
resp = requests.get(leaked_url)
assert resp.status_code == 403The S3 / GCS bucket policy must enforce the prefix - application code is not sufficient.
Test 8 - Search-index direct query
def test_search_query_must_include_tenant_filter(
self, opensearch_client, tenant_a, tenant_b_resource
):
# Direct ES query without tenant filter (simulating leaked path)
result = opensearch_client.search(
index="documents",
body={"query": {"match_all": {}}}
)
# Test asserts the API endpoint always adds a tenant filter.
# The DIRECT search above should not be reachable from any API path.
# This test ensures no route exists that issues unfiltered search.
for route in app.url_map.iter_rules():
if "search" in route.endpoint:
assert "@tenant_required" in inspect.getsource(
app.view_functions[route.endpoint]
)Test 9 - Async-job context-reload
def test_async_job_reloads_tenant_from_db_not_payload(
self, tenant_a, tenant_b_resource
):
# Enqueue a job with a payload pointing at tenant B's resource
job = enqueue_export(
resource_id=tenant_b_resource.id,
# Crafted to spoof - but executor must verify
tenant_id_claim=tenant_a.id
)
result = run_job(job)
# Executor must derive tenant_id from resource_id, not the payload
assert result.tenant_id == tenant_b_resource.tenant_idTest 10 - Cache key collision
def test_cache_keys_are_tenant_scoped(self, cache, tenant_a, tenant_b):
cache.set("user:1", "tenant_a_data", tenant_id=tenant_a.id)
cache.set("user:1", "tenant_b_data", tenant_id=tenant_b.id)
assert cache.get("user:1", tenant_id=tenant_a.id) == "tenant_a_data"
assert cache.get("user:1", tenant_id=tenant_b.id) == "tenant_b_data"Cache wrappers must prepend tenant_id to every key.
Postgres-RLS-direct (test 1 - 6 at DB layer)
Run these as the application role (not superuser):
BEGIN;
SET LOCAL ROLE app_user;
SET LOCAL app.tenant_id = '<tenant_a_uuid>';
INSERT INTO documents (id, tenant_id, body)
VALUES (gen_random_uuid(), current_setting('app.tenant_id')::uuid, 'a');
SET LOCAL app.tenant_id = '<tenant_b_uuid>';
SELECT count(*) FROM documents; -- expect 0
-- Cross-tenant INSERT
INSERT INTO documents (tenant_id, body) VALUES ('<tenant_a_uuid>', 'leak');
-- Expect: ERROR: new row violates row-level security policy for table "documents"
ROLLBACK;Per rls-reference: test fails if either assertion fails (count != 0, or INSERT succeeds).
Step 3 - CI integration
# .github/workflows/tenant-isolation.yml
name: tenant-isolation
on:
pull_request:
paths:
- "**/*.py"
- "**/migrations/**"
- ".github/workflows/tenant-isolation.yml"
jobs:
cross-tenant-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
ports:
- 5432:5432
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install
run: pip install -e ".[test]"
- name: Create non-superuser role
env:
PGPASSWORD: postgres
run: |
psql -h localhost -U postgres -d postgres -c "
CREATE ROLE app_user LOGIN PASSWORD 'app';
"
- name: Apply migrations
env:
DATABASE_URL: postgresql://postgres:postgres@localhost/test
run: python manage.py migrate
- name: Run cross-tenant suite
env:
DATABASE_URL: postgresql://app_user:app@localhost/test
run: pytest tests/tenant_isolation/ --tb=short --no-header -vKey: the test job connects as app_user, not as the postgres superuser. The migrations run as superuser; the tests run as the application role.
Step 4 - Diagnose a failure
When a leak test fails:
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Tests connect as superuser | RLS bypassed; tests pass falsely | Use prod-equivalent role |
| Migrations and tests use same role | Migrations need DDL; tests should not | Two roles: migrations user (owner), app user (RLS-bound) |
| Test setup creates tenants via direct SQL bypassing RLS | OK in setup, but reset tenant context before assertions | SET LOCAL between fixture creation and test body |
| 403 used instead of 404 for unauthorised cross-tenant resources | Leaks existence | Use 404 (and document) |
| Single test for "tenant isolation works" | Insufficient coverage | One test per (surface, pattern) cell |
| Skipping FK/UNIQUE side-channel tests | Real production bugs hide here | Always test FK + unique cross-tenant |
| No async-job context-reload test | Job runners often trust payload | Always test |
| Cache without tenant prefix | Tenant A and B alias same logical key | Verify key generation in test |
| Suite not part of CI | Catches nothing | Block merge on failure |
Limitations
References
Tenant-leak attack patterns
View source (opens in new window)Tenant-leak attack patterns
OWASP WSTG-ATHZ-02 scenarios
Three primary authorization-bypass scenarios apply to every pool/bridge surface:
| Pattern | What | Surface |
|---|---|---|
| Horizontal escalation | Tenant A accesses tenant B's data at identical privilege | All pool/bridge surfaces |
| Vertical escalation | Non-admin in tenant A accesses admin-only resources | All admin-scoped surfaces |
| IDOR / BOLA | Direct reference attack - change an ID in URL/payload | All ID-bearing endpoints |
Tenant-isolation-specific patterns
| Pattern | Test |
|---|---|
| tenant_id from request payload | Send tenant A's session with tenant_id=B in body - must reject |
| Missing tenant_id filter in new endpoint | Enumerate routes added in last N commits; verify each filters by tenant |
| Cross-tenant via foreign key | Create FK from tenant-A row to tenant-B row - must fail |
| Cross-tenant via unique constraint | Insert tenant-A row with a key that exists in tenant B - observe error timing as a side channel |
| JWT replay across tenants | Tenant A's JWT used to call tenant B's endpoint - must reject on signature/iss/aud check |
| Object storage path traversal | Tenant A presigned URL -> modify prefix to tenant B's - must 403 |
| Search query without tenant filter | Direct search index query - must include the tenant routing key |
| Async job tenant context | Job enqueued by tenant A -> executor must reload tenant context, not trust the message |
| Cache key collision | Tenant A and tenant B have the same logical key - cache must namespace |
| Log scrubbing | Tenant A errors must not leak tenant B identifiers |
Source: OWASP WSTG-ATHZ-02 Testing for Bypassing Authorization Schema owasp.org/www-project-web-security-testing-guide/v42/4-Web_Application_Security_Testing/05-Authorization_Testing/02-Testing_for_Bypassing_Authorization_Schema (opens in new window).
Tenant-leak test skeletons
View source (opens in new window)Tenant-leak test skeletons
pytest - full horizontal-escalation battery
import pytest
class TestDocumentsTenantIsolation:
"""Per OWASP WSTG-ATHZ-02 - horizontal escalation battery."""
def test_tenant_a_cannot_read_tenant_b_document(
self, client, tenant_a_user, tenant_b_resource
):
# Authenticate as tenant A user
client.force_login(tenant_a_user)
# Attempt to access tenant B's resource by ID
response = client.get(f"/api/documents/{tenant_b_resource.id}/")
assert response.status_code == 404, "Must return 404, not 403, to avoid existence disclosure"
def test_tenant_a_cannot_list_tenant_b_documents(
self, client, tenant_a_user, tenant_b_resource
):
client.force_login(tenant_a_user)
response = client.get("/api/documents/")
assert response.status_code == 200
ids = {d["id"] for d in response.json()["results"]}
assert tenant_b_resource.id not in ids
def test_tenant_id_in_body_is_ignored(
self, client, tenant_a_user, tenant_b
):
client.force_login(tenant_a_user)
# Attempt to create a document for tenant B by spoofing the body
response = client.post(
"/api/documents/",
data={"tenant_id": str(tenant_b.id), "body": "leak"}
)
# Must be rejected (400) or silently scoped to A (201 with A's tenant_id)
if response.status_code == 201:
doc = response.json()
assert doc["tenant_id"] != str(tenant_b.id)
def test_jwt_signed_for_a_rejected_on_b_endpoint(
self, client, tenant_a_user, tenant_b_resource
):
# Sign a JWT for tenant A user, use it on a B-scoped endpoint
token = sign_jwt_for(tenant_a_user)
response = client.get(
f"/api/documents/{tenant_b_resource.id}/",
HTTP_AUTHORIZATION=f"Bearer {token}"
)
assert response.status_code in (401, 404)Postgres RLS-direct (language-agnostic)
For surfaces relying on RLS per rls-reference, also test at the DB layer:
-- Connect as app_user (not superuser, not table owner)
BEGIN;
SET LOCAL app.tenant_id = '<tenant_a_uuid>';
-- Insert a row for tenant A
INSERT INTO documents (tenant_id, body) VALUES (current_setting('app.tenant_id')::uuid, 'a-doc');
-- Switch to tenant B
SET LOCAL app.tenant_id = '<tenant_b_uuid>';
SELECT count(*) FROM documents; -- expect 0 (tenant A's row invisible)
-- Cross-tenant INSERT attempt
INSERT INTO documents (tenant_id, body) VALUES ('<tenant_a_uuid>', 'leak');
-- Expect: ERROR: new row violates row-level security policy for table "documents"
ROLLBACK;Tenant-isolation models
View source (opens in new window)Tenant-isolation models
Companion reference for cross-tenant-data-leak-tests. The isolation model in use (silo / pool / bridge / vertically-partitioned) decides which test surfaces the planning section must cover.
Overview
Tenant isolation is the foundational concern of every B2B SaaS architecture: the AWS Well-Architected SaaS Lens calls it essential and treats crossing a tenant boundary as a significant, potentially unrecoverable event for a SaaS business. Isolation is a continuum, not a binary - Microsoft's Azure Architecture Center frames it as a spectrum from shared-nothing to everything-shared, with architectures often picking different points per tier (UI shared, app shared, data isolated). This is a pure reference consumed by the leak-test planning section and the tenant-leak critic; it executes nothing.
When to use
Tenant vs deployment
A tenant is a logical customer boundary; a deployment (also called a stamp or supertenant) is a physical set of infrastructure. One deployment can host many tenants (shared model), or each tenant can have its own deployment (silo). The tenant-to-deployment mapping is durable state: a routing table must exist somewhere so requests reach the right deployment.
The four canonical models
| Model | Compute | Data | Network | Cost/tenant | Blast radius | Noisy neighbor |
|---|---|---|---|---|---|---|
| Automated single-tenant (silo) | Dedicated | Dedicated | Dedicated | Highest | One tenant | None |
| Fully multitenant (pool) | Shared | Shared (tenant_id discriminator) | Shared | Lowest | All tenants | High |
| Horizontally partitioned (bridge) | Shared | Dedicated per tenant | Shared | Medium | Data isolated | Data tier: none |
| Vertically partitioned | Mix | Mix | Mix | Mixed | Per-tier | Per-tier |
Per-model when-to-choose guidance, the sourced Microsoft framing, and each model's test surface are in models.md (opens in new window).
Isolation enforcement primitives
Tenant isolation is implemented by combining:
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
tenant_id filter only in application code | One missed query path = cross-tenant leak | Push the filter to the database (RLS) or row-attribute IAM |
tenant_id from request header / body | Spoofable; tenant A can claim to be tenant B | Always derive tenant_id from authenticated JWT/session, never from request payload |
Trust the JWT raw_user_meta_data for tenant claims | User-modifiable per Supabase docs | Use raw_app_meta_data (server-set) or a server-side claim store |
| Single connection pool for all tenants | One slow tenant query blocks all | Per-tenant pools, or quota-aware pools |
| Shared object-storage bucket without prefix isolation | Object enumeration leaks across tenants | Per-tenant prefix + IAM condition on the prefix |
| No isolation tests in CI | Models drift over time | Cross-tenant leak tests in every PR per cross-tenant-data-leak-tests |
| Migration scripts run without tenant context | Schema changes touch all tenants at once; high blast radius | Stamp pattern with progressive rollout |
Test surface
The required test categories per model, plus the per-tier isolation mapping, are in test-surfaces.md (opens in new window). The cross-tenant data leak suite is the universal floor: even silo deployments share some surface (account-management APIs, billing, identity providers) where pool-like leaks are possible.
Limitations
References
The four canonical tenant-isolation models
View source (opens in new window)The four canonical tenant-isolation models
Naming across frameworks: Microsoft's automated-single-tenant / fully-multitenant / horizontally-partitioned / vertically-partitioned; AWS Well-Architected's silo / pool / bridge. Deployments are also called stamps or supertenants.
1. Automated single-tenant (silo / fully-isolated)
| Property | Value |
|---|---|
| Compute | Dedicated per tenant |
| Data | Dedicated per tenant |
| Network | Dedicated per tenant |
| Cost per tenant | Highest |
| Blast radius | One tenant |
| Noisy neighbor | None |
Microsoft's framing: deploying a dedicated set of infrastructure per tenant isolates each tenant's data and reduces the risk of accidental leakage.
When to choose: regulated industries with strong isolation mandates (healthcare HIPAA, financial services, government); a small number of high-value enterprise customers; per-tenant configuration is part of the value proposition.
Test surface: deployment automation (the Deployment Stamps pattern); cross-deployment operations like reporting; tenant-to-deployment routing.
2. Fully multitenant (pool / fully-shared)
| Property | Value |
|---|---|
| Compute | Shared |
| Data | Shared (single DB with tenant_id discriminator) |
| Network | Shared |
| Cost per tenant | Lowest |
| Blast radius | All tenants |
| Noisy neighbor | High |
Microsoft's risk framing: separate each tenant's data and don't leak across tenants; a large tenant running a heavy query or operation might affect other tenants.
When to choose: a large number of low-margin customers; high operational efficiency required; tenants accept shared infrastructure.
Test surface: cross-tenant data leak (the canonical risk), tenant_id propagation through every code path, noisy-neighbor behaviour, resource quotas per tenant.
3. Horizontally partitioned (bridge)
| Property | Value |
|---|---|
| Compute | Shared |
| Data | Dedicated per tenant |
| Network | Shared |
| Cost per tenant | Medium |
| Blast radius | App-tier shared, data isolated |
| Noisy neighbor | App-tier yes, data tier no |
Microsoft's framing: a single application tier with an individual database per tenant, which mitigates the noisy-neighbor problem in the data tier.
When to choose: data isolation matters for compliance, but shared compute is acceptable; data-tier noisy neighbors are the dominant failure mode (heavy queries, large indexes).
Test surface: correct database routing per tenant; connection-pool exhaustion under tenant concurrency; cross-DB query attempts must fail.
4. Vertically partitioned
| Property | Value |
|---|---|
| Compute | Mix (some tenants dedicated, others shared) |
| Data | Mix |
| Network | Mix |
| Cost per tenant | Mixed |
| Blast radius | Per-tier decision |
| Noisy neighbor | Per-tier |
Microsoft's framing: a combination of single-tenant and multitenant deployments - most customers' data and application tiers on multitenant infrastructure, with single-tenant infrastructure for customers who require higher performance or data isolation. Includes geographic partitioning (one deployment per region, tenants mapped to the nearest region).
When to choose: the majority of customers fit the shared model, but a minority need silo (enterprise tier); geographic data-residency requirements.
Test surface: every test from the shared model plus every test from the silo model; tenant migration between tiers; pricing tier enforcement.
Source: Microsoft Azure Architecture Center - Tenancy Models learn.microsoft.com/en-us/azure/architecture/guide/multitenant/considerations/tenancy-models (opens in new window).
Test surface by tenant-isolation model
View source (opens in new window)Test surface by tenant-isolation model
Isolation tier mapping
A common pattern is independent isolation per architecture tier:
| Tier | Common choice |
|---|---|
| UI | Shared host name (fully multitenant) |
| API gateway | Shared, with tenant claim in JWT |
| Application services | Shared, tenant context in every request |
| Async queues / topics | Shared topic with tenant_id message attribute, or per-tenant queue |
| Data | Often partitioned: tables with tenant_id (pool); schemas per tenant (bridge); databases per tenant (silo) |
| Object storage | Per-tenant prefix in bucket (pool); bucket per tenant (silo) |
| Search index | Per-tenant routing key (pool); index per tenant (silo) |
The test surface depends on the lowest isolation level in the stack. A fully isolated UI but shared database still requires the full cross-tenant data-leak test battery against the database.
Required test categories per model
| Model | Required test categories |
|---|---|
| Silo / single-tenant | Tenant-to-deployment routing; per-deployment health; deployment automation |
| Pool / fully-shared | Cross-tenant data leak (highest priority); tenant_id propagation; noisy-neighbor mitigation; quota enforcement |
| Bridge / horizontal | Pool tests + database routing per tenant; cross-database query rejection |
| Vertical | Pool + silo tests + tier-migration tests |
The cross-tenant data leak suite is the universal floor: even silo deployments share some surface (account-management APIs, billing, identity providers) where pool-like leaks are possible.
Related skills
rls-reference
Pure-reference catalog of row-level security for tenant isolation, Postgres-first. Covers enabling RLS (ALTER TABLE ... ENABLE ROW LEVEL SECURITY, default-deny semantics), CREATE POLICY syntax (USING vs WITH CHECK clauses, FOR SELECT/INSERT/UPDATE/DELETE/ALL, permissive vs restrictive, TO role_name), bypassing RLS (superuser / BYPASSRLS / table owner / FORCE ROW LEVEL SECURITY), tenant context patterns (current_user, current_setting, JWT claims via Supabase auth.uid() / auth.jwt()), and performance discipline (wrapping auth functions in SELECT, index on policy-referenced columns). Row/tenant isolation on the non-Postgres engines - MySQL / MariaDB invoker views, CockroachDB native RLS, Vitess vindex sharding, SQL Server security policies - lives in references/other-engines.md. Use as the RLS-pattern reference for tenant isolation on any of these engines. Consumed by cross-tenant-data-leak-tests.
tenant-onboarding-test-author
Workflow-driven skill that authors a test suite for tenant provisioning and offboarding: account creation, isolation at creation (no cross-tenant bleed from a new tenant's first API call), default resource quotas, billing record linkage, seed and default data correctness, idempotent re-provisioning, and teardown with full data deletion. Walks through mapping provisioning surfaces, generating test cases per surface, emitting the test suite skeleton (pytest / Jest / JUnit / Go test), and producing a coverage matrix. Use when a new tenant onboarding flow is introduced or changed, when the offboarding pipeline is modified, or when auditing provisioning coverage before a compliance review. Distinct from cross-tenant-data-leak-tests (leak-test planning + runtime CI gate): this skill covers the provisioning lifecycle, not steady-state access control.