A shared workspace has to answer two questions about everybody in it, and they are not the same question. Can this person see it? And can this person change it? Most permission mistakes in software live in the gap between the two, in the place where a check that meant one was written as the other.
This post is about how Nemi keeps those questions apart: one place that decides what each role may do, a rule that every check must say which question it is asking, and a script that reads every handler before each build and stops it on one that names a workspace without a write gate.
What is the difference between a member and a role?
Being a member of a workspace means you are in it. In Nemi, that is enough to see everything there: every file, document, sheet and photo, and to open, preview and download them. What you may change is decided by your role, and there are four. The owner created the workspace and it runs on their plan. An admin runs it with them. An editor works in it. A viewer looks.
Pick a role below. Every tick in this table is the answer of the same function our server asks when a request arrives, imported into this page rather than copied from it.
What each role may do
Viewer. Can see and download everything, and change nothing.
A viewer may do 1 of these 8. Press a row for the reason.
Two rows deserve a word. Deleting something for good is split in two, because it is the one thing in a workspace that cannot be undone: an editor can tidy up anything and empty out their own work, and only an admin or the owner can make a colleague's upload gone for good. And nobody, not even an admin, can change the owner's role or remove them, because the workspace's storage, limits and features all come from the owner's plan. The help centre has the same table in words.
Why does every check have to say read or write?
The table is the easy half. The hard half is making sure every one of the several hundred places in the code that changes something actually asks it, and asks the right row.
So the functions that find the workspace a request is about take an intent, either"read" or "write", and that argument has no default. Not read, not write: nothing. A handler that leaves it out does not compile. That sounds pedantic until you consider the alternatives. A default of read means a handler that writes can be written without anybody deciding it should check for writing, and it will quietly let a viewer through. A default of write means a page that only lists files refuses the viewers it was meant to serve. Either default is a decision made by nobody, so there is none.
A request to change something, gate by gate
1/5The request arrives
Inside, there is one read check and one write check, and both get their answer from the same table you just clicked through. There is a second rule that keeps the role honest: it must always be the role of the person asking. A query that looks up a workspace's members has to be narrowed to the caller, or it can return somebody else's role and decide one person's permissions from another's.
How does the build check that writes are gated?
Rules in a document are kept by people, and people are busy. So the rule is also a script, and it runs as part of every production build. It walks every route module in the app, splits each into its handlers, and looks at every one that can change something: every POST, PUT, PATCH and DELETE. For each, it asks whether the handler names a workspace, and if it does, whether it carries a write gate. One that does not stops the build, and the deploy with it.
367
handlers that can change something, checked before every build
423
route modules read to find them
58
exceptions, each with a written reason
Every handler that can change something, one square each
367
handlers in 423 route modules
How we measuredHide the method
A harness in the repository repeats the guard's scan and sorts each handler into the first group that applies: a written exception, then no workspace in sight, then the kind of gate it carries. It refuses to write its numbers unless its totals match the ones the guard itself prints (367 handlers in 423 modules), and every exception is put in a category by hand, so a new one cannot slip into a group by keyword.
Handlers that can change something, by method: 215 POST, 65 PATCH, 79 DELETE and 8 PUT. Counted at commit fc6cfd7d.
The largest grey block needs a precise description. Those 221 handlers name no workspace in their own code: account settings, billing, signing in, staff tools, the apps that belong to a person rather than a workspace, such as Calendar, and handlers that hand the decision to a shared function that checks the role. The guard reads text, so it leaves them to code review and tests rather than claiming to have checked them.
What about handlers that have no role to check?
Some handlers change something without anybody signed in to hold a role. A client uploading to your upload link has no account; the link is the credential. Others ask for something stricter than write. For those, the build accepts an exception, but only one written into a file with the reason next to it, in a sentence a reviewer can check. An allowlist without reasons turns into a rubber stamp; one with reasons can be read, argued with, and corrected.
The exceptions, by what authorises them instead
Authorised by a link, a token or a code, for a guest with no account
Acts on the caller's own account, not on a workspace
Held to a stricter check than write: the owner, or the item's creator who still holds a writing role
A read sent as a POST, which changes nothing
Decided by the document's own role check, which refuses a viewer
- A link, a token or a code. Uploading to an upload link, answering a form, dropping a file into a room. The person has no account, so the credential in the link is what is checked, and what it allows is narrow.
- Stricter than write. Changing workspace settings, or managing a link you created, which needs both that you made it and that you still hold a writing role.
- A read sent as a POST. A search or a zip whose selection is too long or too private for a web address. It changes nothing, so it is held to the read check.
Membership decides who may look. The role decides who may change. The build looks for a handler that forgot to ask.
What can an automated check not prove?
It is worth being precise about what a script like this does. It reads source code as text. It checks that every handler which names a workspace carries a write gate, or a written reason why it needs none. It cannot prove that the gate is the right one, that it checks the right workspace, or that the reason in an exception is true. Those are still the job of code review and of tests, and each exception was checked by hand against the code it describes.
What the guard changes is the default. Without it, a handler with no write check is invisible until somebody stumbles on it. With it, that handler stops the build, and the way past is to write down, for the next person to read, why it is safe.
Roles answer who may change things. How much a workspace can hold, from storage to the number of documents, comes from the owner's plan, and the pricing page lists it. Roles also say nothing about who can read the data underneath, which is the subject of what encrypted at rest protects. Sharing outside the workspace has its own rules, set out on the Files page, and a domain of your own adds one more, in why it never serves a sign-in page. The rest of what we do to keep your work safe is on the security page.
Questions people ask
What is role based access control?
Role based access control means what a person may do is decided by the role they hold, not set person by person. Everybody with the same role gets the same permissions, so changing what someone may do is a matter of changing their role. In a Nemi workspace there are four roles: owner, admin, editor and viewer.
What is the difference between a viewer and an editor?
In Nemi, both can see, open, preview and download everything in the workspace. An editor can also create, edit, rename, move and trash things. A viewer can change nothing, and the buttons they cannot use are left out of the app rather than shown and refused.
Can a viewer download files from a shared workspace?
Yes. In Nemi, being a member of a workspace is what lets you see and download what is in it, whatever your role. The role only decides whether you may change anything. If somebody should not be able to download a file at all, it does not belong in a workspace they are a member of.
Who can permanently delete files in a shared workspace?
In Nemi, an editor can move anything to the trash and can delete for good what they uploaded themselves. Deleting for good what somebody else uploaded, including emptying the trash, is limited to admins and the owner, because it is the one thing in a workspace that cannot be undone.
Can an admin remove the owner of a workspace?
No. Nobody can change the owner's role or remove them, not even an admin, and nobody can change their own role. A Nemi workspace runs on its owner's plan, so its storage, limits and features all come from that person. Deleting the workspace and connecting a custom domain are left to the owner for the same reason.
Is hiding a button enough to stop somebody changing something?
No. A hidden button only tidies the screen; anybody can send the same request from a script. In Nemi the refusal that counts happens on the server, which looks up the caller's own role in the workspace a request wants to change, and a build check flags any handler that names a workspace without a write gate or a written reason.
Where the numbers come from
- What each role may do, decided in one place:
lib/workspace-permissions.ts (roleCan, roleMayDelete, canManageMember) - Read and write checks against the database:
lib/workspace-access.ts (userCanReadWorkspace, userCanWriteWorkspace) - An intent with no default:
lib/workspace-route.ts (WorkspaceIntent, resolveRequestWorkspaceId) - The build guard:
scripts/check-write-gates.ts (bun run guard:writes, run by bun run build) - The exceptions, each with its reason:
scripts/write-gate-baseline.json - The counts in this post, from a harness that must agree with the guard:
scripts/blog-bench/role-checked-writes/count.ts, lib/blog/data/write-gates.json - Roles, as customers read them: /help/workspaces/members-and-roles