Audit Tenant Filters on List APIs
Trace list queries and test that each response contains only records the caller may see.
List APIs can leak more than a record detail page. A handler may check that a user belongs to some tenant, then run an unscoped query and return rows from every tenant. Pagination totals and facets can leak information even when visible rows are filtered later. OWASP's multi-tenant guidance calls for isolation at each data access path.
Inventory the list path
For every list endpoint, record the route, caller identity source, query builder, tenant predicate, pagination method, count query, and serializer. Include administrative and background variants. A list owned by a project may need both project membership and tenant ownership; a tenant predicate by itself is not always enough.
| Response part | Boundary question | Evidence to capture |
|---|---|---|
| Rows | Is each row in the caller's permitted set? | Synthetic tenant markers |
| Total count | Does the count use the same scope as rows? | Expected and actual totals |
| Facets | Are categories computed only from permitted rows? | Facet values by tenant |
| Pagination | Can a cursor cross tenant context? | Cursor reuse denial |
| Empty state | Does it reveal another tenant's existence? | Response comparison |
Use Tenant A and Tenant B with distinct synthetic records. Test an A member against the list route, then repeat with B and an account that belongs to both. A valid multi-tenant member may see a union only when the product policy permits it. Also test a newly revoked member and a filter value that matches only another tenant's record.
Apply the scope before counts, sorting, and pagination. Filtering after a database query can still expose counts, consume another tenant's records in a page, or create unstable page boundaries. A response-level filter is not a substitute for a trustworthy query constraint.
Check counts and facets
Illustrative SQL for a list filtered to a verified tenant context uses the same predicate in each query:
SELECT id, category FROM records WHERE tenant_id = $1 AND category = $2 ORDER BY id LIMIT $3;
SELECT count(*) FROM records WHERE tenant_id = $1 AND category = $2;
SELECT category, count(*) FROM records WHERE tenant_id = $1 GROUP BY category;
The facet query omits the selected category filter only if the product deliberately shows all categories within that tenant. Test with a B-only category: A must receive zero matching rows and count, and no B facet. Do not take $1 from an unverified request header. PostgreSQL describes WHERE and GROUP BY processing.
Keep the test result tied to a build and route version. The OWASP API Security Top 10 treats object authorization as an API risk, but this worksheet is narrower: it checks collection behavior. If one of the five response parts has no clear owner, mark it pending. Do not call a list endpoint isolated merely because the first page looks right.
Find the gaps in your access controls
See how an illustrative cross-workspace finding connects evidence, impact and a fix. Discuss the tenant boundaries your team needs reviewed.