Rebuilding RBAC on GCP notation
I hire a web designer. They need to edit the website. Do they now have my customer list?
I could not answer that from the code. Working out why took a week. Four separate systems 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 systems
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.
Dead permissions in the database
Reading the seventy-six database rows was the first useful hour.
departments:read and departments:write both existed. There is no departments table and no departments feature. department is a column on the contacts table. So the vocabulary held 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. project:apps:read and project_apps:read both sat in the table, and routes only ever declared the colon form, so the other could never match anything.
And viewer, the role auto-assigned to every new user on signup, was granted users:read. 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.
Renaming to service.resource.verb
The permissions were named for their scope. project:files:write says which tier it applies to, and the membership row says 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. storage.objects.get never mentions a project. The binding does.
I moved the whole vocabulary to that form:
Before and after
org:settings:write -> resourcemanager.organizations.updateorg:projects:create -> resourcemanager.projects.createorg:billing:read -> billing.accounts.getproject:files:write -> files.objects.create + files.objects.updateproject:apps:read -> apps.instances.listapp:members:read -> apps.instances.getIamPolicyapp:members:write -> apps.instances.setIamPolicyplatform:users:delete -> admin.users.deleteOne 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. platform:admin retired. It was a permission that named a role, so it had to be granted to itself; the middleware now asks whether the user is a platform admin directly, and the twenty-six routes that declared it now declare the admin.* permission they actually guard. The global roles were emptied, which closed a hole where the viewer role auto-assigned at signup carried users:read. And apps.instances.getIamPolicy and apps.instances.setIamPolicy were granted to project owners and admins, because five routes declared them and no role held them, which is why zero app bindings existed anywhere in the system.
Removing app permissions from project roles
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.
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 setIamPolicy deadlock
The rule as written was that project roles grant zero app permissions. Taken literally that includes apps.instances.setIamPolicy, the permission that lets one person grant another a role on an app.
Follow it through. That permission is app-scoped, so it resolves only through a row in app_members. The table is empty. The only route that writes to it requires exactly that permission. 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 two IAM 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.
New apps were unreachable
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.
The member permissions page
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.
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.
Result
A hundred and thirty-five of the hundred and seventy-nine permissions are app-scoped, and only a row in app_members 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 they hold website.pages.update and forty-four others. They hold no email.* and no crm.* at all. There is no path from their bindings to a single one.
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.
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.