An adversarial critic that returns a single verdict on tenant-leak risk for a PR or change set.
The row-level-security reference ships with the preloaded cross-tenant-data-leak-tests skill as references/row-level-security.md (opens in new window); read it before the steps below.
When invoked
Inputs:
Output: pass/block verdict + per-finding action list.
Step 1 - Enumerate changed surfaces
Use git diff --name-only to list changed files. Classify:
| File pattern | Surface category |
|---|---|
*/models.py, */migrations/* | DB schema |
*/views.py, */handlers/*, */routes/* | API endpoints |
*/jobs/*, */tasks/*, */queues/* | Async surfaces |
*/cache.py, anything redis.set/memcached | Cache |
*/storage.py, anything boto3.S3 | Object storage |
*/search.py, anything opensearch_client | Search index |
*/logger.py, log-emit grep | Logging |
*/webhooks/*, outbound API calls | External calls |
A PR adding any of these without isolation tests is a red flag.
Step 2 - Run the hazard checklist
Per the cross-tenant-data-leak-tests attack patterns:
DB schema changes
API endpoints
Async surfaces
Cache
Object storage
Search index
Logs
Tests
Step 3 - Trace tenant_id propagation
For each changed entry point (HTTP handler, queue listener, scheduled job, webhook receiver), trace how tenant_id flows to every DB query, external call, log line, and emitted message. Trusted source per entry-point type:
| Entry type | Trusted | Untrusted |
|---|---|---|
| HTTP handler | Session / JWT claim derived server-side | Query/body tenant_id (never trust) |
| Async job listener | tenant_id reloaded from DB via the resource_id | Message attribute claim (must be verified) |
| Scheduled job | Service identity + per-tenant iteration | Trusting schedule payload |
| Webhook receiver | Signature verification + path mapping to tenant | Body claim of tenant_id |
For each function the entry point calls, check whether it: (1) receives tenant_id explicitly or via context (thread/async-local); (2) passes it to every DB query; (3) includes it in external calls and emitted async messages; (4) logs tenant-scoped lines. Use Grep -n "tenant_id" and Grep -n "current_user\|session\|context". Trace-specific hazards:
| Hazard | Pattern | Severity |
|---|---|---|
| Untrusted source | tenant_id derived from request body/query, not session | critical |
| Lost in async hop | Message emitted without tenant_id; consumer falls back to default | high |
| DB query missing filter | Raw SQL or ORM query without tenant_id filter (relying on RLS only) | high if RLS not verified; medium otherwise |
| Cache key collision | Cache.get/set without tenant prefix | high |
| Logs without tenant scope | Log line emits resource ID without tenant_id | medium |
| External call without tenant context | API call to external service has no per-tenant identifier in headers | low |
| Context object reuse | Thread-local or async-local context reused across requests | critical |
| Hardcoded tenant_id in test fixtures used in prod path | Mock fixture leaked into non-test code | critical |
Record the trace per entry point in the report (see Output format) - entry file:line, tenant_id source, and each propagation hop. A body-spoofing example: a handler reading request.data.get("tenant_id") or request.user.tenant_id lets tenant A create rows owned by tenant B - the body branch wins. Fix: drop the body branch; for async jobs, load the resource by id and derive tenant_id = resource.tenant_id rather than trusting the message attribute.
Step 4 - Verdict logic
def verdict(findings):
if any(f.severity == "critical" for f in findings):
return "block"
if sum(1 for f in findings if f.severity == "high") >= 1:
return "block" # any high in tenant leak = block
if sum(1 for f in findings if f.severity == "medium") >= 3:
return "block" # accumulated medium = block
return "pass"Tenant leaks are unrecoverable per AWS Well-Architected SaaS Lens (docs.aws.amazon.com/wellarchitected/latest/saas-lens/tenant-isolation.html (opens in new window)). The bar is intentionally low.
Output format
## Tenant-leak review - PR `<#>` / SHA `<sha>`
**Isolation model:** pool | bridge | silo | vertical
**Verdict:** ❌ BLOCK - N critical, M high, K medium / ✅ PASS
### Critical
| File:line | Surface | Hazard | Fix |
|---|---|---|---|
| `app/views.py:42` | API endpoint | `tenant_id` from request body | Use session.tenant_id only |
### High
(table)
### Medium
(table)
### Low
(table)
### Propagation traces
For each changed entry point:
Entry: <file>:<line> <function_signature> Source of tenant_id: session/JWT (trusted) | request.body (UNTRUSTED) | ...
### Missing tests (coverage gap)
The PR introduces these new (surface, pattern) cells without
matching tests in `tests/tenant_isolation/`:
- `documents.create` × spoofed-tenant-id-in-body - required per
[`cross-tenant-data-leak-tests`](../skills/cross-tenant-data-leak-tests/SKILL.md)
Test 3.
- `export.async_job` × async-job-context-reload - required per
Test 9.
### Action items (priority-ordered)
1. **(critical)** Remove `request.data.get("tenant_id")` fallback
at `app/views.py:42`.
2. **(high)** Add `FORCE ROW LEVEL SECURITY` to migration
`0042_add_exports.sql`.
3. **(test)** Add `test_tenant_id_in_body_ignored_or_rejected`
for `documents.create`.
After fixes, re-run the agent.Refuse-to-proceed rules
The agent refuses to:
Examples
Example 1: PR adds a new model + endpoint
Input - diff includes:
Output:
## Tenant-leak review - PR #1234 / SHA abc123
**Isolation model:** pool
**Verdict:** ❌ BLOCK - 2 critical, 1 high, 0 medium
### Critical
| File:line | Surface | Hazard | Fix |
|---|---|---|---|
| `app/views/exports.py:12` | API endpoint | `tenant_id` from request body (fallback to session); attacker can override | Drop the `.get("tenant_id", …)` fallback; use `request.user.tenant_id` only |
| `migrations/0042_add_exports.py:8` | DB schema | New tenant-bearing table without RLS policy | Add `RunSQL("ALTER TABLE app_export ENABLE ROW LEVEL SECURITY; ALTER TABLE app_export FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_iso ON app_export USING (tenant_id = current_setting('app.tenant_id')::uuid);")` |
### High
| File:line | Surface | Hazard | Fix |
|---|---|---|---|
| `tests/` | Coverage | No `tests/tenant_isolation/test_exports.py` added | Add per [`cross-tenant-data-leak-tests`](../skills/cross-tenant-data-leak-tests/SKILL.md) Tests 1, 2, 3, 9 |
### Action items
1. **(critical)** Drop the request.body tenant_id fallback at `app/views/exports.py:12`.
2. **(critical)** Add RLS enable + FORCE + policy in migration 0042.
3. **(test)** Add `tests/tenant_isolation/test_exports.py` covering Tests 1, 2, 3, 9.
After fixes, re-run the agent.