Access control as a declarative DSL, then roles and capabilities
Access rules declared on each model and compiled to both a SQL filter and a record check, and the three tiers of rights that became roles and capabilities.
Every model in the platform declares who may do what to it, as class attributes beside its
fields, and one engine turns each declaration into two things: a SQL WHERE clause that scopes
every query, and a Python check on a single record. A list and a detail page cannot disagree about
what someone may see, because both read the same rule. The engine went in ten days after the first
commit. The model of rights it checks against took until June to settle.
Why the rules live on the model#
The usual alternative is a check in each handler: the route loads what it needs and asks whether the caller may have it. That holds until the second route reading the same table forgets the check, or until a list endpoint filters in Python after loading more rows than it returns. I wanted a mistake to be hard to write rather than easy to spot, so the rules sit on the model, and the generic resource routes, the background jobs and the real-time events all pass through the same engine.
A rule names the access right it applies to, the actions it grants, optionally a condition on the row, and optionally the fields it covers:
sec_read = Rule(
role="user",
allow=READ,
when=(Self.owner_id == User.id) | (Self.is_public._is_true()),
)
Self is the row being accessed and User the caller. Comparisons are Python's own operators,
combined with & and |, and the engine compiles the tree to SQLAlchemy for a query or evaluates
it against an instance for a check. The rules a caller holds are combined with OR, and no matching
rule is a denial: false in SQL, an exception on a record. Two days after the first version I
replaced bare boolean literals in conditions with explicit methods such as _is_true(), because a
literal was ambiguous, and every condition has to mean exactly the same thing in SQL and in Python.
What a field list really restricts#
A rule can carry a field allowlist, and the first surprise was how far it reaches. The list that limits what a rule lets you update also masks what it lets you read, so one rule cannot grant a wide read and a narrow write. Staff reading a colleague's profile needed exactly that pair, so it became two rules, one to read and one to manage.
The second surprise was what masking does. A masked field is not absent from the response: the record is built without it and the field takes its schema default. For most fields that is harmless. For the archive date it meant an archived colleague read back as live, since that default is null, and for the id it would have been a freshly minted UUID that every link then pointed at. Both are in the readable set on purpose, and the id refuses to be masked at all, whatever a rule says.
From role labels to access rights#
The first version checked role labels. In March authorisation moved to explicit access rights,
atomic slugs such as the right to write groups, bundled into privileges and granted through groups
that a user holds within one organisation, so that the API, the background jobs and the screens
that assign access all answered the same question the same way. Seeding and workers run under a
system context that bypasses rules and tenancy, which is how they touch data without pretending to
be a user.
The engine also has to decide what happens when it cannot be sure. When the cache holding revoked tokens is unreachable, a token with a valid signature is still accepted so reads keep working, but the request is marked unverified and every write is refused. A revoked token cannot change anything during an outage.
An escalation guard, then inheritance#
Once organisation admins could assign groups to their own members, the obvious hole was an admin granting something they do not hold. The guard is a subset check: the rights the requested groups carry must be contained in the caller's own effective rights, and it runs before any row changes, so a refusal leaves nothing half-written. The interface only offers the groups the caller may grant, and the server computes that list, because list responses do not carry each group's rights and working them out in the browser would cost a request per group.
Privileges then learned to imply one another, so the admin privilege carries everything the member privilege does without repeating it. Resolution expands the transitive closure, so inheritance flows into the escalation guard too, and a link that would close a cycle is refused. The hard part was not the graph but the ORM underneath it. The framework named both columns of an inferred many-to-many junction after the table, which collides when a table points at itself, and the code that syncs those links wrote to the wrong columns for any junction named by hand. The fix reads the join columns SQLAlchemy actually resolved instead of trusting a naming convention.
Three tiers that looked like two#
In late June I asked whether groups added anything over privileges, since the seed mirrored them one to one and groups were named by their slugs. They do. A group is the only unit that can be assigned to a user within an organisation, and the escalation guard hangs off it, while a privilege has no organisation and no assignment path at all. Collapsing the two would have meant a new link table scoped to organisations, moving the guard, and migrating the data.
The redundancy was in the names, so the fix was in the names. Groups became roles, named as roles are ("organisation admin"), and privileges became capabilities, named as capabilities are ("organisation administration"). The seed gained a name and a description for each, and no schema changed and no slug moved, so the resolution of rights and the escalation guard were untouched. Roles later gained an implication of their own, purely declarative, so the permissions screen shows admin and member as one choice of tier rather than two toggles that could contradict each other, while the rights themselves still flow only through capabilities.