25 September 2026 · 6 min read

Capabilities in code, roles in rows

Permissions change with the code, so they live in code; roles change with a customer's org chart, so they live in rows. The deploy line is the test for which side each belongs on.

Teams arrive at a CRM migration with their roles already named. A company leaving Salesforce has profiles called things like Regional Sales Lead and Support Tier 2; a company leaving HubSpot has its own; and each expects to keep them, because the names are how the company describes itself. A permission system built as a single enum of roles cannot accept them without a code change and a deploy, and a permission system built as tables all the way down accepts them and then loses track of what any permission actually does. The way out is to notice that the two halves of the problem change for different reasons, and to put each half where its kind of change is cheap.

Capabilities scale with the code

A capability is a thing the software can do: view a deal, edit a pipeline stage, export contacts, connect a mailbox. New capabilities appear when engineers ship features, and only then. The clearest public record of that relationship is AWS's own permission list, which is the set of IAM actions the platform recognises, one per API operation, published in its service authorization reference. The iam-dataset project has tracked that list daily since 2020, and the count is a nearly straight line: about 7,500 actions in June 2020, 9,259 at the start of 2021, 16,214 at the start of 2024, and 21,916 in mid-September 2026, which is roughly two thousand new permissions a year, every year, added by nobody but the engineers who shipped the APIs behind them.

Number of AWS IAM actions, June 2020 to September 2026 A single rising line: 7,505 actions in June 2020; 9,259 in January 2021; 11,834 in January 2022; 13,699 in January 2023; 16,214 in January 2024; 18,199 in January 2025; 20,270 in January 2026; 21,916 in September 2026. The growth is close to linear at about two thousand actions a year. Permissions grow with the code that needs them AWS IAM actions recognised by the platform, count at the start of each year 25k 20k 15k 10k 5k 0 2021 2022 2023 2024 2025 2026 7,505 21,916 About two thousand new actions a year, each one shipped with the API behind it.
Source: computed by the author from the historic counts file in the iam-dataset repository, which scrapes the AWS service authorization reference daily; values at the first recorded date of each year.

The chart is the argument in one line. A capability is a property of the code, it changes when the code changes, and so it belongs in the code: a constant with a name, referenced at every point where it is enforced, so that searching the codebase for the name finds every place the permission matters. A capability stored only as a string in a table has no such property. Nobody can grep for it, nothing checks that the string in the row matches the string in the handler, and a typo in either is a permission that silently never applies.

Roles scale with the customer

A role is a bundle of capabilities with a name a customer chose. It changes when the customer's organisation changes: a new team is formed, a manager wants their deputies to approve discounts, a regional structure is introduced. None of those events involve the software changing, so none of them should require a deploy, and a role that lives in an enum makes every one of them a ticket to engineering. The migration case makes this vivid. A team importing from another CRM brings dozens of role names, and the import either creates rows or it waits for a release.

The test that decides where a thing lives is a single question with two parts: who changes it, and does a deploy happen when they do? I call the boundary the deploy line.

The deploy line as a two-by-two A grid. Horizontal axis: who changes it, engineers on the left and the customer's admin on the right. Vertical axis: whether a deploy happens, yes at the top and no at the bottom. Top left: capabilities, code constants, changed by engineers with a deploy. Bottom right: roles and memberships, rows changed by the customer's admin with no deploy. Top right is marked as the failure where a customer's change needs a release. Bottom left holds default role templates, seeded by code into rows on boot. Capabilities constants in code, above the line The failure a customer's change waits for a release Default role templates code seeds rows on boot Roles and memberships rows, scoped to the tenant Who changes it: engineers, or the customer's admin Does a deploy happen: yes above, no below
Illustrative: the deploy line drawn as a grid; the dot marks the cell a migrated team's roles land in.

Above the line, capabilities: engineers change them and a deploy happens, which is right, because a new capability only exists once the code that enforces it is running. Below the line, roles and memberships: the customer's admin changes them, no deploy happens, and the pass condition for the whole design is that an admin can create a role, name it, tick its capabilities and assign it, all before lunch and without anyone at the vendor knowing. The top-right cell is the failure, a customer change that needs a release, and it is where an enum of roles puts every customer. The bottom-left cell is the one people forget: the default roles a new tenant starts with, Admin and Member and Viewer, are defined by engineers but are still rows, seeded by code when the tenant is created so that the customer can rename or edit them afterwards.

The schema

The shape that follows has four tables and one list that is not a table.

The schema: capabilities in code, three tenant-scoped tables in rows On the left, a box labelled capabilities, a list of constants in code such as deals.view, deals.edit, contacts.export, mailbox.connect, synced into a small reference table on boot. On the right, three tables: roles with tenant id and name; role capabilities linking a role to a capability; memberships linking a user to a tenant and a role. Arrows show the foreign keys and the boot-time sync. One list in code, three tables in the tenant capabilities (code) deals.view, deals.edit contacts.export mailbox.connect ... synced to a table on boot roles id, tenant_id, name role_capabilities role_id, capability memberships user, tenant, role capability references the synced list, so a typo cannot be saved tables on the right carry a tenant id; the list on the left is shared by all tenants
Illustrative: the tables as I build them; names are examples, the shape is the point.

The capabilities are a list of constants in the codebase, named in a dotted style that reads as the thing being permitted, and every handler that enforces one refers to the constant, never to a string. On boot the application syncs that list into a small reference table, which exists for one reason: so that the role_capabilities table can carry a foreign key to it and the database will refuse to store a capability that the code does not know. A capability removed from the code fails the sync loudly if any role still references it, which is the migration warning you want. Roles carry a tenant id and a customer-chosen name. Role capabilities link a role to the capabilities it grants. Memberships link a user to a tenant and a role, which is where a person's access actually comes from, and the check at request time is one join: does the caller's role in this tenant include this capability.

Where the check runs

Keeping capabilities in code pays off at the point of enforcement. Every handler declares the capability it needs through one function, the only function in the codebase that is allowed to answer "may this caller do this", and the declaration is the constant, not a string. In a typed language the constants are an object whose keys the compiler knows, so a misspelt capability is a build error rather than a permission that quietly never applies. The check itself is the join described above, resolved once per request and cached for its duration, so that a screen that touches ten capabilities pays for one lookup.

Two tests keep the design honest. The first walks every registered route and asserts that each one declares a capability or is explicitly marked public, which catches the endpoint someone added in a hurry with no check at all. The second runs the boot-time sync against a copy of production's role_capabilities table and fails if any row references a capability the code no longer defines, which turns the removal of a feature into a visible migration step rather than a silent hole. Neither test knows anything about any customer's roles, which is the point: the rows can be anything, and the code is still checkable.

The two failures, side by side

The all-in-code version, an enum of roles with permissions attached in a switch statement, fails the deploy line on every customer change, and its symptom is a backlog of tickets titled "add a role for". The all-in-tables version, with permissions as strings and roles as rows, passes the deploy line and fails the code: the strings drift from the handlers, nobody can find every place a permission is enforced, and the permissions table accumulates entries that no code checks any more. The split takes the half of each that is right. Code holds the list of what can be done, and the codebase is the single source of truth for it. Rows hold who can do it, per tenant, and the customer is the single source of truth for that.

The pass condition, again, is the one an imported team tests on day one: the admin who arrived with fourteen role names from the old system creates fourteen rows, maps each to the capabilities that match what the old profiles allowed, and assigns their people, and no engineer at the vendor learns about it except from the audit log. The permission list they mapped onto is the one in the code, which grew last week when a feature shipped and will grow next week when another does, which is exactly the rate at which permissions should change, and exactly the rate at which roles should not.

RBACPostgreSQLMulti-tenant SaaS
All writing

Written by Mohd Shayan

Get new posts by email

Occasional essays on engineering, AI, and building for the people technology leaves behind.

One email per new post. Unsubscribe any time.

Subscribe with RSS