You locked down GET /invoices so it only returns rows the caller can see. Then the dashboard says "Invoices this month: 4,812", and that number comes from SELECT count(*) FROM invoices with no tenant filter.
Counts leak. So do sums, search facets like "Overdue (37)", and the "20 of 3,104" line under a paginated table. When those run on their own query path they can tell a user how large another tenant's account is, or whether a given customer exists at all, without returning a single row.
What I'd do:
- Build the authorization filter in one place and pass it to the list query, the count query and every aggregate.
- Check search facets and pagination totals separately. They often come from a different index call than the hits.
- Add a test: a user who can see 3 invoices gets 3 from the count endpoint, and the facet numbers add up to 3.
- If dashboard numbers are cached, put the tenant (or the user, when access is per user) in the cache key.
Top comments (2)
Good one. Two more places the same leak shows up: export endpoints (
GET /invoices.csvoften gets written later, by someone else, against the raw table) and "exists" checks likePOST /invoicesreturning 409 "number already taken" for a number that belongs to another tenant. Both tell you something about rows you can't see. The fix is the same as yours: one function that builds the scoped query, and every path, including exports and uniqueness checks, starts from it. If the DB supports it, a unique index on(tenant_id, number)instead ofnumberalone removes the 409 case entirely. DominicThe 409 one is sneaky, thanks for adding it. The (tenant_id, number) index works when numbers only need to be unique per tenant. If they have to be global, I'd let the server assign them so nobody can probe for taken ones. Exports are the case I hit most, since they usually get bolted on later by someone who never saw the list helper.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.