Object-level authorization in the OpenAPI spec
The first half of this fixed whether a person may edit pages. It did not touch whether they may edit that page, and nothing in the platform did.
That gap had been open for the life of the codebase. What kept it open is the part worth writing down, because it was not carelessness and it was not a missing check. Six hundred and eighty-six tests were passing, and they were passing because they had been pointed at a model of the rules rather than at the code that enforces them.
Nothing was exploited. Alpha, nine users, people I know. Findings rather than incidents, same as part one.
May you edit page 47
There are two authorization questions and they are not the same one asked twice.
The first is whether a caller may perform an action in a scope. May they edit pages in this project. That is role-based access control, it is declared on the route, it resolves from the URL, and after the work in part one it is correct.
The second is whether a caller may touch a specific record. May they edit page 47. The permission layer cannot answer it, because it reads the URL and never sees the body of the request. So every identifier arriving in a request body was used on trust.
When the second is missing, the failure has a name. A request names an object directly, nobody checks the object belongs to the caller, and the caller reaches another tenant's row. The control that prevents it is called object-level authorization, and there is no shorter name for it that stays accurate.
The question
May you edit pages here
May you edit page 47
Read from
The URL
An id in the request body
Where it lived
Declared on the route
Hand-written in the handler
Test coverage
Generated from the declaration
None
Thirty-two hand-written checks
There were about thirty-two of these checks in the codebase, written by hand, one per route, scattered across four per-application files. Each one was correct. I had written most of them, and I had improved them, moving the scope predicate into the query so that an unscoped call would not compile.
Thirty-two correct checks and no guarantee at all, because the thirty-third route has nobody. Five of eight routes in one group had the check. Three did not, and those three were findings.
This is the distinction the whole fix turns on, and it is simpler than it sounds. Opt-in means you have to do something to get the protection. Opt-out means you have to do something to lose it.
Today, write a route that takes an identifier in its body and add no check. It compiles. The tests pass. It ships. You got no protection because nobody added any, and nothing told you. The developer who forgets is the same person either way. What differs is what the system does when they do.
My own error is worth naming rather than glossing. I improved the implementation of every check while leaving the decision to write one with whoever writes the route. That is precisely the pattern I believed I was removing.
The model checking the model
None of this was caught by the test suite, and the reason is the most useful thing in this post.
The generated authorization suite is 1,277 assertions. Each one asks whether a role maps to a permission, by calling a function that reads a table defined at the top of the same generated file. It never issues a request. It never boots the application. It never reaches the middleware that actually decides anything.
So it cannot observe that a route enforces something its declaration does not mention, or that a declaration of no permissions reads as permit rather than deny, or that one authentication path returns before the check runs at all. It is the model checking the model, and it passes.
The comment block in that generated file states, as fact, a claim about how a platform administrator resolves permissions. The running middleware does the opposite. The file has been asserting the wrong behaviour, confidently, in 1,277 places, for as long as it has existed.
There is no screenshot of that suite failing anywhere in this post, and I went looking for one. It cannot fail. It is generated from the same rules it asserts, so when the rules moved the assertions moved with them and stayed green. The pipeline picture below is the closest thing to evidence there is, and what it shows is the suite passing while the platform underneath it did not work.
No fixture ever created an app binding
There is a second suite that does boot the application and issue real requests. It has the opposite problem.
After the change in part one, an application-level binding is the only route to a hundred and thirty-five of the platform's permissions. No fixture anywhere created one. The table appears zero times in the test setup.
So every one of those permissions was covered in exactly one direction. A project role is refused, asserted across dozens of endpoints and four roles. Correct, and it proves the removal worked. What was never asserted is that the right binding is allowed.
Follow that through. If the resolver that reads application bindings had been broken to return false unconditionally, every test would still have passed. A hundred and thirty-five of a hundred and seventy-nine permissions could have granted nothing at all, and the pipeline would have been green, because the only thing asserted about them was that the wrong binding is refused.
A suite that only tests denial is satisfied by denying everything, and denial is the direction an authorization bug fails in.
The fix was in the generator rather than in eighty-eight files. A binding fixture, application roles added to the case matrix, and expected access computed from the role bundle for that application type rather than the project one. Two hundred and forty binding rows now exist where there were none, and seventy-seven cases assert that a strong binding on a different application is still refused, which is the claim the whole separation rests on.
The forcing guard was frozen green
Then a third thing, which is the one that still bothers me.
I had written a guard that reads the running application's own interface description, finds every identifier a route accepts, and fails if any of them is undeclared. It ran in the pipeline and it was green.
It was green because the test cache had frozen it. The cache keys each test on a hash of the bundled test file. This guard loads the whole application through a dynamic import the bundler does not follow, so the application, its routes and every declaration in them are invisible to that key. The key changes only when the guard's own text changes.
One green result was recorded, and replayed on every run afterwards. Run fresh with the cache cleared, the guard fails with a hundred and eighty undeclared identifiers. The core of the platform was zero of a hundred and twenty-eight files declared, and had been the whole time.
The lesson is not about one cache. Every suite that boots the whole application had the same property, so a change to a route, a declaration or a permission could not invalidate the thing that tests it. The suites most worth running were the ones least able to notice they should.
I edited the test that caught the regression
One more, and it is mine.
While making inheritance work correctly between organization and project roles, a test went red. It asserted that a person with an explicit project binding resolves to that binding rather than to the one inherited from their organization role.
I edited it. The name still said the explicit binding is used. I added a comment saying there was no organization membership at all. And I changed the assertion to expect the inherited role to win. Three statements, two of them describing behaviour the third contradicts.
Everything downstream then agreed, because the generated cases are calibrated against the same rule I had just moved. A regression had a test, the test failed, and the test was changed to accept it.
What I take from it is a check that is cheap to run. After editing a test, read its name, its comment and its assertion, and ask whether all three say the same thing. Mine did not, and the moment to notice is when the name stops matching what is asserted.
A second reader found the half I was missing. That check verifies whether an edit was honest. It cannot verify whether the new behaviour is correct, and those come apart exactly where a denial turns into an allow, because that is where something previously hidden becomes visible. When a test flips from refused to permitted, the separate question is whether the newly visible permission belongs to that role at all. Three of the four that flipped for me turned out not to be changes. They were grants the role already held, on paths the old resolver had never reached.
Declaring object scope on the route
The answer is the same shape as the answer in part one. Stop writing the rule in code and start declaring it, beside the permission it completes.
Every route already declares which permission it requires. It now also declares, for every identifier it accepts, which table that identifier points at and how a row in that table is scoped. Middleware reads the declaration before the handler runs, resolves each identifier against the caller's scope, and answers with a not-found on a miss. The response for a foreign row and a missing row is identical, so the pair cannot be used to discover what exists.
Three properties follow, and only the third is really new.
The rule becomes data. It is greppable, reviewable in a change, and inherited by the generated client libraries and the machine tool surface without anyone doing anything. Previously it existed only as an inference from a query predicate three files deep.
The build verifies it against the live database. Every declared table, column and soft-delete flag must exist, so a migration that breaks a declaration fails the build rather than quietly rotting behind it.
And the default becomes closed. Generation walks every route, finds every identifier that refers to one of our rows, and fails the build if one is neither declared nor explicitly marked as not a reference with a stated reason. You do not hand-enumerate hundreds of routes. The build lists the unanswered ones and refuses to produce anything until each has an answer.
That last mechanism was not invented for this. The codebase already refuses to generate a route that has no test beside it, and it caught me with it during this work: three routes I had added silently failed to register locally and would have been fatal in the pipeline. A rule that had been enforced on its own author is a rule worth copying.
What it does not cover
Two things this cannot reach, stated so nobody assumes otherwise.
Identifiers that arrive inside stored content rather than in the request. A reference harvested from inside a saved document is invisible to the request contract, so it cannot be declared and cannot be forced.
And which rows a filtered list returns. The middleware validates the parent identifier a caller named. It cannot add a predicate to the query the handler then runs.
Both need the data layer rather than the request layer, which is row-level security, and that is a project rather than a switch. The residue is roughly two dozen queries that carry their scope by hand today. It is smaller than what this replaced, and it is written down.
Result
The two halves of this took a week and they were the same lesson at two scales. In part one, four systems answered one question and the one that decided was not the one I was reading. Here, three test suites reported on a system and none of them was pointed at it.
What connects them is that every mechanism I had built to catch a mistake was verifying itself. The permission suite checked a table against the table. The guard replayed a result recorded before the code existed. And when a real test caught a real regression, I edited the test.
The corrections were all the same move. Point the check at the running thing. Boot the application and issue the request. Read the caller of the function rather than the function. Clear the cache and run it cold. Every wrong claim I made in that week came from reading, and every correction came from running something.
The declarations are the durable half. Both questions are now answered in the same place, in the description the platform generates everything else from, and the build will not produce that description until every identifier has been accounted for.
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.