Proves whether tenant A can reach tenant B's data.
A multi-tenant isolation auditor for SaaS applications: it finds broken object level authorization (OWASP API1:2023, CWE-639) across tenants and proves each finding with evidence, so the report can gate a merge instead of starting an argument.
Your test suite runs as one tenant, so the one query that forgot its tenant filter looks fine. TenantTrace seeds two tenants into your application, asks for one while authenticated as the other, and reports what came back.
β [critical/confirmed] Cross-tenant read on GET /api/invoices/{invoice_id}
GET /api/invoices/018f4c1e-3a9b-7c2d-9e5f-1a2b3c4d5e6f
200 Β· body contains tt-canary-B-β¦3f7a91c2
That is not a similarity score or a heuristic. We planted that string in tenant B's invoice ninety milliseconds earlier, and it came back to tenant A.
Open an example report β Β· the same run against a correctly isolated app
Both are generated by the real tool on every deploy, not screenshots.
git clone https://github.com/halilibrahimd27/tenant-trace
cd tenant-trace
docker compose up -dThat boots two multi-tenant applications β one deliberately leaky, one correctly isolated β audits both over real HTTP with a real Redis, and serves the reports at http://127.0.0.1:8088. Nothing else to install.
Reports live in a named volume, so the demo runs as a non-root user on every
platform. To pull them onto your disk: make reports (or
docker compose cp report:/reports ./reports).
Without Docker:
uv sync --extra dev --extra fixtures
uv run tenanttrace demo # audits both fixtures in-process, writes HTMLFindings are facts, not scores. Every record belonging to tenant B is seeded with a unique canary string. If that canary appears in a response served to tenant A, the leak is confirmed β there is nothing to interpret.
Aggregates work the same way: we seeded tenant A's rows, so we know the correct count. Anything higher is a confirmed leak.
When the oracle cannot decide β a 5xx, a timeout, an unparseable body β the
verdict is inconclusive. It is never silently a pass.
Every run checks its controls first. Tenant A must be able to read tenant
A's own data. If that fails, authentication or seeding is broken and the run is
marked INVALID with exit code 3. A run that could not reach anything is not a
run that found nothing β that distinction is the difference between a security
tool and a placebo.
| proven by | ||
|---|---|---|
| Object reads | A fetches B's record by id | B's canary in the response |
| Collection leaks | A's own list contains B's rows | B's canary in the response |
| Aggregate leaks | counts computed over every tenant | arithmetic against seeded rows |
| Parameter override | ?tenant_id=B, X-Tenant-Id: B, body fields |
canary, after a clean baseline |
| Cache-key leaks | correct query, tenant-less cache key | refused cold, served after B warms it |
| Cross-tenant writes | A creates a record inside B | reading it back as B |
| Public endpoints | the same data comes back with no credential | replaying the leak anonymously |
The last row is a different bug wearing the same clothes. If an unauthenticated
request gets the record too, tenant scoping is not the control that failed, and
adding a WHERE tenant_id = β¦ fixes nothing because there is no caller to
scope to. TenantTrace asks before it blames scoping
(ADR-0011).
The cache case is the one worth pausing on. The query is correct β code review
sees a proper WHERE tenant_id = β¦, and a single-tenant test suite passes β but
the result is cached under invoice:{id}, so whichever tenant asks first wins.
TenantTrace finds it by requesting the object cold (refused), having the owner
read it (populating the cache), then repeating the first request. See
ADR-0008.
One self-contained HTML file β no CDN, no fonts, no outbound requests, so it is safe to attach to a ticket or open on an air-gapped machine. It opens with the answer, not the method:
- The verdict.
6 confirmed cross-tenant leaks, orNo cross-tenant access proven β 34 attempts refused across 10 endpoints. A clean run says what it covered, because "no findings" and "nothing was tested" must never read alike. - An access graph. Which tenant reached which endpoint, drawn only from
proven results β a
LEAKEDverdict is an edge, anENFORCEDone is not. The idea is BloodHound's: on a large API the useful sentence is "four of these hundred endpoints are where the boundary breaks", and a table cannot say it. - Every finding with the request that proved it β the canary, the response, and a fix aimed at the data-access boundary rather than the route handler.
- Run integrity last. Positive controls, what the application refused, and anything the oracle could not decide.
--format md writes the same content for a pull request; --format json for a
machine. Credentials are stripped and canaries shortened in all three.
| probe | dynamic, language-agnostic | Sends real requests. Works against FastAPI, Laravel, Rails, .NET β anything speaking HTTP. Findings are confirmed. |
| static | per-language adapter | Reads source to find suspicious paths and the leaks HTTP cannot see: raw SQL, cache keys, job payloads. Findings are suspected until the prober confirms them. |
Static proposes, dynamic proves. Only confirmed findings fail your build by default β that is what keeps the gate tolerable. See ADR-0002.
The static engine parses with the standard library's ast. It never imports or
executes the code under analysis
(ADR-0005).
Two adapters ship β python_sqlalchemy and python_django β and adapter = "auto" picks by looking at imports. Writing the second one is what showed that
three of the six rules were never about an ORM at all: raw SQL, cache keys and
job payloads are patterns in Python, so they moved to static/rules.py where
both adapters read them
(ADR-0012).
Adjacent tools exist and this one is not a replacement for any of them.
| What it does | Why it does not answer this question | |
|---|---|---|
| Semgrep / CodeQL | Pattern-match "query without a tenant filter" | Static only, so nothing is ever confirmed β and against the repository/service-layer pattern, where the filter lives far from the query, the rule flags every call site. Hundreds of findings, no signal. |
| Burp Autorize / AuthMatrix | Replay your requests with a second identity | Proxy-driven: you browse, it replays. The oracle is response similarity, which is noisy in both directions and cannot judge an aggregate at all. It does not run as a merge gate. |
| Schemathesis / Dredd | Property-test an API against its schema | Excellent at schema conformance. Has no concept of tenancy. |
| Your test suite | Everything else | Runs as one tenant, so the query that forgot its filter returns the right answer. |
What is actually different here:
- The oracle is exact. We seed the data we later go looking for, so a finding is true or it isn't β no similarity scoring, no thresholds.
- Static proposes, dynamic proves. Hypotheses only gate CI once a real request confirmed them. That is what makes the false-positive rate low enough to block a merge on.
- It runs headless. A merge gate with a baseline file, not an interactive proxy session.
- It crosses layers. Cache keys, aggregates, and background-job payloads, not only endpoint responses.
If your application has no OpenAPI document, export a HAR from one click-through and point TenantTrace at that instead.
TenantTrace cannot guess how your application creates a tenant, authenticates
one, or creates an owned record. You write that once, in about thirty lines β
start from seeders/example_seeder.py or the
working fixtures/seeder.py:
class MySeeder:
def __init__(self, client): self.client = client
def create_tenant(self, label): # -> {"tenant_id": ..., "access_token": ...}
def auth_headers(self, tenant): # -> {"Authorization": f"Bearer {...}"}
def seed_records(self, tenant, canary): # -> records carrying the canary
def cleanup(self, tenant): # -> remove what you createdPut the canary in a field the API actually returns β a title, name, or description. Create at least two records per kind: the harness keeps its control reads and its attack reads on different records (ADR-0008).
Two details decide whether the rest of the run works, and neither raises anything when wrong:
tenant_idis the tenant as it appears in a URL path. The prober substitutes it into tenant path parameters, so for/api/v1/accounts/{account_id}/β¦it is the account id and for/admin/realms/{realm}/β¦the realm name. Wrong value, and the canonical cross-tenant test never runs.kindmust equal the endpoint's resource segment, lowercase and singular:/api/invoices/{id}wantskind="invoice". Wrong value, and every endpoint quietly falls back to trying a few ids blindly. The run says so when no kind matches anything.
A nested record declares the parents that lead to it β
{"kind": "row", "id": "1", "path": {"table_id": "38"}} β because a row cannot
be addressed from a row id alone. [tenancy] path_literals pins a slot that
names a type rather than an object, as Squidex's {schema} does.
SeederClient is optional and removes the call/check/decode dance every real
seeder wrote by hand; its value is the failure messages, which name the
request, the expected status, and what the application actually said.
Then point at it:
[target]
base_url = "http://127.0.0.1:8000"
allowed_hosts = ["127.0.0.1", "localhost"]
spec = "openapi" # or "har" / "postman" / "routes"
spec_path = "http://127.0.0.1:8000/openapi.json"
[seeder]
adapter = "seeders.my_app:MySeeder"
[tenancy]
column = "tenant_id"
cross_tenant_allowlist = ["/api/admin/*"] # endpoints that cross tenants on purposetenanttrace validate-config -c tenanttrace.toml # says exactly what it will do
tenanttrace probe -c tenanttrace.toml --dry-run # lists attempts, sends nothing
tenanttrace probe -c tenanttrace.tomltenanttrace.example.toml documents every key, and a test asserts the loader
accepts it β so it cannot drift.
The prober takes an injected transport, so it can drive an ASGI application in-process with no server, no port, and no container:
from tenanttrace.probe.asgi import SyncASGITransport
from tenanttrace.probe.runner import ProbeOptions, run_probe
def test_tenants_are_isolated():
with SyncASGITransport(my_app) as transport:
report = run_probe(config, ProbeOptions(transport=transport)).report
assert report.status is RunStatus.VALID # controls passed β the run is real
assert report.confirmed == ()- uses: halilibrahimd27/tenant-trace@v0
with:
config: tenanttrace.toml
fail-on: high
baseline: .tenanttrace-baseline.jsonAccepted findings live in the baseline and stay quiet; new ones fail the check. Fingerprints survive re-seeding, endpoint reordering, parameter renames, and line-number churn β they are built from the endpoint or the source symbol, never a line number (ADR-0007). The baseline holds fingerprints and titles only: never a canary, a token, or a response body.
Exit codes: 0 clean Β· 1 findings at or above fail-on Β· 2 usage or
config error Β· 3 run INVALID, positive controls failed.
Every other view answers "what did this run find?". A team running it weekly needs the other question, and an application that still holds and one the harness no longer reaches both report no findings.
tenanttrace diff .tenanttrace/myapp/runs/<earlier> .tenanttrace/myapp/runs/<later> \
--fail-on-regressionIt reports three regressions β an endpoint no longer tested, an endpoint whose
every attempt is now inconclusive, and fewer refusals than before β and can
fail a build on coverage alone, with no new finding. Only ENFORCED counts as
coverage: an endpoint that was visited but never decided was not tested. It
refuses to compare against an INVALID run, because comparing with a run that
never happened is how a regression gets explained away.
git clone https://github.com/halilibrahimd27/tenant-trace
claude plugin marketplace add ./tenant-trace
claude plugin install tenant-trace@tenant-traceA Claude Code plugin with two surfaces. A hook runs the
static engine over each Python file you edit and reports queries with no
visible tenant predicate β it never blocks, never probes, and stays quiet when
it has nothing to say. A skill carries how to run a real audit, why INVALID
is not a clean result, and what "refused" counts.
Being specific about this is part of the tool being trustworthy.
- Anything it cannot seed. The oracle works because TenantTrace plants the data it later goes looking for. It cannot audit a system you are not allowed to write to.
- Leaks with no HTTP surface. A report generator writing the wrong tenant's rows to a file nobody fetches is invisible to probing. The static engine can flag the code path; it cannot prove the leak.
- Routes it never hears about. Coverage comes from an OpenAPI document, a HAR capture, a Postman collection, or a hand-written route list. Undocumented endpoints go untested, and the report says how many endpoints it knew about so a thin inventory cannot pass for a clean result.
- Sums. The aggregate oracle judges
*_countfields against seeded row counts. It does not judge*_total, because that is 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 does not ask it.
- Non-Python codebases, statically. The prober is language-agnostic; the static engine ships Python adapters only β SQLAlchemy and Django. Point it at a Rails or Laravel tree and it says it does not recognise it, rather than reporting nothing found.
The prober sends adversarial requests and, with --allow-mutation, writes data.
- Read-only by default. Mutating attacks require
--allow-mutationon the command line andallow_mutation = truein config. Neither alone is enough. - Host allowlist. The target host must appear in
allowed_hosts. - Non-loopback targets additionally require
--i-have-authorization. That flag is a statement you are making, not a permission this tool grants you. - Redirects are not followed β a redirect could move a request to a host outside the allowlist.
- Rate limited to
max_rps, shared across both tenant sessions. - Credentials are redacted where the record is created, not at render time,
so a token has no path to an artifact. A test asserts no JWT reaches
exchanges.jsonl. - Every request and response is captured to
.tenanttrace/, which is gitignored because it contains real findings.
Mutating attacks clean up after themselves, and say so in the finding when they could not.
See SECURITY.md and THREAT_MODEL.md.
make install # uv sync --extra dev --extra fixtures
make verify # ruff Β· black Β· mypy --strict Β· pytest β₯88% Β· recall β₯90%
make demo # audit both fixtures, write reports
make fixtures-up # boot the fixtures in Docker (only needed for the HTTP demo)make verify is the gate and CI runs the same command. It is hermetic β no
Docker, no Redis, no network β because the fixtures are driven in-process over
ASGI (ADR-0004).
The gate includes a precision/recall score against
fixtures/labels.yaml, the answer key describing every
hole in the fixture applications. Recall below 90%, or any false positive on the
correctly-isolated app, fails the build. That is what turns "I think it works"
into a number.
CONTRIBUTING.md has the house rules, CLAUDE.md
has the non-negotiables, and every significant decision is recorded in
docs/adr/.
MIT β see LICENSE.