Testland
Browse all skills & agents

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-author
View source

tenant-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

  • New feature introduces a tenant-bearing surface (table, API resource, queue topic, search index).
  • Audit existing coverage - does the test suite probe every surface?
  • PR review adds a new tenant_id column or scoped endpoint.
  • Migrating from pool to bridge (or vice versa) - test surface shifts.

Step 1 - Inventory tenant-bearing surfaces

Walk the codebase and enumerate every surface that should be tenant-scoped. Categorise:

Surface categoryExamplesHow to find
Database tablestables with tenant_id columngrep -r "tenant_id" --include="*.sql" ; ORM model field annotations
API endpointsroutes returning tenant dataroute registrations grep
Object storagebuckets / prefixes per tenantIaC for buckets, lifecycle config
Search indicestenant-routed Elasticsearch / Algoliaindex-naming scheme
Async messagestenant_id in message attributes / payloadmessage-class definitions
CachesRedis keys with tenant_id prefixcache-client wrappers
Logs / metricslog lines containing tenant_idlog-emit grep
Background jobsSidekiq / Celery tasks taking tenant_idtask definitions
Reports / exportstenant-scoped reportsexport endpoints
Webhooks / outboundtenant-routed external callswebhook 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:

  • Pool → cross-tenant access tests (the canonical battery).
  • Bridge → cross-database-routing tests (tenant A's session must not connect to tenant B's DB).
  • Silo → tenant-to-deployment routing tests (tenant A's request must not hit tenant B's deployment).

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:

PatternWhatSurface
Horizontal escalationTenant A accesses tenant B's data, identical privilegeAll pool/bridge surfaces
Vertical escalationNon-admin in tenant A accesses admin-only resourcesAll admin-scoped surfaces
IDOR / BOLADirect reference attack - change ID in URL/payloadAll 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

  • with tenant_id as the "other identity".

Required fixtures

FixturePurpose
tenant_aFirst tenant with seeded data
tenant_bSecond tenant with disjoint seeded data
tenant_a_userAuthenticated user in A
tenant_b_userAuthenticated user in B
tenant_a_adminAdmin in A (for vertical-escalation tests)
tenant_a_resourceA document/record/etc owned by A
tenant_b_resourceSame shape, owned by B

Step 4 - Pick the test framework + emit skeleton

Pick by stack:

StackFrameworkWhy
Python (Django/Flask/FastAPI)pytesttenant fixtures via @pytest.fixture; assertions via assert response.status_code == 403
Node (Express/Nest)Jest or Vitest + Supertestsupertest(app).get(...).expect(403)
JVM (Spring/Quarkus)JUnit 5 + Testcontainers@SpringBootTest with real Postgres for RLS coverage
Gotesting + httptesttable-driven tests over (tenant, resource) pairs
Ruby (Rails)RSpec + request specsshared 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 disclosure

The 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   |   -  |   -  |  -  | -  |   -   |  X

Generate 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-patternWhy it failsFix
Tests run as superuser/BYPASSRLS roleTests pass; prod leaks per row-level-security-postgres-referenceRun with prod-equivalent role
Single tenant in test fixturesCan't test cross-tenant - that's the whole pointAlways fixture two disjoint tenants
403 instead of 404Existence disclosure: tenant A learns B's resource existsReturn 404 for unauthorised resources (debatable; document the choice)
Coverage by route onlyMisses object storage, queues, search, cachesInventory all surfaces (Step 1)
Static fixture IDsCoincidental match between A's and B's IDs masks bugsUUIDs, random per-test
Reusing JWT/session across testsCross-test bleedPer-test session creation
Testing only the happy pathMisses spoofed-body, replayed-JWT, IDORRun the full attack pattern battery (Step 2)
Skipping silo deploymentsShared management surface still has pool-like leaksEven silo gets the management-surface battery

Output

This skill produces:

  • A surface inventory document (Step 1).
  • An attack-pattern × surface coverage matrix (Steps 2 - 3).
  • A test suite skeleton (Step 4) committed to the project repo.
  • A README in the test directory documenting the matrix and how to add a surface.

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:

PatternWhatSurface
Horizontal escalationTenant A accesses tenant B's data at identical privilegeAll pool/bridge surfaces
Vertical escalationNon-admin in tenant A accesses admin-only resourcesAll admin-scoped surfaces
IDOR / BOLADirect reference attack - change an ID in URL/payloadAll ID-bearing endpoints

Tenant-isolation-specific patterns

PatternTest
tenant_id from request payloadSend tenant A's session with tenant_id=B in body - must reject
Missing tenant_id filter in new endpointEnumerate routes added in last N commits; verify each filters by tenant
Cross-tenant via foreign keyCreate FK from tenant-A row to tenant-B row - must fail
Cross-tenant via unique constraintInsert tenant-A row with a key that exists in tenant B - observe error timing as a side channel
JWT replay across tenantsTenant A's JWT used to call tenant B's endpoint - must reject on signature/iss/aud check
Object storage path traversalTenant A presigned URL -> modify prefix to tenant B's - must 403
Search query without tenant filterDirect search index query - must include the tenant routing key
Async job tenant contextJob enqueued by tenant A -> executor must reload tenant context, not trust the message
Cache key collisionTenant A and tenant B have the same logical key - cache must namespace
Log scrubbingTenant 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.