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.
75
94%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
This plugin was archived by the owner on Oct 11, 2026
Reason: Tile archived: Retired from testland/qa (merged into another skill or removed).
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.
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:
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), 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.
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
| 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 |
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.
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-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 |
This skill produces:
The runtime gate is
cross-tenant-data-leak-tests.
tenant-isolation-models-reference.row-level-security-postgres-reference.cross-tenant-data-leak-tests.