A close-up portrait of a man with a salt-and-pepper beard wearing a white collared shirt against a textured beige background.
A close-up portrait of a man with a salt-and-pepper beard wearing a white collared shirt against a textured beige background.

Four systems, one question

J
Jesse James Richard
|
Aug 25, 2026
|
11 min read
#Access Control
#Architecture
#Signature

I hire a web designer. They need to edit the website. Do they now have my customer list?

I asked myself that in August and could not answer it from the code. Working out why took a week, and the answer was worse than a bug. Four separate systems in this platform were answering the question of whether a person may do a thing, they did not agree with each other, and the one that decided was not the one I had been reading.

Nothing was exploited. The platform is in alpha with nine users, all of them people I know. These were findings rather than incidents, and I want to be clear about that before describing them, because a post like this reads as an incident report if you do not say otherwise.

The four

Asked whether this user can edit this page, the platform consulted four things.

A set of global role tables. A user held a role, the role held permissions, and those were compared against what the route declared. The grant attached to nothing. Not an organization, not a project, not an app. It could not express editor on this app but not that one, which is the entire question I was trying to answer.

A TypeScript list of permissions and role maps, maintained by hand, separately from the database. Seventy-six rows in one, forty-one entries in the other. Not a copy that had drifted. Each was authoritative for a different half of the permission space, decided by a string prefix in the middleware, which is why the question of which one is canonical had no answer.

Membership rows on organizations and projects, carrying a role per scope. This is what the request-time checks actually read.

And the console, running a third vocabulary of its own. Fifty-nine route exports spelling app access in a form that matched nothing on the server, falling through to a catch-all that let organization owners and admins past regardless. The strings were decorative. Real access was whether a person was an org owner or admin, and everything else was refused.

Four systems, and a person could hold one role in the first, be absent from the second, hold a different role again in the third, and be treated as something else entirely by the fourth.

What was in the vocabulary

Reading the seventy-six database rows was the first useful hour.

A permission existed for reading and writing departments. There is no departments table and no departments feature. Department is a column on the contacts table. So the vocabulary contained permissions minted for a single field, at a granularity nothing else in the system used, and no route had ever declared them.

Colon and underscore spellings of the same permission coexisted, so both forms sat in the table and only one could ever match a route.

And viewer, the role auto-assigned to every new user on signup, was granted a permission to list users. Every account that ever signed up could enumerate everyone on the platform.

None of these were reachable in a way that mattered on a nine-user alpha. All of them were the same shape: a table that had grown by accretion for a year with nothing checking it against the routes, because no mechanism to check it had ever been built. The scripts meant to keep the two in step were manual, called by no workflow, and last run in May.

The rename, and why it had to come first

The permissions were named for their scope. A permission said which tier it applied to, and the membership row said it too, so the scope was stated twice and the two could disagree.

Google's IAM says it once. A permission names a service, a resource and a verb, and which project it applies to is decided by where the role is bound. The permission itself never mentions the project.

I moved the whole vocabulary to that form. One hundred and seventy-nine permissions across seventeen services, derived from the route tree rather than authored, because in a file-based routing scheme the file path is the route path and the list falls out. Reads split into get and list, which is what makes it a hundred and seventy-nine rather than about ninety, and which is what lets a detail page and an index be gated differently.

It had to happen before anything else. Auditing three hundred and forty-nine route declarations against a vocabulary I intended to rename means auditing twice.

Three things changed behaviour deliberately in that commit. A permission that named a role, and therefore had to be granted to itself, retired and became a role. The global roles were emptied, which is what closed the signup-grants-user-listing hole. And the two verbs that let one person grant another access to an app were given to project owners and admins, because they had been declared by five routes and granted by no role at all, which is why zero app bindings existed anywhere in the system.

The flip

Then the change the whole exercise existed to make.

Bindings in this model are additive. A binding cannot subtract. So there is no way to add someone to the website app and thereby remove the CRM access their project role already gave them. The only way to withhold something is to never grant it.

Which means the fix is not a clever binding. It is a removal. Project roles stop carrying app content entirely.

Before the flip
After

Project owner

150 permissions

17

Project admin

149

16

Project editor

117

10

Project viewer

66

7

One hundred and thirty-five permissions came out of every project role. App access now comes only from a binding on the app itself. A web designer becomes a project viewer plus an editor on the website app, and the CRM is not in any binding they hold. There is no path from their grants to a single email permission.

Twenty-four bindings were backfilled onto production before the code shipped, mapping each project role to the same role on every app in that project. Nobody's access changed on the day the migration landed, because nothing read the table yet, and the rows were waiting when the code arrived.

The deadlock

The rule as written was that project roles grant zero app permissions. Taken literally that includes the verb that lets you grant someone an app role.

Follow it through. That verb is app-scoped, so it resolves only through an app binding. The table is empty. The only route that writes to it requires exactly that verb. Nobody could ever create the first binding, and the system would be permanently unusable.

The sharpened rule is what shipped. Project roles grant zero app content, and the delegation verbs stay on owner and admin. Administration inherits down, content does not. A project admin cannot edit the website and can grant someone the ability to, which is also what makes a bad binding recoverable.

What the flip broke, and I did not notice for four days

An app binding became the only route to any of a hundred and thirty-five permissions. Exactly one code path in the platform created one, which was accepting an invitation.

So: create a project. Create an app in it. Nobody can open that app, including you, the person who just made it.

It was recoverable, because a project owner holds the delegation verb and can bind themselves. But the default state of a newly created app was unreachable, and that is the first thing a real user hits.

This is the honest shape of the mistake. The flip changed what a project binding means without changing what happens when a project membership or an app is created. Google does not have this problem because a project-level binding grants everything beneath it, which is precisely the property the flip deliberately gave up. Giving it up was right. Not replacing the convenience it provided was the miss.

The fix cascades bindings at both moments a person and an app first coexist, which are someone joining a project that already has apps, and an app being created in a project that already has people. It went into the service rather than the call sites, because the rule had previously lived at one of three join paths, and a rule enforced at one of three places is not a rule.

It has a cost I want stated rather than buried. It makes the default convenient rather than restrictive. A project member now receives bindings on everything automatically, so website but not CRM is reached by removing a binding rather than by never granting one. The mechanism survives, because the rows are explicit and individually removable, but scoping a contractor now depends on someone remembering to prune.

Worse, and this is the part that took a second reader to see, a prune does not survive. There is no withheld state. An absent binding means both not granted yet and deliberately removed, and the cascade fills absent rows. Remove a contractor from the CRM, then create any new app, and they are bound to it again by an action that has nothing to do with them.

That is now asserted as a passing test named for the limitation. Encoding it as green is deliberate. The day someone adds a way to express withheld, that test fails, and it should.

What the screen found that the code review did not

Every substantive defect in this arc was found by looking at something, not by reading code. Code review found the mechanical bugs. The screen found the wrong ideas.

The member permissions page is one screen. Organization role at the top, each project beneath it, each project's apps under that. Drawing it honestly is a test of the model, because a fact that cannot be stated on one screen is usually a fact the model has wrong.

It found three things immediately. A project row with no explicit binding rendered as no access, which was false for anyone who inherits, and the same screen said so in a banner two inches above. Every option in every dropdown resolved to the same effective role for an organization owner, so the controls were decorative, and my response had been to add captions explaining why. And an option below the inherited level looked like it did nothing and did not: it is a durable grant, masked, which becomes that person's access the day their organization role is dropped.

Three attempts at that page went in the bin. The first put an access tab inside all eight apps. The second put the whole organization tree in a dropdown. The third had the right structure and filled it with the model's own vocabulary, including a control labelled no explicit binding, which is the absence of a database row said out loud.

The answer, once it was said plainly, was two lines per row. The role, and where it came from.

The member permissions page, showing an organization role above a project and its eight apps, each app with its own role selector.
The finished page. The organization role, then the project, then every app in it with a role of its own.

No captions, no banner, no vocabulary to learn. Everything I had added to make the model legible came back out, and the change was net negative code.

The lesson is worth more than the page. A permissions screen is not a rendering of the permission model. The model has bindings and inheritance and precedence and storage states. The person has a question, which is what can they do here and why. Show the answer and the source.

And the part that stings. I never opened the page. Three attempts of reasoning about strings for a screen I had not looked at, with a screenshot pipeline sitting in the repository the entire time.

What this bought

A hundred and thirty-five of the hundred and seventy-nine permissions are app-scoped, and only an app binding reaches them. That ratio is the answer to the question that started it. Hire a web designer, bind them as editor on the website app, and the mail app is not merely discouraged. There is no path from their bindings to a single email permission.

That is one system where there were four, one place a role is defined, and one resolution path from a request to a decision.

It is also only half the problem. Everything above answers whether you may edit pages. None of it answers whether you may edit page 47, and it turned out nothing in the platform did.

That is part two.

What one person actually built

A normal company assembles a team and thirty SaaS subscriptions to run a platform like this. This one is one person, one integrated system, ...

Aug 3, 2026
Previous

Building something like this

I'm Jesse. I build platforms end to end, and I'm open to work. If this is the kind of engineering you need, get in touch.

Contact Jesse
Home
About
Contact
Sitemap
Privacy Policy
Terms of Service
Cookie Policy
Four systems, one question | Jesse James Richard