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.

Where one permission lives

Jesse James Richard
|
Feb 18, 2026
|
5 min read
#Access Control
#Testing

Every route in the Giant Context API declares which permission guards it. That declaration is a description, and the enforcement is a behaviour, and nothing in a type system makes them agree.

A route says app:write. Its handler, through a refactor or a copy-paste, stops checking. The contract says guarded. The behaviour says open. Nothing downstream notices, because the catalog, the database and the console's own gating all read the declaration rather than the handler, and the declaration has not changed.

Only one thing distinguishes a route that enforces its permission from a route that merely claims to, which is sending it a request as somebody who should be refused and seeing what comes back. So that is what the tests do, and a generator writes them from the same declarations they are checking.

What a route declares

The permission is written into the contract, next to the code it guards:

The route declaration

// apps/email/server/routes/…/actions/send/protected.ts{  summary: "Send transactional email",  "x-permissions": ["app:write"],  // …}

The x- prefix is an OpenAPI extension, the spec's mechanism for attaching custom fields to an API description. This API already describes itself in OpenAPI and the whole client side is generated from that description, so the contract was already the one file every tool reads.

From there a script walks every route and collects the declarations into a catalog, where the generic declaration gets specific. The email app's app:write becomes email:write, named for the app it guards:

Home two, the catalog

A script walks every route in the platform and collects the declarations into a catalog. In the catalog, the generic declaration gets specific. The email app's app:write becomes email:write, named for the app it guards:

The catalog entry

{  "name": "email:write",  "routes": [    "POST …/apps/email/{appId}/actions/send",    "PUT  …/apps/email/{appId}/templates/{templateId}",    "PUT  …/apps/email/{appId}/settings"  ]}

The permissions table in Postgres is filled from that same catalog. So what permissions exist, what each one unlocks, and what the database will check are one answer with one source, and the roles that bundle them sit on top of it.

None of which says anything about whether the handler behind the route reads the declaration at all.

The tests

The generator reads the same declarations and, for every endpoint, writes two blocks. One sends a real request as each role that should be refused:

The generated denial test

// AUTO-GENERATED - DO NOT EDITdescribe("denied roles", () => {  it("returns 403 for org collaborator", async () => {    const ctx = await setupOrgScenario("collaborator");    const response = await app.inject({      method: "GET",      url: `/organizations/${ctx.org.id}/members/…`,      headers: authHeaders(ctx.user.token),    });    // 403 = permission denied (expected)    // 400 = validation failed before permission check    expect([400, 403]).toContain(response.statusCode);  });});

And one tries it as each role that should get through:

The generated allowed test

describe("allowed roles", () => {  it("returns 200 for org owner", async () => {    // …    expect(response.statusCode).not.toBe(401);    expect(response.statusCode).not.toBe(403);  });});

Real requests, against a real running API, with users manufactured for each role. A route that quietly stopped checking fails the denied block, because the collaborator got in. A route that got too strict fails the allowed block, because the owner was refused. Both directions, from the same line that created the problem.

The allowed block asserts what it did not get rather than what it did. A permitted role hitting a synthetic record can legitimately receive a 404, so the assertion is that the request was neither unauthenticated nor forbidden. Checking for 200 would fail on routes that behave correctly.

The denied block accepts a 400 alongside a 403 for a related reason. Validation runs before the permission check on some routes, so a refused caller sending a malformed id gets rejected one step early. Both statuses mean the caller did not reach the handler, which is the property under test. A stricter assertion would be more satisfying to write and would fail on correct code.

The suite runs in its own job on every push.

A screenshot of a command-line interface showing automated test results for API endpoints.
The RBAC suite in a real run. Every route tried as every role, both directions.

Wiring this up yourself

Declare the permission in the API description. If you use OpenAPI, extensions are the standard mechanism, and any field starting with x- is yours. A declaration a tool can read is worth more than a document a person is supposed to remember.

Extract a catalog. A script that walks the routes and collects the declarations is an afternoon of work. Once it exists, what-permissions-exist stops being something anyone has to know.

Generate the denial tests first. For every operation and every role, the expected outcome is already known from the declaration, so the tests are mechanical to produce. Denial comes first because an over-permissive route is the expensive failure and the denied block is what catches it. The allowed direction is worth having and it catches the cheaper mistake.

The permission model here does not get reviewed on a schedule. Its reviewers are regenerated on every push, from the same declarations they are checking.

Names steer the code

#Architecture
#AI & Agents

Finding the name is how I work out whether I understand the thing I am about to build. A name draws a boundary before any code exists, then steers eve...

Jesse James Richard

|

Feb 17, 2026
Read 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