Designing permissions
All names, domains, and identifiers in these examples are fictional. The Tidewell Gazette is a regional newspaper, and its newsroom keeps every picture it might publish in one shared library on photos.example, the same photo service the printer used in The problem OAuth solves and the OAuth lessons after it. Photographers upload from their assignments, picture editors choose and caption what runs, the legal team watches for pictures that must not be published or destroyed, and Theo, the library administrator, keeps track of who can do what.
That last job turned out to be the hardest.
From one flag to many permissions
The first version of the Gazette's library asked one question about each account: is it an administrator? Every account record carried a flag, is_admin. Administrators could upload, caption, approve, delete, share albums, and manage other accounts. Everyone else could browse.
For a while that was enough. The sports desk was small, so Maya, a staff photographer, and Omar, the desk's picture editor, were both administrators, as was nearly everyone who did more than look. Each route that changed anything began by refusing anyone without the flag.
Then the Gazette hired Lena, a freelance photographer, to cover the Riverside festival. Lena needed to upload pictures to the festival album and fix their captions. Nothing else. The library also held unpublished photos from other stories, pictures the legal team had frozen, and albums the news desk was not ready to show anyone. With one flag, Theo had two choices. Without it, Lena could not upload at all. With it, Lena could approve photos for publication, delete anyone's work, and share any album the Gazette had.
The obvious repair is a second flag, is_freelancer, with exceptions in the routes Lena uses. That lasts until the next case arrives. The legal team needs to place holds without editing photos. Each case adds a flag, each flag is checked in whichever routes someone remembered, and soon nobody can say what an account is allowed to do without reading the code.
The flag answers a single question, whether to trust someone with everything, while the newsroom makes many separate decisions. Who may upload is a different decision from who may approve, and both differ from who may delete. A permission gives each of those decisions a name. It describes one action the library can allow or refuse, and the code asks about it at the point where the action happens. So the redesign started with a list of the actions the Gazette actually needed to control.
Actions on resource types
The Gazette names each permission after a type of resource and an action on it: photo.upload, photo.approve, album.share. Read aloud, each one completes a sentence. Someone may upload a photo. Someone may share an album.
Two other naming habits cause trouble. A permission named after a screen, such as upload_page, is tied to an interface that will change, and one screen often performs several actions with very different risks. The photo API has no screens at all, and it needs the same checks. A permission named after a job title, such as editor_access, bundles every action an editor performs into one switch, which is the flag's problem again. Bundles of permissions are useful, but they are roles, and Roles, assignments, and scope, later in this series, builds them from single actions like these.
Here is the Gazette's list, matched to the tasks people in the newsroom perform:
| Task in the newsroom | Permission |
|---|---|
| Browse the library and open a photo's details | photo.view |
| Download the full-resolution file for the print edition | photo.download |
| Add photos from an assignment to an album | photo.upload |
| Correct a caption or add keywords | photo.edit_caption |
| Record license terms and the photographer's credit | photo.edit_rights |
| Approve a photo for publication | photo.approve |
| Remove a duplicate or rejected photo | photo.delete |
| Place or lift a legal hold | photo.place_hold |
| Start an album for a new story | album.create |
| Share an album with another desk | album.share |
| Create a role or change what it includes | role.edit |
| Give someone a role | role.assign |
The hardest part of drawing up a list like this is granularity: how much each permission covers. A list that is too coarse forces people to be given more than they need. If the Gazette had a single photo.manage covering upload, approval and deletion, there would be no way to let Lena upload without also allowing Lena to approve and delete. A list that is too fine fails the other way. Separate permissions for captions, keywords and caption formatting could each be justified, but nobody would grant one without the others, and an administrator facing dozens of near-identical checkboxes soon starts ticking all of them.
The useful test is whether the newsroom ever makes the decision separately. Captions and keywords are always edited by the same people, so one permission, photo.edit_caption, covers both. License terms and credits look like two more text fields on the same photo, but getting them wrong breaks an agreement with a photographer or an agency, and the Gazette wants only a few people changing them. That is a separate decision, so it gets its own permission, photo.edit_rights. Uploading and approving are separate for the plainest reason of all: Lena does one and not the other.
Scopes, such as photos.read in Grants, scopes, and consent, look similar, and the Gazette keeps its naming different on purpose, because the two answer different questions. A scope limits what a client application, such as the printer, may do on someone's behalf. A permission describes what a person may do in the library. A request from the printer has to pass both.
None of these names mentions a particular album or photo, either. photo.upload says what Lena may do, not where. Where a permission applies is decided when it is granted, and the role lessons build that part too.
Checking permissions, not titles
With permissions defined, the next temptation appears in the code. When the Gazette first added roles to its library, the approve route checked the role's name. The redesign changed one line:
// user is req.auth.user, from the validated session or token.
// photo was loaded by the route from its own records.
// Before: the route decides which job titles may approve.
if (user.role === 'editor') {
await photos.approve(photo.id, user.id);
}
// After: the route asks whether this user may approve this photo.
if (can(user, 'photo.approve', photo)) {
await photos.approve(photo.id, user.id);
}
Both versions work on the day they are written. The difference shows when the newsroom changes its mind. Suppose the Gazette decides that senior photographers may approve their desk's pictures too, or that editors should no longer delete photos. In the first version, the meaning of "editor" is written into every route that mentions it. Someone has to find each one and change it, and any route they miss keeps the old meaning until someone notices. In the second, the route asks about an action. Which roles include photo.approve is stored once, as data, and changing it changes the answer for every route at the same moment, with no code change and no deployment.
The role check also assumes that a person has exactly one role, everywhere. Omar is a picture editor for the sports desk's pictures but can only browse the news desk's. "What is Omar's role?" has no single answer, while "may Omar approve this photo?" does. That is why can takes the photo as well as the permission. The answer may depend on which album the photo is in, who uploaded it, or whether the legal team has placed a hold on it, and later lessons in this series fill in each of those.
The user passed to can always comes from the authenticated session or validated token. It never comes from an ID in the request body or a header the client controls, because a request that may name its own user can simply name Omar.
Denying by default
The photo API in Enforcing access at an API refused any route missing from its scope table. The Gazette applies the same idea inside its own decisions. Every call to can starts at no, and only something that grants the requested permission for this photo can change the answer. Three situations that are easy to overlook all stay at no.
The first is the ordinary case where nothing applies. Lena asks to open a photo in the news desk's library. Nothing in the Gazette's model connects Lena to news photos, so the answer is no. A model that started at yes and refused only what some rule forbade would let Lena in, along with everyone else whose situation nobody had thought to write a rule for.
The second is a permission name the library does not know. A developer writes can(user, 'photo.aprove', photo), or keeps checking a permission that was later removed from the list. No role can include a name that is not on the list, so every request is refused. That sounds harsh, but it fails where everyone can see it: the first approval attempt is refused, and someone reports the bug that afternoon. If unknown names were treated as unrestricted, the typo would have opened approval to anyone, and nobody would have complained.
The third is an error while deciding. The database holding Lena's access times out, or a record is malformed. A decision that could not be finished must be treated as a refusal, which is called failing closed. Code usually gets this wrong through its shape rather than its intent:
// Fails open: an error makes the check report that nothing is in the way.
function isBlocked(user: User, permission: string, photo: Photo): boolean {
try {
return !grantedPermissions(user, photo).has(permission);
} catch {
return false;
}
}
// Fails closed: an unknown name or an error leaves the answer at no.
function can(user: User, permission: string, photo: Photo): boolean {
if (!knownPermissions.has(permission)) return false;
try {
return grantedPermissions(user, photo).has(permission);
} catch {
recordDecisionFailure(user.id, permission, photo.id);
return false;
}
}
Both functions perform the same lookup. The first asks whether anything stands in the way, so an error that stops it from finding anything reads as permission. The second asks whether anything grants access, so an error leaves it where it started. Recording the failure matters too. "Refused because nothing grants it" and "refused because the decision broke" look the same to Lena, but only the second needs someone to repair the library.
With default deny in place, Lena starts with nothing, and the Gazette can grant photo.upload and photo.edit_caption for the festival album alone. Making sure that each request really concerns that album, and not just one that its URL happens to mention, is the subject of Checking access to each object.