Export and “download all” endpoints are where object-level checks quietly disappear. The UI shows a filtered table, but the export handler often runs a privileged query, streams every matching id, and trusts that the list page already did the hard part.
If any row in that stream would 404 or 403 on the single-resource path, the export just became a bulk BOLA.
Patterns that hold up:
- Authorize the action (can this subject export this collection?) and then constrain the query with the same scope the list API uses — tenant, ownership, relationship — not a god-mode
SELECT * WHERE created_at …. - Prefer filtering in the query over post-filtering in memory. Post-filters drop rows the caller should not see; they do not stop the database from reading them, and they fail open when someone “temporarily” removes the filter for a performance hotfix.
- Keep denial consistent with single-resource routes. If
GET /invoices/{id}returns 404 across tenants, the CSV must not leak those ids as blank lines, totals, or filenames. - Re-check on long jobs. A 20-minute export started under one membership can finish after a role change; treat chunks like fresh reads, not a standing grant from job creation time.
Quick check: as user A, start an export that should only include A’s rows, then compare the file to what GET /resource/{id} allows for a peer’s id. If the CSV has more access than the detail API, your gate was “can export,” not “can see each row.”
Bulk is a delivery shape. Authorization is still about every object in the stream.
Top comments (0)