Why does an application store permissions as resource-action strings like establishment:inspect rather than one boolean column per feature?
answer
- capability, not person
- one action, one resource class
- data rather than schema
- resource:action, stable spelling
- membership test, deny by default
basics
~20 sA permission string names one resource and one action together: establishment:inspect is the inspect action on establishment records. Keeping permissions as rows makes capability data rather than schema, so adding one is an insert instead of a new column and a deployment.
solid answer
~50 sA permission is a named capability — one action on one class of resource, written as a stable string such as `establishment:inspect` or `report:sign_off`. It says nothing about who holds it or which record it applies to; those are properties of the grant, not of the name. Storing the vocabulary as rows in a permission table, joined to roles, means a new capability is an insert and a seed entry rather than a column, a migration and a redeploy, and the request-time check is the same operation every time: assemble the caller's effective permission set and test whether the required string is in it. A boolean column per feature spreads authorization across the user table's shape, so every new feature changes the schema, nothing can enumerate what a role may do, and the check ends up as a different hand-written expression at every call site.
code
json · 12 lines{
"permissions": [
{ "key": "establishment:view", "description": "Read an establishment record" },
{ "key": "establishment:inspect", "description": "Record an inspection visit" },
{ "key": "establishment:reopen", "description": "Return a closed establishment to trading" },
{ "key": "report:sign_off", "description": "Sign an inspection report as final" }
],
"roles": [
{ "key": "inspector", "permissions": ["establishment:view", "establishment:inspect"] },
{ "key": "area_supervisor", "permissions": ["establishment:view", "establishment:inspect", "report:sign_off", "establishment:reopen"] }
]
}go deeper
Be able to read a permission string aloud and say what it denotes: one action on one class of resource, independent of who holds it and which record is being touched.
Explain the join from principal to role to permission and why the check is one set-membership test; show why a boolean column per feature turns every new capability into a schema migration.
Talk about vocabulary discipline over years: verb drift, wildcards silently granting future actions, and the data migration you owe when a capability is split or renamed.
Decide what the vocabulary is allowed to express at all, because every string you publish is something you must still be able to rename later without touching every stored grant.
## What a permission string actually denotes A **permission** is a named capability: exactly one **action** on exactly one **class of resource**. `establishment:inspect` denotes the inspect action over establishment records. Three things it deliberately does not denote: - **who holds it** — that is a grant, a separate row joining a principal to a role; - **which record** it applies to — an individual establishment is chosen at request time, not baked into the name; - **where the check runs** — the same string is tested by an HTTP handler, a scheduled job or a message consumer. Keeping those three out of the name is what makes the vocabulary reusable. The moment a name starts carrying an organisational unit (`reopen_establishment_in_district_7`) or a screen (`can_see_reopen_button`), the vocabulary stops describing the domain and starts describing one caller's situation, and it will be re-invented for the next district or the next screen. ## Why a row, and not a column The alternative most teams try first is a boolean column per capability on the user table — `can_inspect`, `can_sign_off`, `can_reopen`. It works for about six months. | | Boolean column per feature | Permission rows joined to roles | |---|---|---| | Adding a capability | Schema migration plus a deploy on every service reading the table | An insert into the permission table and a seed entry | | Asking what a supervisor may do | Read the code that sets the columns | Select the rows for that role | | Changing what supervisors may do | Backfill every supervisor's row | Edit one role's permission rows | | The request-time check | A different expression per call site | One set-membership test against the required string | | Auditing the vocabulary | No list exists | The table is the list | The column design also hides a subtler cost: it has no place to put a role at all, so seniority gets encoded as a combination of columns that only the code understands. Nobody can answer "what does an area supervisor actually get?" without reading every branch that reads those columns. Be honest about what rows do **not** buy you. Adding a capability is an insert, but **renaming or splitting one is still a data migration**, because every grant and every hard-coded check that mentions the old string has to move. Rows make the vocabulary cheap to extend and no cheaper to change. ## Naming rules that survive a growing product 1. **Resource first, action second**, one delimiter, one spelling — pick `resource:action` and never mix in `action_resource` elsewhere. 2. **One verb per meaning.** `read`, `view`, `list` and `get` are four names for one action if you let them be; agree on one and write it down beside the table. 3. **Name the domain object, not the screen.** `establishment:reopen`, not `reopen_page_access`. User interfaces get redesigned; establishments do not. 4. **No unit, no record, no person inside the string.** Anything that varies per grant belongs on the grant row. 5. **Additive by default.** A wildcard such as `establishment:*` is convenient and quietly grants next year's actions to everyone who holds it today; prefer enumerating, or accept that the wildcard is a decision you re-make every time you add a verb. ## What the server does with the string At request time the server assembles the caller's **effective permission set** — the union of the permission rows reachable from the roles that principal has been granted — and tests whether the string the handler requires is a member. Two properties matter: - **Deny by default.** Absence from the set is a denial; the check never has an "unknown, so allow" branch. - **The required string is written at the call site, not derived.** `establishment:inspect` appears literally in the handler, so a reader of that handler can see what it demands, and a test can enumerate handler-to-permission pairs. The set is small — tens of strings — so it is normally assembled once per request and passed down rather than re-queried in each layer. ## The failure modes to recognise - **Verb drift**: `report:view` and `report:read` both exist, half the handlers demand one and half the other, and a role grants only one of them. - **Role names used as permissions**: a handler demanding `admin` instead of a capability. Now the only way to give one capability to somebody is to give them everything. - **Permissions minted per customer**: a string that contains a district, a customer or a person is a grant wearing a permission's clothes. - **Nothing owns the list**: strings are invented in handlers and never inserted, so the table is no longer the vocabulary and the enumeration stops being true. A permission vocabulary is worth exactly as much as its discipline: a short list of stable, domain-shaped strings that a person can read aloud and a query can enumerate.
- Where does the record being acted on enter the decision, if the permission string does not name it?The permission answers whether this kind of action is available to the caller at all. Which establishment is being inspected is then matched against the caller's grants and the record's owning unit. Keeping the two separate is what stops the vocabulary growing one string per record.
- Is a wildcard permission such as establishment:* ever the right call?It is defensible for a break-glass administrator role that is deliberately unbounded, and poor everywhere else, because it grants every verb you add later to everyone who already holds it. If you use one, treat adding a new verb as a change to that role's blast radius and review it as such.
- How do you stop a handler demanding a permission string that was never inserted into the table?Enumerate them: collect the strings the handlers demand and compare that set with the permission table in a test. A string in the code but not in the table can never be granted, so the endpoint is permanently closed; a string in the table with no handler is dead vocabulary.
saying these in an interview costs you the question
- Treats a role name as a permission and checks for admin at the handler.
- Puts a district or a customer inside the permission string.
- Names permissions after screens and buttons rather than domain actions.
- Claims permission rows make renaming a capability free; renaming is still a data migration.
- Invents a new verb per handler, so view, read and list all exist.
- Says a boolean column per feature is equivalent because both end in an if statement.