Testland
Browse all skills & agents

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

cross-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:

  • Each test fixtures two disjoint tenants (A, B).
  • Each test exercises one (surface, pattern) cell.
  • Each test asserts denial of cross-tenant access.
  • The suite runs as part of CI on every PR.
  • A single failure blocks merge.

When to use

  • Designing a multi-tenant test suite for a new feature or auditing coverage for an existing tenant boundary (planning section).
  • Implementing the leak-test suite from the coverage matrix.
  • Adding the CI gate to an existing multi-tenant project.
  • Investigating a leak finding - reproduce with a minimal test.
  • Adding coverage for a newly-introduced tenant-bearing surface.

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

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

Generate 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 testResult
SuperuserBypasses RLS - tests pass, prod leaks. Do not use.
Role with BYPASSRLS attributeSame 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=f

For 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 disclosure

Convention: 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 ids

Even 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.text

Per 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 == 403

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

Test 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 -v

Key: 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:

  1. Read the assertion - which surface + pattern leaked?
  2. Check the connection role - SELECT current_user; in the test. If it's superuser, the test was bypassed. Fix the test connection first.
  3. Reproduce locally with the same role.
  4. Check the policy - is there a policy on the table? Is RLS enabled? Is FORCE ROW LEVEL SECURITY set?
  5. Check the application path - is tenant_id derived from the session, or from request payload?
  6. Write the minimal regression test before fixing.

Anti-patterns

Anti-patternWhy it failsFix
Tests connect as superuserRLS bypassed; tests pass falselyUse prod-equivalent role
Migrations and tests use same roleMigrations need DDL; tests should notTwo roles: migrations user (owner), app user (RLS-bound)
Test setup creates tenants via direct SQL bypassing RLSOK in setup, but reset tenant context before assertionsSET LOCAL between fixture creation and test body
403 used instead of 404 for unauthorised cross-tenant resourcesLeaks existenceUse 404 (and document)
Single test for "tenant isolation works"Insufficient coverageOne test per (surface, pattern) cell
Skipping FK/UNIQUE side-channel testsReal production bugs hide hereAlways test FK + unique cross-tenant
No async-job context-reload testJob runners often trust payloadAlways test
Cache without tenant prefixTenant A and B alias same logical keyVerify key generation in test
Suite not part of CICatches nothingBlock merge on failure

Limitations

  • Cannot test side-channel timing. Unique-constraint timing differences are detectable by an attacker but expensive to unit-test reliably. Constant-time response or rate-limit layers are the prod defence.
  • Cannot test all routes. New routes added without isolation tests are still vulnerable. Trace tenant_id propagation through new routes to enforce coverage on PR.
  • Cannot detect log leaks. Tests verify response payloads, not log lines. Add log-grep tests separately.
  • Test database must mirror prod RLS setup. If test DB uses a different connection model, prod-only bugs slip through.

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

  • Designing the tenant-isolation model for a new B2B SaaS product or feature.
  • Auditing an existing model - does the testing surface match the declared isolation level?
  • Choosing what to test: each model creates a distinct set of failure modes the test suite must cover.
  • PR review of architecture changes that move components along the isolation continuum.

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

ModelComputeDataNetworkCost/tenantBlast radiusNoisy neighbor
Automated single-tenant (silo)DedicatedDedicatedDedicatedHighestOne tenantNone
Fully multitenant (pool)SharedShared (tenant_id discriminator)SharedLowestAll tenantsHigh
Horizontally partitioned (bridge)SharedDedicated per tenantSharedMediumData isolatedData tier: none
Vertically partitionedMixMixMixMixedPer-tierPer-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:

  • Identity context - tenant_id in JWT claims (auth.jwt() in Supabase per supabase.com/docs/guides/database/postgres/row-level-security (opens in new window)) or AWS Cognito ID token; the source of truth for "who is this request for".
  • Authorisation policy - Postgres Row-Level Security per rls-reference, AWS IAM dynamic policies generated per tenant, application-level authorisation middleware.
  • Resource ABAC tags - tag each tenant resource with tenant-id=<x>, then enforce via IAM condition keys.
  • Network segmentation - per-tenant VPCs / subnets / security groups (silo only).
  • Encryption keys - per-tenant KMS keys (silo / bridge); useful for crypto-shredding on tenant offboarding.

Anti-patterns

Anti-patternWhy it failsFix
tenant_id filter only in application codeOne missed query path = cross-tenant leakPush the filter to the database (RLS) or row-attribute IAM
tenant_id from request header / bodySpoofable; tenant A can claim to be tenant BAlways derive tenant_id from authenticated JWT/session, never from request payload
Trust the JWT raw_user_meta_data for tenant claimsUser-modifiable per Supabase docsUse raw_app_meta_data (server-set) or a server-side claim store
Single connection pool for all tenantsOne slow tenant query blocks allPer-tenant pools, or quota-aware pools
Shared object-storage bucket without prefix isolationObject enumeration leaks across tenantsPer-tenant prefix + IAM condition on the prefix
No isolation tests in CIModels drift over timeCross-tenant leak tests in every PR per cross-tenant-data-leak-tests
Migration scripts run without tenant contextSchema changes touch all tenants at once; high blast radiusStamp 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

  • No model is leak-proof by construction. Silo defends against most cross-tenant leaks but inherits leak risk in any shared management surface (admin UI, billing). RLS defends the DB but not application caches.
  • Cost vs isolation is a real trade-off. Per Microsoft, if a single tenant requires a given infrastructure cost, 100 tenants in pure silo require roughly 100 times that cost.
  • Compliance scope. Some regulators (e.g., FedRAMP High, certain healthcare regimes) effectively mandate silo for certain data classifications. Check counsel-of-record before assuming pool is acceptable.
  • Azure subscription / AWS account limits. Shared infrastructure reaches account-level scale limits faster than silo.

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)

PropertyValue
ComputeDedicated per tenant
DataDedicated per tenant
NetworkDedicated per tenant
Cost per tenantHighest
Blast radiusOne tenant
Noisy neighborNone

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)

PropertyValue
ComputeShared
DataShared (single DB with tenant_id discriminator)
NetworkShared
Cost per tenantLowest
Blast radiusAll tenants
Noisy neighborHigh

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)

PropertyValue
ComputeShared
DataDedicated per tenant
NetworkShared
Cost per tenantMedium
Blast radiusApp-tier shared, data isolated
Noisy neighborApp-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

PropertyValue
ComputeMix (some tenants dedicated, others shared)
DataMix
NetworkMix
Cost per tenantMixed
Blast radiusPer-tier decision
Noisy neighborPer-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:

TierCommon choice
UIShared host name (fully multitenant)
API gatewayShared, with tenant claim in JWT
Application servicesShared, tenant context in every request
Async queues / topicsShared topic with tenant_id message attribute, or per-tenant queue
DataOften partitioned: tables with tenant_id (pool); schemas per tenant (bridge); databases per tenant (silo)
Object storagePer-tenant prefix in bucket (pool); bucket per tenant (silo)
Search indexPer-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

ModelRequired test categories
Silo / single-tenantTenant-to-deployment routing; per-deployment health; deployment automation
Pool / fully-sharedCross-tenant data leak (highest priority); tenant_id propagation; noisy-neighbor mitigation; quota enforcement
Bridge / horizontalPool tests + database routing per tenant; cross-database query rejection
VerticalPool + 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.