Multi-tenant isolation auditor
Your tests run as
one tenant.
That's the bug.
Every test you have passes as a single customer, so the one query that forgot its tenant filter looks fine. TenantTrace seeds two tenants, asks for one while signed in as the other, and tells you what came back.
signed in as tenant A
GET /api/invoices/018f9c…b41d
response200 OK
{
"id": "018f9c…b41d",
"title": "tt-canary-B-7f3a91 invoice 2",
"amount": 102
}
tenant B's canary returned to tenant A — proof, not a guess
Two applications, one command, opposite answers
Both pages below are written by the real tool on every deploy — not screenshots, not mock-ups. Same routes, same attacks, same report format.
The leaking application
Six proven cross-tenant leaks, each with the request that proved it and an access graph of who reached what.
Open the report → nothing provenThe correctly isolated one
Identical routes, no findings — and 34 refused attempts on the page, because "no findings" only means something when you can see what was tried.
Open the report →The leak nobody's test suite finds
It only takes one
A tenant id enters through middleware and has to stay attached through every service call, ORM filter, cache key and job payload. Drop it in one place and one customer reads another customer's invoices.
Static analysis alone cries wolf
"Query without a tenant filter" flags every repository call in a well-built app, because the filter lives at the boundary, not the call site. Hundreds of findings, no signal, nobody reads the report twice.
So we confirm instead of guessing
Static analysis proposes; the prober proves. Only findings reproduced against a running app are reported as confirmed — and only those fail your build.
How a run works
Order matters here — step 2 is what stops a broken harness from reporting a clean bill of health.
-
01
Seed two tenants
Your seeder adapter — about thirty lines, written once per app — creates tenant A and tenant B. Every B record carries a unique canary string in a text field.
-
02
Check the controls
A reads A's own data. If that fails, authentication is broken and the run is marked invalid. A run that can't reach anything is not a run that found nothing.
-
03
Cross the line
Now A goes after B: object ids, list endpoints, aggregate counts, tenant parameters in the query string, and — if you allow writes — moving a record into someone else's account.
-
04
Read the verdict
B's canary in A's response is a confirmed leak. A count higher than A's seeded total is a confirmed leak. Anything the oracle can't decide is reported as inconclusive, never as a pass.
What it looks for
| Check | Question it asks | Engine | Classification |
|---|---|---|---|
| Object access | Can A fetch B's record by id? | probe | CWE-639 · API1:2023 |
| Listing | Does A's collection endpoint include B's rows? | probe | CWE-200 · API1:2023 |
| Aggregates | Does a row count reach past A's own data? | probe | CWE-200 · API1:2023 |
| Parameter override | Does the app trust a client-supplied tenant id? | probe | CWE-639 · API1:2023 |
| Mass assignment | Can A create or move a record into B? | probe | CWE-915 · API3:2023 |
| Cache keys | Is a cached value keyed without the tenant? | probe static | CWE-524 · API1:2023 |
| Scope escapes | Raw SQL or a bypass flag stepping around the global filter? | static | CWE-284 |
| Job payloads | Does a background job lose the tenant on its way to the queue? | static | CWE-639 |
probe findings are reproduced against a running app and reported as confirmed. static findings are suspicions — they're marked as such, and they don't fail your build on their own.
Quickstart
See it work — one command, nothing to install
git clone https://github.com/halilibrahimd27/tenant-trace
cd tenant-trace
docker compose up -d
# two apps audited over real HTTP; reports at :8088
Point it at your own application
# not on PyPI yet — install from the repository
pip install git+https://github.com/halilibrahimd27/tenant-trace
# scaffolds tenanttrace.toml and a seeder stub
tenanttrace init
# says exactly what it will do, before it does anything
tenanttrace validate-config
tenanttrace probe --dry-run
# two tenants, one question
tenanttrace probe
tenanttrace report --format md
Run it on every pull request
# .github/workflows/tenant-isolation.yml
- uses: halilibrahimd27/tenant-trace@v0
with:
config: tenanttrace.toml
fail-on: high
baseline: .tenanttrace-baseline.json
Known findings you've accepted go in the baseline and stay quiet. New ones fail the check. Fingerprints survive re-seeding, endpoint reordering and line-number churn — here's exactly what goes into the hash, and what deliberately doesn't.
What that actually prints
$ docker compose up -d
── auditing vulnerable at http://vulnerable-app:8000 ──
run VALID — 10/11 endpoints probed, 30 cross-tenant attempts
TT-0001 critical confirmed param_override GET /api/customers
TT-0002 critical confirmed listing_leak GET /api/documents
TT-0003 critical confirmed cross_tenant_read GET /api/invoices/{invoice_id}
TT-0004 critical confirmed cross_tenant_write POST /api/invoices
TT-0005 high confirmed cache_key_leak GET /api/documents/{document_id}
TT-0006 high confirmed aggregate_leak GET /api/stats
6 new confirmed finding(s) at or above high (worst: critical) exit 1
── auditing safe at http://safe-app:8000 ──
run VALID — 10/12 endpoints probed, 34 cross-tenant attempts
Findings: none
no new confirmed findings at or above high exit 0
Same command, same routes, opposite results — and the clean run reports 34 refused attempts, because "no findings" only means something when you can see what was tried.
Both of these are on this site as real pages — open either one.
What it won't find
A security tool that only lists its strengths is telling you half of something.
- Anything it can't seed. The oracle works because TenantTrace plants the data it later goes looking for. It can't audit a system you're not allowed to write to.
- Leaks with no HTTP surface. If a report generator writes the wrong tenant's rows to a file nobody fetches, probing won't see it. The static engine flags the code path; it can't prove the leak.
- Routes it never hears about. Coverage comes from an OpenAPI document, a HAR capture, a Postman collection, or a route list. Undocumented endpoints stay untested — and the report says how many endpoints it knew about, so a thin inventory can't pass for a clean result.
- Sums. The aggregate oracle checks *_count fields against the rows it seeded. It deliberately doesn't judge *_total — that's usually money, and comparing it to a row count would report a critical against correct code.
- Authorization beyond tenancy. Whether a viewer can act like an admin within one tenant is a different question, and this tool doesn't ask it.
- Non-Python codebases, statically. The prober speaks only HTTP and works against anything. The static engine currently ships one adapter: Python and SQLAlchemy.