Insights

Granting access should be harder than revoking it

Most permission systems make adding and removing a user mirror images of each other. They should not be. A note on asymmetry, written while adding roles to our own admin panel.

We added roles to our admin panel this week. The interesting decision was not which roles — it was making the add and the remove deliberately unequal.

The setup

Two gates stand in front of the panel:

Cloudflare Access  ──▶  decides who may reach /admin at all
admin_users table  ──▶  decides what they may do once they are there

Access is configured in Cloudflare’s dashboard and is not managed by the application. The table is ours. A request must clear both.

That produces an asymmetry that most role systems do not have, and we kept it on purpose:

  • Adding a row grants nobody anything. The person still has to pass Access, which means somebody has to add them there too. Two systems, two people’s attention, two chances to notice.
  • Suspending a row denies on that person’s very next request. One system, one action, immediate.

Revocation should only ever need one lock to turn. Granting can afford friction; revoking cannot. When you are removing somebody’s access, it is usually because something has gone wrong, and the last thing you want is a checklist.

Why symmetric systems fail

The failure is not technical, it is human, and it has a shape:

Somebody leaves. An admin opens the panel and deletes the row. The UI says “User removed.” Everyone believes the person is gone.

They are not gone, because the SSO policy still lists them, or the VPN group does, or the shared account they were added to a year ago still works. The panel told the truth about its own table and was read as telling the truth about access. That gap is where offboarding incidents live.

So the panel now says it out loud, on the page, above the table:

Adding someone here is not enough on its own — add them to the Access policy too, or they will be turned away at the door.

Suspending or removing someone here is enough on its own, and takes effect on their next request.

This is not decoration. It is the most load-bearing copy in the application.

The anti-lockout rule

Every access-control system needs an answer to “what happens when the last administrator locks themselves out?” Usually the answer involves a database console at two in the morning.

Ours: the addresses in the ADMIN_EMAILS secret are owners whether or not they have a row, and no write from the panel can suspend, demote or delete one. A suspended row for a listed owner is ignored, not obeyed.

That last part is worth stating carefully, because it looks wrong. The database says “suspended” and the resolver does not care. The reasoning is that the secret and the table are not peers: the secret exists precisely so there is something the UI cannot reach, and a row that contradicts it is not a policy, it is a mistake — possibly somebody else’s. A lie in an ACL is worse than a refusal.

The set of people who can restore access cannot be emptied from inside the UI. Changing it requires wrangler secret put, which requires Cloudflare credentials, which is a different threat model entirely.

Two smaller rules, same family

Nobody edits themselves. Not their role, not their status, owners included. This costs an owner one extra conversation and removes two things: a fat-fingered dropdown demoting the only person who could undo it, and a compromised session quietly promoting itself.

Unknown denies. A missing row, a suspended row, a role string the code does not recognise, and a database that will not answer all take the same path out. The one exception is the recovery path above, and it is one line, commented, and tested.

That last category matters more than it sounds. A row carrying role = 'superuser' was written by something other than this application, or by a version of it that no longer exists. Neither is a reason to guess.

Routes are default-deny too

The panel’s middleware maps each area to the permission it needs. Anything under /admin that is not in the map is refused to everybody, owners included.

This trips people up in review, so: the failure mode of a forgotten entry is a page nobody can open, which somebody reports within the hour. The failure mode of the alternative — allow by default, deny where listed — is a page everybody can open, which nobody reports at all.

Pick the failure that announces itself.

What we would tell a client

Three questions, in order:

  1. When you remove somebody, how many systems have to agree before they are actually gone? If the answer is more than one, does your UI say so?
  2. If your permissions table were dropped, who could still get in and fix it?
  3. What does your code do when the permission store is unreachable?

The third one is the tell. If nobody has an answer, the answer is usually “fails open”, and nobody has looked.