Roles Were the Easy Part: Scoping Authorization for a Small Multi-Account Service Business
Read this framing before anything else. What follows is a design exercise. KMJ Tire does not run the system described here, has not built it, has not deployed it, and has no incident history behind it. There is no production deployment, no rollout, no migration, no adoption curve, and no measured performance to report. Every figure that appears below — a count of vehicles, a token lifetime, a cache window — is illustrative and chosen to make an argument legible, not lifted from a running service. What is real is the operating environment: a Calgary tire and oil-change business with retail customers, commercial fleet accounts, a mobile van, and a savage seasonal peak. That reality is useful because it constrains the design in ways a made-up SaaS example never does. The code is written to be argued with, not pasted into a repository.
Now the actual problem.
The Cast of Readers Nobody Warns You About
Service records look boring until you write down who has to read them.
A technician in the bays needs the vehicle, the work ordered, the tread readings, and the history on that specific unit. A front-counter staffer needs the same record plus the customer's phone number, because they are the one who has to reach the customer when a tire arrives damaged. The owner needs everything, including margin. A retail customer needs their own vehicle's history and nothing else — not their neighbour's, not the vehicle they sold two years ago, and certainly not the one whose plate is one character off from theirs.
Then the interesting ones arrive.
A commercial fleet account has a manager who must see all twelve vans the company operates, across every visit, across multiple drivers, including work performed before that manager was hired. A driver of van seven should see van seven's record so they can confirm a rotation happened, and should see nothing about vans one through six. A third-party accountant who does the fleet's books needs invoice totals, tax lines, and dates, and must never see a driver's mobile number or a home address.
Seven readers. One table of service records. That gap — between a role name and a set of rows — is the whole article.
The failure modes are asymmetric and both are bad. Get it wrong in the permissive direction and one customer reads another customer's data, which in Alberta is a privacy incident with a reporting obligation attached, not a bug you quietly patch on Friday. Get it wrong in the restrictive direction and staff route around the system: shared logins, a spreadsheet that mirrors the real data with none of the controls, a phone photo of a screen sent over text. Clumsy authorization does not produce careful users. It produces a shadow copy of your database on someone's personal device.
Three Questions That Keep Getting Merged Into One
Most authorization messes I have read start as a vocabulary problem. Three separate questions get answered by the same lump of code, and after eighteen months nobody can say which part is which.
Who is this? Authentication. A session token, an OIDC flow, an API key on a fleet integration. Its only output is a verified subject identifier. It says nothing about permission, and a surprising amount of bad design comes from treating a successfully authenticated request as a partly authorized one.
Is this subject allowed to perform this action on this object? Authorization. A pure question with a boolean answer, and — this is the part people skip — it needs three inputs, not one. Subject, action, object. A system that only ever consults the subject cannot answer it, and a system that consults user.role and the action but never the object is answering a different, easier question and hoping the difference does not matter.
Where is the answer obtained and applied? The enforcement point. This is architecture, not policy, and it is where most systems rot.
The literature gives us two names worth using because they force the split to stay visible. The Policy Decision Point is the component that computes allow or deny. The Policy Enforcement Point is the code path that asks and then honours the answer. A decision point that nobody consults is decoration. An enforcement point with the rules inlined is a decision point that has been smeared across two hundred files.
Which brings me to the line I want to argue against for the rest of this piece:
if user.role == "admin":
return service_record
There is nothing syntactically wrong with it. The problem is what it becomes. Written once in a controller, it is fine. Written in forty controllers by six people over three years, it is a policy — an important one, governing who reads customer data — that exists only as an emergent property of grep. You cannot review it. You cannot version it. You cannot answer "who can read this record" without reading every route. And when the requirement changes from admin to admin, or the owner of the vehicle, or a manager of the organization that owns the vehicle, unless the record is flagged confidential, you now need to find all forty sites and update them identically, which nobody has ever done successfully.
The rot is not the check. The rot is that the check is the only place the rule exists.
Where Role Checks Quietly Stop Answering the Question
Role-based access control earns its reputation. It maps cleanly onto how a service business already thinks — technician, counter staff, manager, owner — and for verbs it is genuinely the right tool. Who may void an invoice? Who may issue a refund? Who may change a tax rate? Those are role questions and they should stay role questions.
RBAC answers what kind of action a subject may perform. It does not answer on which rows. For a single-tenant internal tool nobody notices, because the answer to "which rows" is always "all of them." Introduce customers reading their own records and you have quietly become multi-tenant, and the model develops a hole it cannot close.
Watch it happen. The fleet manager at Acme needs to see Acme's twelve vans. So somebody adds a role:
fleet_manager_acme
fleet_manager_globex
fleet_manager_northgate
I want to be fair to this pattern, because it does not come from ignorance. It comes from having exactly one place to express permission — the roles table — and a real requirement that does not fit there. Given only a hammer, encoding the customer identity into the role name is the locally rational move. It even works for a while.
Then it stops. Consider what has actually been built. The permission model now has unbounded cardinality: every new fleet account is a schema-adjacent change, a deploy, or at minimum a row that some human has to remember to create correctly. Any code that wants to know "which organization is this subject scoped to" has to parse a string, which means a customer named fleet_manager breaks your parser and a customer with a hyphen in their name breaks it differently. Revocation is a delete with no history — you cannot answer "did this person have access last March" because the evidence was the row you removed. Nothing carries an expiry, so the manager who left in April still has their role in November. And the cross product is coming: a manager who oversees two accounts, an account with a manager and a supervising dispatcher, a driver who is also the owner of the company. Now you need fleet_manager_acme_readonly and the naming scheme collapses under its own punctuation.
The tell is simple and worth memorizing. When the value inside a role name is a foreign key, you are not modelling a role. You are modelling a relationship, badly, in a column that cannot hold one.
A fleet manager is not a kind of person. A fleet manager is a person in relation to an organization, for a period of time, with a scope. That sentence has four moving parts, and a role name has one slot.
Naming the Three Models Honestly
Three families of model are worth knowing. The trade-offs are real and I will not pretend one dominates.
RBAC attaches permissions to named roles and roles to subjects. Cheap, explicable, auditable in an afternoon. Blind to rows.
ABAC — attribute-based — evaluates a rule over attributes of the subject, the object, and the environment. subject.org_id == object.owner_org_id AND now() BETWEEN grant.valid_from AND grant.valid_until. It is enormously expressive, which is both the feature and the bill: expressive policy languages are hard to reason about, hard to test exhaustively, and hard to answer reverse queries against. "Which records can this subject read" is not naturally answerable from a rule engine that only evaluates one tuple at a time, and that reverse query is exactly what your list endpoints need.
ReBAC — relationship-based, the Zanzibar shape — stores permission as a graph of tuples: object#relation@subject. Access is a reachability question. vehicle:van-07#viewer@org:acme#member says every member of Acme may view van seven, and membership itself is another tuple, so nesting comes free. It handles delegation and indirection beautifully, supports both the check query and the list query, and it is the heaviest thing on this list to operate.
| Dimension | RBAC | ABAC | ReBAC |
|---|---|---|---|
| Core question | what may this kind of user do | does this rule evaluate true | is there a path from subject to object |
| Row scoping | absent | possible, awkward to invert | native |
| New tenant onboarding | new role, deploy | data only | one tuple |
| "List everything I can see" | trivial but wrong | expensive or impossible | supported by design |
| Delegation with expiry | not expressible | expressible via attributes | expressible as a tuple with validity |
| Debugging a denial | read the role | trace rule evaluation | trace graph traversal |
| Operational weight | near zero | moderate | high — a service, a store, consistency semantics |
| Fits a three-person business | yes | usually not | almost never |
The honest answer for the domain in question is a hybrid, and I do not think that is a cop-out. Roles for verbs, because verbs really are role-shaped. Relationships for rows, because rows really are relationship-shaped. A small amount of attribute logic at the edges for things like validity windows and confidentiality flags. What matters is not which family you pick — it is that the rules live in one addressable place instead of being distributed across controllers as folklore.
The Relationship Graph, Written in Boring SQL
You do not need a graph database to store a graph this small. A few thousand relationship rows in PostgreSQL is not a scaling problem, it is a rounding error, and the operational simplicity of having authorization data in the same transaction as business data is worth a great deal.
Start with the nouns.
CREATE EXTENSION IF NOT EXISTS citext;
CREATE TABLE subjects (
subject_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
kind text NOT NULL CHECK (kind IN ('staff','customer','service_account')),
display_name text NOT NULL,
email citext UNIQUE,
disabled_at timestamptz
);
CREATE TABLE organizations (
org_id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
legal_name text NOT NULL,
account_type text NOT NULL CHECK (account_type IN ('retail','commercial')),
opened_on date NOT NULL DEFAULT current_date
);
CREATE TABLE vehicles (
vehicle_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
vin text UNIQUE,
plate text,
unit_label text,
owner_org_id uuid NOT NULL REFERENCES organizations (org_id),
retired_on date
);
A retail customer is an organization of one. That feels like overhead for about a day and then it pays for itself permanently, because every downstream query stops needing a branch for "is this a person or a company." One shape, one predicate.
Now the edge that does the real work.
-- btree_gist lets uuid and text equality share an index with a range
-- overlap test, which the exclusion constraint below depends on.
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TYPE scope_kind AS ENUM ('organization', 'vehicle');
CREATE TABLE access_grants (
grant_id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
subject_id uuid NOT NULL REFERENCES subjects (subject_id),
scope_type scope_kind NOT NULL,
scope_id uuid NOT NULL,
relation text NOT NULL CHECK (relation IN ('owner','manager','driver','bookkeeper')),
validity tstzrange NOT NULL DEFAULT tstzrange(now(), NULL, '[)'),
granted_by uuid REFERENCES subjects (subject_id),
grant_reason text,
revoked_at timestamptz,
revoked_by uuid REFERENCES subjects (subject_id),
EXCLUDE USING gist (
subject_id WITH =,
scope_id WITH =,
relation WITH =,
validity WITH &&
)
);
CREATE INDEX grants_by_subject ON access_grants USING gist (subject_id, validity);
CREATE INDEX grants_by_scope ON access_grants (scope_type, scope_id);
Five things about that table are deliberate.
validity is a range type rather than a pair of nullable timestamps. It gives you overlap operators, GiST indexing, and the exclusion constraint on the last four lines, which makes it structurally impossible to have two overlapping identical grants — no application-layer uniqueness dance, no race between two counter staff creating the same access twice.
granted_by and grant_reason exist because six months later someone will ask why a dispatcher could see a vehicle, and "there was a row" is not an answer anybody accepts.
revoked_at is separate from closing the validity range on purpose. Letting a range expire naturally and killing access deliberately are different events with different urgency, and flattening them loses the distinction exactly when an investigation needs it.
scope_type plus scope_id is a deliberately loose polymorphic reference. It costs a real foreign key, which I dislike, and it buys the ability to add scope_kind values without a new table each time. A stricter alternative is two nullable columns with a check constraint that exactly one is populated. I have shipped both. Pick based on how much you trust the code paths that write here.
And relation is a small closed vocabulary, not free text. Four values. If you find yourself wanting a fifth every quarter, that is a signal your relations are actually roles in disguise and you should split the concepts.
Validity Ranges Change What a Grant Means
Time is the part that gets bolted on last and should have been there first.
A grant without a validity window is a permanent statement about the world, and almost nothing in a service business is permanent. A dispatcher covers a two-week vacation. A seasonal changeover rush brings in temporary counter help who need record access for six weeks in October and November and then do not. A leased van moves from one company to another and the receiving company's manager should see it going forward. A customer's spouse should be able to approve work while the customer is out of the country.
Every one of those is naturally a bounded interval, and every one of them is misrepresented by a boolean. The default in the schema above is [now, unbounded) — open-ended when you genuinely mean open-ended — but the mechanism for bounding it is present from the first migration rather than retrofitted after somebody notices the vacation coverage never got removed.
The query that answers "what does this subject have right now" stays honest:
SELECT scope_type, scope_id, relation
FROM access_grants
WHERE subject_id = $1
AND validity @> now()
AND revoked_at IS NULL;
Two conditions, one index, no application logic deciding what "active" means. That last point deserves emphasis: the definition of an active grant should exist in exactly one query, because the moment it exists in two, they will disagree, and the disagreement will be discovered by a customer.
One Decision Function, Consulted Everywhere
With relationships in a table, the permission question becomes a function with a stable signature. Same three arguments every time, one place to change.
from dataclasses import dataclass
from typing import Literal, Sequence
Action = Literal["view", "view_pii", "view_financials", "create", "amend", "void"]
@dataclass(frozen=True)
class Decision:
allowed: bool
reason: str # stable machine code, e.g. "no_active_grant"
policy_version: str # what ruleset produced this
obligations: tuple[str, ...] = () # e.g. ("redact:phone", "redact:address")
ROLE_VERBS = {
"technician": {"view", "create"},
"counter": {"view", "view_pii", "create", "amend"},
"owner": {"view", "view_pii", "view_financials", "create", "amend", "void"},
}
RELATION_VERBS = {
"owner": {"view", "view_pii", "view_financials"},
"manager": {"view", "view_pii", "view_financials"},
"driver": {"view"},
"bookkeeper": {"view_financials"},
}
POLICY_VERSION = "authz-2026.08.3"
def decide(subject, action: Action, record, grants: Sequence["Grant"]) -> Decision:
if subject.disabled_at is not None:
return Decision(False, "subject_disabled", POLICY_VERSION)
# Staff path: verb permission from role, row permission from employment.
if subject.kind == "staff":
if action not in ROLE_VERBS.get(subject.staff_role, set()):
return Decision(False, "role_lacks_verb", POLICY_VERSION)
return Decision(True, "staff_role", POLICY_VERSION)
# External path: every allowance must trace to a live relationship.
reachable = [
g for g in grants
if g.covers(record.vehicle_id, record.owner_org_id) and g.active_at(now())
]
if not reachable:
return Decision(False, "no_active_grant", POLICY_VERSION)
permitted = set().union(*(RELATION_VERBS[g.relation] for g in reachable))
if action not in permitted:
return Decision(False, "relation_lacks_verb", POLICY_VERSION)
obligations = ()
if "view_pii" not in permitted:
obligations = ("redact:phone", "redact:email", "redact:address")
return Decision(True, "grant_" + reachable[0].relation, POLICY_VERSION, obligations)
The function is unremarkable, and it should be. What matters is everything around it.
It returns a structured verdict rather than a bare boolean. reason is a stable code that lands in the audit stream and, when you choose, in a support conversation. policy_version is stamped on the answer so that a decision recorded in July can still be explained in December after the rules moved. obligations is the escape hatch that keeps field-level rules from metastasizing into serializers, and I will come back to it.
Notice the bookkeeper. That relation grants view_financials and nothing else — no view, no view_pii. The accountant reading invoices for a fleet cannot resolve a driver's mobile number, not because a serializer somewhere remembers to strip it, but because the verb was never granted. That is the difference between a rule and a habit.
Notice also that grants are passed in rather than fetched inside. The decision stays pure and trivially testable; the fetching is somebody else's problem, which means it can be batched, cached, and instrumented without touching policy code. Pure functions are not an aesthetic preference here — they are what makes the test matrix later in this piece cheap enough that people actually write it.
Enforcement Wants a Chokepoint, Not a Sprinkle
Having a decision function is worthless if reaching data does not require passing through it.
The failure I have seen most often is a codebase that adds a clean policy module, wires it into the endpoints someone remembered, and leaves twelve other paths untouched: a CSV export, an internal admin view, a webhook receiver that echoes a record back, a "resend the receipt" utility, a nightly job that emails summaries. Each of those reads the same table. Policy coverage of 80 percent of read paths is not 80 percent of the protection. The attacker, or more likely the bored curious employee, uses the other 20 percent.
So the enforcement point should be structurally unavoidable. In a service business app, the honest place for it is the repository layer — the thin band of code that is the only thing permitted to build a query against service records.
class ServiceRecordRepo:
def __init__(self, db, principal: Principal):
self._db = db
self._principal = principal # no default; cannot be omitted
def _scope(self) -> tuple[str, list]:
"""Returns a SQL predicate that is ANDed into every read. Never empty."""
p = self._principal
if p.is_staff:
return "TRUE", []
return (
"""EXISTS (
SELECT 1 FROM access_grants g
WHERE g.subject_id = %s
AND g.validity @> now()
AND g.revoked_at IS NULL
AND (
(g.scope_type = 'vehicle' AND g.scope_id = sr.vehicle_id)
OR (g.scope_type = 'organization' AND g.scope_id = sr.owner_org_id)
)
)""",
[p.subject_id],
)
def find(self, record_id):
pred, args = self._scope()
return self._db.one(
f"SELECT * FROM service_records sr WHERE sr.record_id = %s AND {pred}",
[record_id, *args],
)
def page(self, cursor, limit=50):
pred, args = self._scope()
return self._db.many(
f"""SELECT * FROM service_records sr
WHERE {pred} AND sr.created_at < %s
ORDER BY sr.created_at DESC LIMIT %s""",
[*args, cursor, limit],
)
Three properties are doing the work. The repository cannot be constructed without a principal, so there is no ambient "system" mode that a tired developer reaches for at 6pm. _scope() returns a predicate rather than a boolean, so it composes into any query instead of gating one. And every read method interpolates it — which is the part a reviewer can actually verify, because a missing {pred} is visible in a diff in a way that a missing if several files away is not.
You can enforce that mechanically. A lint rule, or a test that reflects over the repository's public methods and fails when a new one does not reference the predicate, converts a convention into a wall. Conventions decay under deadline pressure. Walls do not.
Filter in the Query, Never After It
There is a specific implementation choice here that separates systems that hold up from systems that leak, and it is small enough that people underestimate it.
Fetch-then-filter looks like this: pull the rows, loop, drop the ones the subject may not see, return the rest. It reads naturally and it is wrong twice over.
It is wrong on performance, obviously — you are asking the database for a thousand rows to hand back nine, and pagination becomes incoherent because page size is applied before filtering, so a subject with narrow access gets a page of two, then a page of zero, then a page of one, and your cursor logic starts lying about whether more data exists.
It is wrong on safety more seriously. Every filtered row has already crossed a trust boundary. It exists in application memory. It appears in the query log, in the ORM's debug output, in an APM trace, in a heap dump, in a crash report shipped to a third party. And the filter itself is now one continue statement away from disappearing. I have watched a refactor convert a filtered loop into a generator expression and quietly drop the condition, and the tests passed, because the tests asserted on what an authorized subject could see and nobody had written the negative case.
Scope belongs in the WHERE clause. If the predicate is not there, the row must not be materialized.
Which leads to the bug class that will bite this design harder than any other.
The List Endpoint That Forgot the Predicate
Single-record reads are usually safe by accident. Someone requests record abc, the handler looks it up, and even a careless implementation typically compares an owner identifier before returning it, because the shape of the code invites the comparison.
List endpoints are different. A list endpoint's natural implementation is "select from the table with the filters the user asked for," and the tenant predicate is not one the user asked for. It has to be added by someone who remembered. Under deadline pressure — a report for the owner, a CSV for a fleet account, a search box that needs to ship Thursday — it is exactly the line that does not get written.
The characteristic signature: the endpoint works correctly in every manual test, because whoever tested it had broad access and saw everything, and everything looked right. The bug is only visible from a low-privilege session, which is the session nobody logs in as.
Countermeasures, in descending order of how much I trust them:
- Structural. There is no way to query the table except through the scoped repository. The unscoped query does not exist to be written.
- Database-enforced. Row-level security applies the predicate whether or not the query author remembered. More on the bill for that below.
- Test-enforced. A negative-path test per list endpoint, executed as a subject with exactly one visible row, asserting the count is exactly one.
- Review convention. Somebody notices in a pull request. This works until the reviewer is busy, which is always.
Rely on the first two. Treat the third as the safety net that catches what they miss. Do not rely on the fourth alone; it is not a control, it is a hope.
Row-Level Security and What It Actually Costs
PostgreSQL can enforce the predicate itself. Turn on row-level security, attach a policy, and no query — not from the application, not from a psql session, not from an analytics tool somebody wired up — sees rows outside the policy.
ALTER TABLE service_records ENABLE ROW LEVEL SECURITY;
ALTER TABLE service_records FORCE ROW LEVEL SECURITY;
CREATE POLICY sr_visible_to_subject ON service_records
FOR SELECT
USING (
current_setting('app.subject_kind', true) = 'staff'
OR EXISTS (
SELECT 1 FROM access_grants ag
WHERE ag.subject_id = current_setting('app.subject_id', true)::uuid
AND ag.validity @> now()
AND ag.revoked_at IS NULL
AND (
(ag.scope_type = 'vehicle' AND ag.scope_id = service_records.vehicle_id)
OR (ag.scope_type = 'organization' AND ag.scope_id = service_records.owner_org_id)
)
)
);
That is a genuinely strong control and I like it more than most people expect me to. It is also not free, and the costs are the kind that show up at month four rather than day one.
Session variables versus pooled connections. The policy reads current_setting('app.subject_id'), which has to be set per request. In a pooled environment, connections are shared, so the variable must be set with SET LOCAL inside the transaction and it must be set on every path. Forget once and you get a request executing under whatever the previous tenant left behind, which is precisely the leak the policy was supposed to prevent. Transaction-scoped pooling makes this workable; session pooling makes it a trap. And any code that touches the database outside a transaction — a health check, a migration runner, a background job written in a hurry — needs an explicit story.
Debuggability. A query returns zero rows. Is the data absent, is the policy excluding it, or did the session variable not get set? The database will not volunteer which. You end up building tooling to answer a question that an application-level predicate answers by being visible in the SQL you are already reading.
Migration pain. Policies are schema objects and they drift from application logic. A new column that should be scoped differently, a new relation type, a new table that someone forgets to enable RLS on — each is a separate migration, reviewed by whoever reviews migrations, which is often not whoever thinks about authorization. FORCE ROW LEVEL SECURITY matters here too: without it, the table owner bypasses the policy entirely, and application accounts are frequently table owners by accident.
Superuser and bypass roles. Anything with BYPASSRLS, and the owner without FORCE, walks straight through. Your reporting user, your ETL job, your ORM's migration connection. The policy is only as strong as the least careful role in your cluster.
Performance shape. The EXISTS subquery runs per row. With good indexes on a table this size that is fine. On a wide scan with a poor plan it is not, and the planner's behaviour under RLS is harder to reason about because the predicate is injected rather than written.
My position: RLS is excellent as a second layer and uncomfortable as the only layer. Scope in the repository so the predicate is visible, reviewable, and debuggable, and enable RLS underneath so that the day someone writes a query outside the repository — and someone will — the database refuses anyway. Defence in depth is not redundancy when the two layers fail for different reasons.
Fail Closed, Especially When It Is Inconvenient
Every authorization design eventually confronts the case where the answer cannot be obtained. The policy service times out. The grants query deadlocks. A cache node drops. Somebody deploys a bad migration at 4pm on the Friday before a long weekend.
There are two possible defaults and only one defensible one.
Allow-on-error is how breaches happen, and it never arrives as a decision anybody would defend out loud. It arrives as a try block written to stop an unrelated page from erroring, with a comment like "don't block the user on a policy hiccup." It arrives as a circuit breaker whose open state returns permissive defaults because the person configuring it was thinking about a recommendations widget. It arrives as a default parameter — def can_view(subject, record, default=True) — that seemed harmless at the definition site. And the resulting failure is silent. Nobody notices that during a 40-second degradation every fleet manager could read every other fleet's records, because success looks exactly like success.
Deny-on-error produces a loud, ugly, extremely visible failure. Staff cannot work. Customers see an error page. Someone gets paged. That is the correct outcome, because the alternative is an invisible failure whose blast radius you can only reconstruct from logs you may not have kept.
The engineering work is not choosing deny — that part is easy. It is making deny survivable:
- Keep the decision path short. If authorization requires a network hop to a separate service, you have added a dependency to every request. Grants in the same database as the data means the authorization query fails exactly when the data query fails, and there is no partial-availability state to reason about.
- Cache conservatively and treat staleness as a bounded risk, not a fallback. A short-lived positive cache absorbs a blip. Serving decisions from a cache with no upper bound because the source is down is allow-on-error wearing a hat.
- Distinguish the failure in the response. A 503 with "we could not determine your access" is honest. A 403 saying "you do not have permission" is a lie that will generate support noise and, worse, train people to disbelieve real denials.
-
Alert on the denial reason distribution, not the rate. A rise in
no_active_grantis business as usual during onboarding. A rise inpolicy_unavailableis an incident.
Write the deny-path test first. Take the grants table offline in a test environment and assert every protected route returns 503, not 200. It is a ten-minute test and it is the one that would have caught every allow-on-error I have ever read.
Fields Are Objects Too
The accountant case breaks the mental model that authorization operates on rows.
A bookkeeper working a fleet's books needs the invoice: line items, quantities, tax, totals, dates, the vehicle unit label. They do not need the driver's mobile number, the customer's home address, or the note the technician left about which gate code gets you into the yard. Same row. Different columns.
The naive fix — build an InvoiceForAccountantSerializer — works exactly once. Then somebody adds a CSV export with its own column list, and a PDF renderer with its own template, and a webhook payload assembled by hand, and a search index that flattens everything for convenience. Now the rule "bookkeepers never see phone numbers" lives in five places written by four people, and one of them is a search index that happily returns a phone number in a snippet.
The rule belongs in one place, expressed as data:
FIELD_REQUIREMENTS = {
"customer_phone": "view_pii",
"customer_email": "view_pii",
"service_address": "view_pii",
"gate_or_yard_note": "view_pii",
"line_items": "view_financials",
"labour_total": "view_financials",
"parts_margin": "view_margin",
"vehicle_unit": "view",
"performed_on": "view",
"tread_readings": "view",
}
def shape(record: dict, permitted: set[str]) -> dict:
out = {}
for field, value in record.items():
required = FIELD_REQUIREMENTS.get(field)
if required is None:
continue # unknown field: omit, do not pass through
if required in permitted:
out[field] = value
return out
Two decisions in nine lines are worth defending.
Unknown fields are dropped rather than emitted. A new column added by a migration is invisible until someone classifies it. That is mildly annoying during development and it is the correct trade, because the alternative default — pass through anything unclassified — means every new column is a potential disclosure and the failure is silent. This is the same fail-closed argument applied to schema evolution.
And shaping happens after the decision, driven by the obligations the decision returned, in a single function that every output path goes through. Not each serializer's private judgment. If the PDF renderer receives a record, it receives one that has already been shaped, and it is structurally incapable of printing a number it was never handed.
One caveat worth stating plainly: omitting a field and nulling a field are different signals, and clients notice. Omission means "not available to you." Null means "genuinely empty." Mixing them produces a client that renders "Phone: —" for a bookkeeper and a support conversation about missing customer data that was never missing. Pick one convention, document it, and make the shaping function the only thing that decides.
Records Before the Relationship, and After It Ends
This is the part that gets discovered in production and it deserves more design attention than it usually receives.
A relationship starts at a moment. The records do not.
A fleet hires a new operations manager in March. The vans have been serviced for four years — changeovers, rotations, flat repairs, oil changes. Should the new manager see the 2023 history? Almost certainly yes: the records belong to the organization, the manager acts for the organization, and being unable to see last year's tread readings makes the job impossible.
A driver is assigned van seven in June. Should they see van seven's records from before their assignment? Probably not all of them. The driver's legitimate interest is the vehicle they operate now, and last year's driver's routes, notes, and any incident detail are somebody else's business.
So the two relations have different temporal semantics, and that is a design decision, not an accident to be discovered:
| Relation | Backward reach | Forward reach after end | Rationale |
|---|---|---|---|
| organization manager | full history of the org | none | records are org property, manager acts for the org |
| organization bookkeeper | full financial history | window for filing periods | tax work is inherently retrospective |
| vehicle driver | from assignment start | none | operational need is present-tense |
| delegated dispatcher | inherits delegator's reach, capped | none | delegation cannot exceed its source |
Encode it rather than implying it. Add a column:
ALTER TABLE access_grants
ADD COLUMN history_reach text NOT NULL DEFAULT 'from_grant_start'
CHECK (history_reach IN ('from_grant_start', 'all_history', 'financial_history'));
Then the scope predicate consults it, and "can the driver see last spring's service" has an answer stored in a row instead of an answer that depends on which developer wrote which endpoint.
The end of a relationship is harder, and the case that forces clarity is a van changing companies. Unit seven is sold from Acme to Northgate in July. Northgate's manager needs to see the vehicle. Do they get the four years of records from when Acme operated it?
No. Those records describe work performed for a different customer, and handing them over is a disclosure with no consent behind it. But the vehicle is the same physical object with the same VIN, and there is a real safety argument that the current operator should know something about its history — that a tire was repaired rather than replaced, that a load rating was previously mismatched to the axle.
The resolution I would defend: scope service records to the organization that owned the vehicle at the time of service, not to the vehicle in perpetuity. Then add a narrow, explicitly-modelled safety projection — vehicle-level facts, no customer identity, no pricing, no notes — that follows the vehicle across ownership. Two different objects with two different rules, because they genuinely are two different things.
ALTER TABLE service_records
ADD COLUMN owner_org_id uuid REFERENCES organizations (org_id);
-- Backfill each row from whoever operated the vehicle on its service date,
-- then close the door:
ALTER TABLE service_records
ALTER COLUMN owner_org_id SET NOT NULL;
-- Written once, at record creation, from the operator as of that day.
-- Never rewritten when a vehicle changes hands. The record remembers
-- who the work was actually performed for.
That one column, written once and frozen, resolves an entire family of questions that would otherwise be argued case by case forever. It is the single highest-leverage line in this whole design.
Delegation Without a Permanent Hole
Delegation is the requirement that makes people give up and hand out an admin login, so it is worth modelling properly.
Three shapes show up constantly in a business with commercial accounts. A manager going on leave wants a dispatcher to approve work for two weeks. A retail customer wants their spouse to authorize a repair while they are away. An owner-operator wants their bookkeeper to pull invoices during tax season and only then.
All three are the same primitive: a subject with a grant creates a narrower, time-bounded grant for another subject. Four rules make it safe.
Delegated authority cannot exceed its source. A driver cannot delegate organization-wide visibility, because a driver does not have it. Enforce this at write time by intersecting the requested scope with the delegator's live scope and rejecting anything outside it. Enforcing it only at read time means the invalid row exists in your table, and rows in tables get trusted eventually.
A delegated grant dies when its parent dies. If the manager's own access is revoked, every grant they issued goes with it. That needs an explicit parent pointer and a cascade you actually run:
-- Delegation edges: who issued this, and how far a chain may run.
ALTER TABLE access_grants
ADD COLUMN parent_grant_id uuid REFERENCES access_grants (grant_id),
ADD COLUMN delegation_depth smallint NOT NULL DEFAULT 0
CHECK (delegation_depth BETWEEN 0 AND 2);
Bounding the depth is not paranoia. Without a cap, a delegation chain becomes a mechanism for laundering access away from anyone's understanding of who granted what, and traversal cost stops being predictable.
Delegation is bounded by default, not by discipline. The interface that creates one should require an end date, with a short default — a fortnight, say, illustratively — rather than offering "no expiry" as the path of least resistance. Defaults are policy. Whatever the form pre-fills is what will exist in your table a year from now.
Every delegation is visible to the person whose access was extended. The manager should be able to see, without asking anyone, that a dispatcher currently holds two weeks of view access to eleven vehicles. Visibility is what makes stale delegations get cleaned up, because the only person motivated to clean them up is the one accountable for them.
The awkward edge is work in flight. A dispatcher approves a tread replacement on Thursday; their delegation expires Friday. On Monday, someone asks who authorized that work. If the answer requires a live permission check, the dispatcher no longer has it and the record appears unauthorized. So separate the two concerns cleanly: the audit record captures who decided and under which grant at the moment of the decision, permanently, as historical fact. Present-tense access answers a different question and should never be used to reconstruct the past.
Log the Denials, They Are the Interesting Half
An authorization decision that leaves no trace cannot be investigated, and investigation is the entire reason this machinery exists.
Log every decision — allow and deny — with enough structure to answer questions you have not thought of yet:
{
"ts": "2026-08-21T14:07:33.418Z",
"request_id": "01J9F2Q7X3K8M1",
"subject_id": "6d1a...",
"subject_kind": "customer",
"action": "view_pii",
"object_type": "service_record",
"object_id": "9c04...",
"object_org_id": "b71e...",
"decision": "deny",
"reason": "relation_lacks_verb",
"matched_grant_id": "3f88...",
"policy_version": "authz-2026.08.3",
"enforcement_point": "ServiceRecordRepo.find",
"latency_us": 412
}
Allows are the boring majority and you keep them for completeness. Denials are the signal, and I would argue they are underused as an operational instrument.
A single denial is noise — a stale tab, a bookmarked link, someone poking at a URL. A pattern of denials is information, and the patterns say different things. One subject producing many denials across many object identifiers in a short window is enumeration, and it does not matter whether the person is malicious or wrote a script that ignores errors; you want to know. Denials clustered on one endpoint from many subjects usually means the product is wrong rather than the users are — you built something people are supposed to be able to do and forgot to grant it. Denials that suddenly stop on a route are the alarming one: either usage stopped, or somebody widened a policy and nobody noticed.
Two operational notes that are easy to get wrong. Do not log the object's contents in the deny record — logging what somebody was not allowed to see, in a log that is usually more widely readable than the database, is a genuinely common own-goal. And stamp policy_version on every line, because the first question during any investigation is "which rules were in force," and reconstructing that from deploy timestamps is miserable.
Retention deserves a real decision rather than a default. Authorization logs are useful at a horizon of months, not days, because privacy questions surface late. They also contain a map of who is related to whom, which makes them sensitive in their own right. Separate stream, tighter access, defined expiry.
Caching Decisions Without Outliving a Revocation
Grants change rarely and get read on every request, which makes caching look obvious. It is — as long as you keep the revocation problem in view, because that is where caching turns into an incident.
Consider what a five-minute cache means the day it matters. Someone leaves the business under bad terms. Access is revoked at 09:00. Until 09:05, their session sails through every check, because the cached answer is still yes. Five minutes is a long time when someone is exporting customer data. "Eventually consistent authorization" is a phrase that should make you uncomfortable in exactly this scenario.
TTL alone is the wrong instrument, because it forces a trade between staleness and load and gives you no way to be decisive when you need to be. The better mechanism is a version counter per subject, folded into the cache key:
ALTER TABLE subjects
ADD COLUMN authz_epoch integer NOT NULL DEFAULT 1;
Bump authz_epoch in the same transaction as any grant insert, revoke, expiry, role change, or disable. Build keys as authz:{subject_id}:{epoch}:{action}:{object_id}. Now revocation does not invalidate anything — it makes every prior key unreachable, atomically, because the epoch that produced them is gone. Old entries age out on their own TTL as garbage nobody will ever request again.
The remaining hazard is the epoch lookup itself. If you fetch the epoch from the database on every request you have not saved much, and if you cache the epoch you have reintroduced the same staleness one level up. Reasonable resolutions, roughly in order of cost: carry the epoch inside a short-lived session token so it is presented rather than fetched, and re-mint on refresh; keep the epoch in a fast shared store and accept that it is the one read you always do; or accept a very short epoch TTL — seconds, not minutes — as a deliberate, documented, bounded exposure.
Two rules I would hold to regardless of which you pick. Never cache a deny for long, because a deny that outlives the grant that fixes it means a customer whose newly-granted access does not work and a support conversation that ends in "try again later," which is corrosive. And never cache across a subject boundary — a key that omits the subject identifier is a serving-one-customer-another-customer's-data bug with a stack trace nobody will believe.
The Matrix Is the Test Suite
Authorization testing fails in a specific way: teams test that permitted things work. That is half a suite. The half that matters asserts that forbidden things fail, and it is the half that gets skipped because nothing is visibly broken when it is missing.
The structure that works is a table over subject × object × action, written as data:
CASES = [
# (subject, object, action, expect)
("tech_dana", "rec_retail_A", "view", "allow"),
("tech_dana", "rec_retail_A", "view_financials", "deny"),
("counter_sam", "rec_retail_A", "view_pii", "allow"),
("owner_kim", "rec_fleet_acme_07", "void", "allow"),
("retail_alex", "rec_retail_A", "view", "allow"),
("retail_alex", "rec_retail_B", "view", "deny"),
("retail_alex", "rec_fleet_acme_07", "view", "deny"),
("mgr_acme", "rec_fleet_acme_07", "view_pii", "allow"),
("mgr_acme", "rec_fleet_globex", "view", "deny"),
("mgr_acme_expired", "rec_fleet_acme_07", "view", "deny"),
("driver_van07", "rec_fleet_acme_07", "view", "allow"),
("driver_van07", "rec_fleet_acme_03", "view", "deny"),
("driver_van07", "rec_van07_pre_hire","view", "deny"),
("books_acme", "rec_fleet_acme_07", "view_financials", "allow"),
("books_acme", "rec_fleet_acme_07", "view_pii", "deny"),
("dispatch_deleg", "rec_fleet_acme_07", "view", "allow"),
("dispatch_expired", "rec_fleet_acme_07", "view", "deny"),
]
@pytest.mark.parametrize("subject,obj,action,expect", CASES)
def test_matrix(fixtures, subject, obj, action, expect):
d = decide(fixtures.subject(subject), action,
fixtures.record(obj), fixtures.grants(subject))
assert ("allow" if d.allowed else "deny") == expect, d.reason
Seventeen rows, and note how many are denials. That ratio is the point. A matrix that is mostly allows is testing the happy path with extra ceremony.
Three properties make this suite worth maintaining. It is data, so adding a subject type means adding rows rather than writing a new test function — cheap enough that people actually do it. Failures name the reason code, so a red test tells you which rule fired instead of just that something is wrong. And the fixtures deliberately include awkward states: an expired manager, a driver looking at records predating their assignment, a delegation past its end. Those three lines cover the bugs that reach production, because they are the states nobody creates by hand while testing.
Then add the properties that a table cannot express. No view_pii result ever contains a phone number, asserted over generated records rather than one example. Every list endpoint queried as a single-vehicle driver returns only that vehicle's rows, asserted over a randomly seeded database. Any two subjects with disjoint grants have disjoint result sets. Property tests find the case your imagination did not.
A Route That Nobody Authorized Should Fail Loudly
Here is the test I would fight to keep, because it defends against the failure mode that no amount of care prevents: someone adds an endpoint and forgets.
Enumerate the routes. For each, require an explicit authorization declaration. Fail the build when one is missing.
def test_every_route_declares_authorization(app):
undeclared = [
r.name for r in app.routes
if getattr(r.endpoint, "__authz__", None) is None
]
assert not undeclared, (
"Routes with no authorization declaration: " + ", ".join(undeclared)
)
The declaration is a decorator that records intent, including the deliberate choice to be open:
@authz(action="view", object_loader=load_service_record)
def get_service_record(record_id): ...
@authz(public=True, justification="marketing page, no data access")
def get_service_areas(): ...
The value is not in the decorator. It is that "I did not think about authorization" and "I decided this is public" become distinguishable states, and only one of them lets the build go green. A developer adding a route hits a failing test within a minute of writing it, while the design is still in their head — which is roughly a thousand times cheaper than discovering the gap in a privacy review, and infinitely cheaper than discovering it from a customer.
Extend the same idea to the data layer: a test that reflects over every table containing customer-identifiable columns and asserts each is either registered as scoped or explicitly listed as public reference data. New table, failing build, five-second decision.
Getting Here From a role Column Without a Rewrite
Nobody starts with the design above. Every real system starts with users.role and a handful of conditionals, and the reason teams stay there is that the migration looks like a rewrite. It does not have to be. Six steps, each shippable on its own, each leaving the system working.
Step one: centralize without changing behaviour. Write decide() so that it reproduces today's rules exactly, bugs included. Replace the scattered conditionals with invocations of it. Ship. Nothing changes for users; everything changes for you, because there is now one file to read when someone asks who can see what. Resist improving the rules during this step — a refactor that also changes behaviour is two changes wearing one commit message, and when something breaks you will not know which half did it.
Step two: build the graph alongside the column. Create subjects, organizations, vehicles, access_grants. Backfill from what exists: every customer becomes an organization of one with an owner grant on their own vehicles; every commercial account becomes an organization; every current staff member gets a role. Write the backfill as an idempotent, re-runnable script, because you will run it more than once.
Step three: shadow-evaluate. Compute both answers on every request — old logic decides, new logic runs beside it — and log disagreements without acting on them. This is the step people skip and the step that makes the rest safe. Let it run through a full changeover peak, where volume and staffing are both abnormal, and read the disagreement log. Every mismatch is either a bug in the new model or an undocumented rule in the old one, and you need to know which before anything switches.
Step four: flip reads, keep the column. When disagreements are zero or explained, make decide() authoritative. Leave users.role in place, still populated, and keep the old path behind a flag you can throw in ten seconds. A rollback plan you have not exercised is a wish; test it deliberately during a quiet week rather than discovering it during a busy one.
Step five: introduce relations the old model could not express. Only now do fleet managers, drivers, delegated dispatchers, and bookkeepers arrive. This is the payoff step, and putting it fifth rather than first is the whole discipline — the new capability lands on a substrate you have already proven equivalent, so any bug is unambiguously in the new capability.
Step six: add depth. Turn on row-level security underneath the repository predicate. Add the route-declaration test. Add the field-shaping function. Each is independently valuable and none of them is a prerequisite for the others, which means they can be scheduled around actual work — the mobile van integration, the self-serve reservation flow, whatever is actually paying that quarter.
The property that makes this sequence work is that no step requires the next one. Stop after two and you have a centralized ruleset, which is already better than what most systems have. Stop after four and you have a relationship model with a proven equivalence to the old behaviour. Nothing is stranded.
When All of This Is Too Much Machinery
I have spent several thousand words arguing for a design, so let me argue against it.
A single-location business with three staff and no external readers does not need any of this. Everyone who touches the system is an employee, the system is not exposed to customers, and the actual access control is that all three people work in the same building and know each other. users.role with three values and a handful of conditionals is not technical debt in that context — it is a correctly sized solution, and replacing it with a policy engine would be a self-inflicted wound. You would spend a month building infrastructure to manage relationships that do not exist.
Do not build a policy service when a WHERE clause is the answer. The single most valuable idea in this article is the scoped repository predicate, and it is perhaps forty lines. Everything else — the delegation graph, the epoch counters, the obligations, the shaping layer — is machinery that earns its keep only in proportion to how many distinct external readers you actually have. Two kinds of reader do not need a graph.
Do not adopt Zanzibar-style infrastructure for a few thousand relationships. Those systems solve a real problem: authorization data too large and too hot to sit beside your business data, requiring its own consistency model. If your grants table fits comfortably in memory and joins cheaply against the rows it governs, a separate service buys you a network hop, a consistency question, a new failure domain, and an on-call rotation, in exchange for elegance you can get from an index.
Do not build a policy DSL for eleven rules. The expressiveness is seductive and the cost is that your team now maintains a language, its evaluator, its tooling, and the tribal knowledge of its evaluation order. A Python function that a new hire can read in five minutes is worth more than a policy language nobody is fluent in.
Here is the trigger I would actually use. Build the relationship model when a person who is not your employee needs to read a subset of rows that is defined by something other than "rows they created." That is the moment RBAC stops being adequate. Before it, you are anticipating. After it, you are behind.
For a business that serves both walk-in retail work and multi-vehicle commercial accounts, that moment arrives the first time a fleet manager asks for a login. Not before.
The Part I Would Not Compromise On
If a constrained version of this had to ship — one afternoon, no new infrastructure, no migration window — I know which three pieces I would keep.
Every read of customer data goes through a predicate that cannot be omitted. Every route declares its authorization or fails the build. Every denial is logged with a reason code and a policy version.
That is not a complete authorization system. It has no delegation, no field shaping, no caching story, no graph. But it means access is enforced in one place, gaps are visible before they ship, and when a question arrives about who saw what, there is an answer rather than a reconstruction. Systems get away with a thin ruleset for years. They rarely recover from having no chokepoint and no record — by the time you need those, the data has already moved.
The rest of it — the balance and rotation history a driver can see, the invoice a bookkeeper can total but not de-anonymize, the dispatcher whose two weeks quietly ran out on schedule — those are refinements on a foundation. Get the foundation wrong and the refinements are decoration on something that leaks.












