tenant-leak-test-author
Workflow-driven skill that builds a tenant-leak test plan from an inventory of tenant-bearing surfaces (database tables, APIs, object storage, search indices, async messages) and the isolation model in use. Walks through identifying tenant-bearing surfaces, enumerating the attack patterns per OWASP WSTG-ATHZ-02 (horizontal escalation, vertical escalation, IDOR / BOLA), generating test cases that exercise each pattern against each surface, and emitting the test suite skeleton (pytest / Jest / JUnit / Go test) with explicit cross-tenant probes. Use when designing a multi-tenant test suite for a new feature, when auditing test coverage for an existing tenant boundary, or when reviewing PRs that add tenant-bearing surfaces. Distinct from cross-tenant-data-leak-tests which is the runtime gate; this skill produces the plan.
Install with skills.sh (any agent)
npx skills add testland/qa --skill tenant-leak-test-authortenant-leak-test-author
Overview
A tenant-leak test suite is the runtime guarantee that cross-tenant access fails. This skill builds that suite from an inventory of tenant-bearing surfaces - not a pre-canned set of tests, since every product has a different surface area. Steps 1 - 5 below run the workflow end to end; the output is committed to the project repo, and cross-tenant-data-leak-tests describes the runtime contract those tests must satisfy.
When to use
Step 1 - Inventory tenant-bearing surfaces
Walk the codebase and enumerate every surface that should be tenant-scoped. Categorise:
| 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 |
Per tenant-isolation-models-reference: "The test surface depends on the lowest isolation level in the stack." A fully-isolated UI on a shared database still needs the full DB-leak battery.
Decision point: classify each surface as pool / bridge / silo. Tests differ:
Step 2 - 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:
| Pattern | What | Surface |
|---|---|---|
| Horizontal escalation | Tenant A accesses tenant B's data, 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 ID in URL/payload | All ID-bearing endpoints |
Layer the tenant-isolation-specific patterns (spoofed-body tenant_id, missing filter on new endpoints, cross-tenant FK and unique-constraint, JWT replay, object-storage path traversal, unfiltered search, async-job context, cache-key collision, log scrubbing) on top - the full test-per-pattern catalog is in references/attack-patterns.md.
Step 3 - Generate test cases
For each (surface, pattern) cell of the matrix, generate one or more test cases. Conventions:
test_<surface>_<pattern>_<expected>()
e.g.,
test_documents_api_horizontal_escalation_returns_403()
test_storage_bucket_idor_returns_403()
test_jwt_replay_cross_tenant_returns_401()
test_async_job_tenant_context_reload_on_exec()Each test creates fixtures for two tenants A and B, performs the cross-access attempt, and asserts denial. Per OWASP WSTG-ATHZ-02: create two users with identical privileges, hold concurrent sessions for both, and modify session tokens and parameters to target the other user's data
Required fixtures
| Fixture | Purpose |
|---|---|
tenant_a | First tenant with seeded data |
tenant_b | Second tenant with disjoint seeded data |
tenant_a_user | Authenticated user in A |
tenant_b_user | Authenticated user in B |
tenant_a_admin | Admin in A (for vertical-escalation tests) |
tenant_a_resource | A document/record/etc owned by A |
tenant_b_resource | Same shape, owned by B |
Step 4 - Pick the test framework + emit skeleton
Pick by stack:
| Stack | Framework | Why |
|---|---|---|
| Python (Django/Flask/FastAPI) | pytest | tenant fixtures via @pytest.fixture; assertions via assert response.status_code == 403 |
| Node (Express/Nest) | Jest or Vitest + Supertest | supertest(app).get(...).expect(403) |
| JVM (Spring/Quarkus) | JUnit 5 + Testcontainers | @SpringBootTest with real Postgres for RLS coverage |
| Go | testing + httptest | table-driven tests over (tenant, resource) pairs |
| Ruby (Rails) | RSpec + request specs | shared examples for cross-tenant battery |
Minimal probe - fixture two tenants, attempt cross-access by ID, and assert denial without existence disclosure:
class TestDocumentsTenantIsolation:
def test_tenant_a_cannot_read_tenant_b_document(
self, client, tenant_a_user, tenant_b_resource
):
client.force_login(tenant_a_user)
response = client.get(f"/api/documents/{tenant_b_resource.id}/")
assert response.status_code == 404 # 404 not 403: no existence disclosureThe full pytest battery (list-scoping, spoofed-body, JWT-replay) and the language-agnostic Postgres RLS-direct probe are in references/framework-skeletons.md.
Step 5 - Coverage assertions
The suite is incomplete unless it covers every (surface, pattern) cell. Track via a coverage matrix:
| 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 this matrix from Step 1's surface inventory × Step 2's pattern list. Empty cells are coverage gaps the PR must justify.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Tests run as superuser/BYPASSRLS role | Tests pass; prod leaks per row-level-security-postgres-reference | Run with prod-equivalent role |
| Single tenant in test fixtures | Can't test cross-tenant - that's the whole point | Always fixture two disjoint tenants |
| 403 instead of 404 | Existence disclosure: tenant A learns B's resource exists | Return 404 for unauthorised resources (debatable; document the choice) |
| Coverage by route only | Misses object storage, queues, search, caches | Inventory all surfaces (Step 1) |
| Static fixture IDs | Coincidental match between A's and B's IDs masks bugs | UUIDs, random per-test |
| Reusing JWT/session across tests | Cross-test bleed | Per-test session creation |
| Testing only the happy path | Misses spoofed-body, replayed-JWT, IDOR | Run the full attack pattern battery (Step 2) |
| Skipping silo deployments | Shared management surface still has pool-like leaks | Even silo gets the management-surface battery |
Output
This skill produces:
The runtime gate is cross-tenant-data-leak-tests.
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 row-level-security-postgres-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;Related skills
cross-tenant-data-leak-tests
Workflow-driven skill that emits the runtime CI gate of cross-tenant leak tests - the actual battery a multi-tenant codebase must pass on every PR. 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 expected response codes per pattern (404 vs 403 disclosure trade-off), the Postgres-RLS-direct test patterns, and the CI integration (run with non-superuser non-BYPASSRLS role, fail the build on any leak). Use when implementing the actual leak-test suite (after tenant-leak-test-author produces the plan), when adding the CI gate to an existing project, or when investigating a leak finding.
multi-engine-row-level-security-reference
Pure-reference catalog of row/tenant isolation mechanisms across four database engines: MySQL and MariaDB (no native RLS - views with SQL SECURITY INVOKER plus app-layer enforcement), CockroachDB (native RLS via ALTER TABLE ENABLE ROW LEVEL SECURITY and CREATE POLICY, matching Postgres semantics), Vitess (keyspace sharding + vindexes route tenant writes to dedicated shards without a policy layer), and SQL Server (CREATE SECURITY POLICY with inline table-valued function filter/block predicates). Covers the isolation mechanism, tenant-context pattern, bypass risks, and test patterns for each engine. Use when designing or auditing tenant isolation on MySQL, MariaDB, CockroachDB, Vitess, or SQL Server.
row-level-security-postgres-reference
Pure-reference catalog of Postgres Row-Level Security (RLS) for tenant isolation. 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()), performance discipline (wrapping auth functions in SELECT, index on policy-referenced columns), and anti-patterns. Use as the RLS-pattern reference for Postgres-backed tenant isolation. Consumed by tenant-leak-test-author, cross-tenant-data-leak-tests.
tenant-isolation-models-reference
Pure-reference catalog of tenant-isolation models for B2B SaaS. Defines the isolation continuum from full-isolation (separate compute + data + network per tenant) to fully-shared (one deployment, tenant_id discriminator), names the canonical models (Microsoft's automated-single-tenant / fully-multitenant / vertically-partitioned / horizontally-partitioned; AWS Well-Architected's silo / pool / bridge framing; deployment-stamps / supertenants terminology), enumerates the trade-offs (cost, blast radius, noisy neighbor, compliance, scale limits), and lists the test surfaces each model creates (cross-tenant data leak, tenant-id propagation, deployment-routing). Use as the model-selection reference when designing or auditing tenant isolation. Consumed by tenant-leak-test-author, 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 tenant-leak-test-author (runtime cross-tenant access) and cross-tenant-data-leak-tests (CI gate): this skill covers the provisioning lifecycle, not steady-state access control.