Enforcing access across a system
All names, domains, and identifiers in these examples are fictional. While the Gazette answered a complaint about last year's flood coverage, Raj placed a legal hold on a dozen of those photos in the News library. The legal-hold rule forbids anyone to delete a held photo, and every delete button in the picture library respects it. A week later, four of the held photos were gone from the library.
Nobody had pressed a delete button. Every night, a job copies photos more than a year old to a cheaper archive and removes them from the library. It was written to work with storage directly, because it moves thousands of files, and it never asked the decision point anything. The policy was right. It was simply never consulted.
Every way in
A policy protects only the paths that consult it. The web app, the mobile app, and the current API are the paths everyone thinks of, and they usually do ask. The paths that get missed are the ones that do not look like a person making a request:
- Background jobs, such as the nightly archive, that act on many photos at once.
- Exports and scheduled reports, such as a weekly email of new photos and their captions. Each line is a read, and an embargoed photo's caption can reach people who could not open the photo.
- Webhooks that send photo details to other systems. An event may fire months after the subscription was created, when the subscriber may no longer see what it describes.
- Administrative and support tools, which are often built quickly and given broad direct access.
- Older API versions and the mobile backend, which may predate a rule. A version 1 download route that was never taught about embargoes is a second door with no lock.
Finding these paths starts with an inventory of everything that reads or changes photos and what each one checks. The fix that lasts is structural. If the only code that can remove a photo from the library is one function in the photo service, and that function asks the decision point before it acts, a job cannot skip the check without going around the photo service entirely, which is much easier to spot in review.
The archive job also needs an identity of its own, so that it asks as itself with only the permissions an archive job needs. The legal-hold rule applies to it unchanged, because it forbids deletion by anyone, not only by people. Rebuilt this way, the job is refused when it tries to remove a held photo, and it reports the photo as kept.
Coarse checks at the edge, fine checks inside
Requests to api.photos.example pass through a gateway before they reach the photo service. The gateway sees every request, which makes it a good place for checks that need nothing but the request: whether a valid token is present, and whether its scope covers the route. Rejecting those requests early is cheap.
What the gateway cannot answer is anything about the object. It does not know that photo 9002 is unpublished, when its embargo ends, or that Lena is assigned to the Riverside festival album and no other. Only the photo service, which holds the photo, has those facts at hand.
Trouble starts when the gateway's approval is mistaken for the whole decision. A service skips its own checks because the gateway let the request through. Or the gateway adds a header such as X-Authorized: true, and the service trusts any request that carries it, including one from an internal caller, such as the archive job, that never passed through the gateway at all. The photo service should verify the caller's identity itself and make every object decision as if the gateway were not there, treating the gateway's checks as an early filter rather than a verdict.
Enforcing in the data
Even when every path asks, one query can forget a condition. A developer adds a panel of recent uploads to the sports desk's home page and leaves out the library filter. Nothing about the code looks wrong, and the panel shows News library drafts to sports editors who hold no role there.
A second line of defense lives in the data layer. Many databases can attach a row filter to a table, so that every query made for a user is limited to the libraries where that user holds a role. The application sets the current user for each connection or transaction, and the database adds the condition itself. Application code can get the same effect from a data access layer that offers only scoped queries, where photos.visibleTo(user) exists and an unscoped photos.all() does not. Either way, the panel's careless query cannot return another desk's rows.
This does not replace the application's check. A row filter knows which rows a user may see, but not the difference between viewing a photo and approving it, whether the device is managed, or when an embargo ends. Stretching it to cover all of that would copy the policy into a second place, where the two would drift apart. It also depends on being told the right user. A job that connects with an account exempt from the filters, or a pooled connection still carrying the previous request's user, defeats it without any visible error.
Turning a policy into a query
The lesson on checking access to each object showed how lists, counts, and search results can leak. Lists also raise a problem of scale. A page of photos available to download asks not whether Omar may download this photo, but which photos Omar may download. With hundreds of thousands of photos in the Sports library, the answer cannot come from asking the decision point about each one.
Some decision points can answer that question in another form. They evaluate everything known about the request except the resource, and return what remains: the conditions that depend on the photo. Conditions on data the photo library's database holds become a filter it can apply, and the rest come back as checks to make on each photo. This is often called partial evaluation. For Omar on the managed laptop at 13:30 UTC, the download rules from version 14, shown in Evaluating a policy, reduce to this:
{
"decision": "conditional",
"policy_version": 14,
"filter": { "field": "library", "in": ["sports"] },
"check_each": ["embargo"]
}
Omar is a picture editor only in the Sports library, so a condition on the subject's roles became a condition on the photo's library. Because the laptop is managed, the two download permits together cover every Sports photo, published or not. On a personal laptop without a recent passkey sign-in, the filter would also require a published status. The embargo could not be translated, because embargo times live in the rights database, which the photo library's database cannot query, so it comes back as a check on each photo. The photo service uses both:
app.get('/downloads', async (req, res) => {
const user = req.auth.user;
const plan = await decisions.planFor(user, 'photo.download', req.context);
if (plan.decision === 'deny') return res.json({ photos: [], next: null });
// The database applies the filter. Reading stops once the page is full.
const page = [];
for await (const photo of photos.where(plan.filter).after(req.query.cursor)) {
if (!(await plan.check(photo))) continue; // the embargo, photo by photo
if (page.length === 50) return res.json({ photos: page, next: page[49].cursor });
page.push(photo);
}
return res.json({ photos: page, next: null });
});
The page is filled only with photos that have passed both the filter and the check, so a page of 50 means 50 photos Omar may download. A service that read the first 50 photos and then removed the ones Omar may not download could show three photos on one page and forty on the next, or an empty page while more remained.
If the per-photo check discards most of what is read, the usual fix is to give the database what it lacks, such as a copy of each photo's embargo time kept current from the rights database, so that the condition can move into the filter.
When a token meets a policy
The printer from the OAuth lessons is back. Maya connected it to print a book of festival pictures, and it holds a valid access token for Maya with the photos.read scope. At 15:15 UTC it asks for photo 9001, one of Maya's own:
GET /photos/9001 HTTP/1.1
Host: api.photos.example
Authorization: Bearer demo-access-token-21
Enforcing access at an API followed a request like this through the token, the scope, and the object. A policy adds a point that is easy to miss: a delegated request has two subjects. Maya is the person whose access is being used, and the printer is the client using it. The request can have no more access than the token's scope, no more than Maya's own permissions, and no more than the policy allows for that client. The first limit is what Maya approved, as described in Grants, scopes, and consent. The third is the newsroom's own decision about the printer.
The Gazette has decided that unpublished and embargoed photos never leave its own applications, however willing Maya is to share them. Its policy treats the client as an attribute of the request:
# outside-unpublished
forbid photo.view, photo.download
when client.kind is not "newsroom"
and resource.status in ["draft", "approved"]
# outside-embargoed
forbid photo.view, photo.download
when client.kind is not "newsroom"
and environment.time is before resource.embargo_until
The decision point looks up client.kind in the client registry, using the client ID from the validated token, just as it takes Maya's roles from the role assignments rather than from anything in the request. Whatever headers the printer sends, the registry decides what it is. The question the photo API asks now names both subjects, and the answer comes back:
{
"subject": { "id": "user-1450" },
"client": { "id": "photo-printer" },
"action": "photo.view",
"resource": { "type": "photo", "id": "9001" },
"context": { "time": "2026-06-14T15:15:00Z", "request_id": "req-8c21" }
}
{
"decision": "deny",
"reason": "outside_client_unpublished",
"policy_version": 14
}
Taking the three limits in turn:
| Limit | For this request | Result |
|---|---|---|
| The token's scope | photos.read covers viewing a photo | Allows |
| Maya's permissions | Contributor in the Sports library includes photo.view | Allows |
| The policy for this client | Photo 9001 is approved but not yet published, and the printer is not one of the newsroom's applications | Forbids |
Photo 9001's embargo ended the night before, but the photo is still unpublished, so outside-unpublished decides. For this request the photo is not visible, and the photo API answers as it would for a photo that does not exist:
HTTP/1.1 404 Not Found
The printer should never have had this ID to ask about. When it listed Maya's photos, the same rules became part of the list's filter, and photo 9001 was not on the list. Maya, opening the photo in the newsroom's web app a minute later, sees it as usual.