Checking access to each object
All names, domains, and identifiers in these examples are fictional. Lena now has exactly what the festival assignment needs: in the Riverside festival album, album 4410 in the Gazette's Sports library, Lena can view photos, upload new ones and correct captions. Nowhere else does anything grant Lena access. Whether the library actually behaves that way depends on every route asking the right question about the right object.
Beyond a single lookup
Enforcing access at an API showed the check that protects a single album: look the album up only among those the caller may see, and answer 404 when it is not there, so a refusal does not reveal that it exists. The Gazette's album route works that way.
When the Gazette reviewed the rest of its library, that route turned out to be the easy part. Most requests touch more than one object, or touch an object without naming it: a photo reached through an album, a thumbnail, a move between albums, a search across thousands of photos, one field inside a photo record. Each makes it easy to ask about the wrong object, or to ask once when the request needs several answers.
Objects inside objects
Photo 9001 is one of Maya's pictures from the festival, and its address in the API says where it lives: /albums/4410/photos/9001. The first version of that route checked the album in the path and then loaded the photo by its ID. Here is what it returned when Lena changed the photo number in that address:
GET /albums/4410/photos/7310 HTTP/1.1
Host: api.photos.example
Authorization: Bearer demo-access-token-12
HTTP/1.1 200 OK
Content-Type: application/json
{
"id": 7310,
"album_id": 5230,
"library": "news",
"caption": "Council members review the draft Riverside budget",
"status": "draft"
}
The path names album 4410, which Lena may see. The response names album 5230, in the News library, which Lena may not. The route checked the album, then fetched photo 7310 by its number alone, and nothing connected the two. The path is only the client's claim about where a photo lives. The server's own records contradicted it, and Lena saw an unpublished photo for a story the Gazette had not yet run.
// Before: checks the album in the path, then loads the photo by ID alone.
app.get('/albums/:albumId/photos/:photoId', async (req, res) => {
const user = req.auth.user;
const album = await albums.find(req.params.albumId);
if (!album || !can(user, 'photo.view', album)) return res.status(404).end();
const photo = await photos.find(req.params.photoId);
return res.json(photo);
});
// After: finds the photo inside that album, then decides about the photo itself.
app.get('/albums/:albumId/photos/:photoId', async (req, res) => {
const user = req.auth.user;
const photo = await photos.findInAlbum(req.params.albumId, req.params.photoId);
if (!photo || !can(user, 'photo.view', photo)) return res.status(404).end();
return res.json(photo);
});
The fixed route looks for the photo within the album, so photo 7310 is simply not found, and Lena gets the same 404 as for a photo that does not exist. It then decides about the photo, not the album, because a photo can carry restrictions of its own, such as a legal hold, that an answer about its album knows nothing about.
The same photo also exists in forms the route never sees: thumbnails, previews, and the full-resolution file in storage. The first version served thumbnails such as /thumbnails/7310-small.jpg from a file host that checked nothing, on the reasoning that a thumbnail is only a small copy. A small copy of an unpublished photo is still an unpublished photo, and sequential numbers made every one of them easy to find.
Derived files need the same decision as the photo they come from. When the file host cannot make that decision, the API does, and then issues a signed link: a URL carrying an expiry time and a signature the file host can verify. The API creates the link only after the check it would apply to the photo itself, photo.view for a thumbnail or photo.download for the original. Anyone holding the link can use it until it expires, so it lasts minutes and is never stored where other people can load it.
Moving and copying
Sometimes a festival picture turns out to belong with a news story, and Omar, the sports desk's picture editor, tries to move it:
POST /photos/9001/move HTTP/1.1
Host: api.photos.example
Authorization: Bearer demo-access-token-14
Content-Type: application/json
{
"to_album": 5230
}
A move is two changes, so it needs permission at both ends. The Gazette treats leaving album 4410 as a removal, which requires photo.delete there, and arriving in album 5230 as an addition, which requires photo.upload. Omar holds both for the sports desk's albums but can only view the news desk's, so this move is refused.
Checking one end is not enough in either direction. Checking only the destination would let anyone pull pictures out of albums they can merely look at. Checking only the source would let Omar push sports photos into news albums, in front of people Omar has never worked with. A moved photo takes its access from its new album, so a move is also a decision about sharing.
Copying looks safer, because the original stays where it was, but a copy is a new photo that takes the destination album's access, not the original's. Omar may view photo 7310 but not download it. A copy in a sports album would let Omar download it and share it along with the rest of that album. Copying is a way of taking, so it needs photo.download at the source as well as photo.upload at the destination, and restrictions such as a legal hold or an embargo must travel with the copy, or the copy must be refused.
Batch requests repeat the problem. Approving photos 9001, 9002 and 7310 together needs three decisions, not one for the first photo or for the album most of them share. The route must also decide what a partial failure means. All-or-nothing refuses the whole batch if any item fails, which suits changes that only make sense together. Per-item results apply what is allowed and report on each item, which suits bulk work like approving a day's photos. A per-item result for a photo the caller may not see must look exactly like the result for one that does not exist, or the batch becomes a faster way to probe for photo numbers.
Lists, search, and counts
Lists are where object checks most often go missing, because no single object is named. The library's first search route fetched the 50 most relevant photos and then removed those the caller could not see. Lena's search for "riverside" showed three results on the first page and none on the second, although the festival album held dozens of matches. Every hidden photo had also already passed through code that could log or cache it.
The filter belongs in the query. The single-album lookup becomes a condition on the whole result set: the database is asked only for photos in albums Lena may view, so sorting, limits and pagination all work on the right set from the start. Turning a full access policy into a query condition takes more than a list of albums, and Enforcing access across a system, later in this series, covers it.
Search often runs in a separate index built from the photo records. That index must know who may see each photo, so each entry carries what the access decision needs, such as its album and library, and every search filters on the caller's albums. When a photo moves or a hold is placed, the index must be updated too. Until it is, search can show what the library itself would refuse.
The quietest leaks are the numbers around a list. Even after the query was fixed, Lena's results page said "Showing 1 to 20 of 214", counted over the whole library. The festival album held only a few dozen of those matches, so the total revealed well over a hundred riverside photos that Lena could not see. A sidebar of filters offered "Under legal hold (3)". Search suggestions completed "riv" with "Riverside budget cuts", a keyword from an unpublished news photo. None of these showed a hidden photo, and each revealed something the list was careful to hide. Totals, filter counts and suggestions must be computed over the same filtered set as the results, or left out.
Fields within an object
Even a single photo is not one thing to protect. Lena may correct captions, which photo.edit_caption covers, but not the license and credit fields, which need photo.edit_rights. All of them live on the same photo record and arrive through the same route, PATCH /photos/9001.
The first version of that route saved whatever JSON arrived. This is called mass assignment: the route trusts the client to send only the fields it is supposed to change. Nothing stopped this request:
PATCH /photos/9001 HTTP/1.1
Host: api.photos.example
Authorization: Bearer demo-access-token-12
Content-Type: application/json
{
"caption": "Fireworks over the river on the festival's final night",
"uploaded_by": "user-2290",
"status": "approved"
}
The caption change is allowed. The other two fields would record Lena, account user-2290, as the person who uploaded Maya's photo, and approve it for publication without any editor seeing it. No screen in the library offers either change, but the route never asked what the screens offered. The fixed route lists each field a request may change, with the permission that covers it:
// Each field a request may change, and the permission that covers it.
const editableFields: Record<string, string> = {
caption: 'photo.edit_caption',
keywords: 'photo.edit_caption',
license: 'photo.edit_rights',
credit: 'photo.edit_rights',
};
app.patch('/photos/:photoId', async (req, res) => {
const user = req.auth.user;
const photo = await photos.find(req.params.photoId);
if (!photo || !can(user, 'photo.view', photo)) return res.status(404).end();
for (const field of Object.keys(req.body)) {
const permission = editableFields[field];
if (!permission || !can(user, permission, photo)) {
return res.status(403).json({ error: 'field_not_editable', field });
}
}
await photos.update(photo.id, req.body);
return res.status(204).end();
});
uploaded_by and status are not on the list, so nobody can set them through this route. The library sets uploaded_by itself at upload, from the authenticated user, and a photo's status changes only through the approve route, which has checks of its own. The route refuses the whole request rather than quietly dropping fields, so a client that sends one it should not finds out instead of believing the change succeeded.
Reading needs the same care in the other direction. When Raj, on the legal team, places a legal hold, the photo record gains a note explaining why, which may name people or a dispute. Picture editors need to know that a photo is held, so they do not publish it, but the note itself is for the legal team. The library builds each response from the fields the caller may read, and adds the note only for callers who hold photo.place_hold for that photo. Building responses from an allowlist, rather than sending the stored record minus a few fields, means a column added to the photo table next year stays out of every response until someone decides who may see it. It is the same default deny as before, applied one field at a time.