The bypass role is the one that proves nothing

In most authorization functions the membership join also proves the resource exists. The branch that skips the join skips that proof, and the 404 with it.

Two interlocking glass rings surround a core, with the open bypass ring glowing orange

In almost every authorization function, the proof that a resource exists is a free side effect of the membership join. The branch for the role that sees everything skips that join, so it skips the proof, and the day somebody moves authorization into a shared service layer, that role stops getting 404s.

The membership join answers two questions and names one

The published advice for a resource the caller may not see is to answer 404 instead of 403, so the response never confirms the resource is there. What follows runs the other way. The question here is not whether a caller can learn that a project exists; it is whether the authorization layer itself still knows.

A role gate over a multi-tenant resource is nearly always one read:

select role from memberships where project_id = $1 and user_id = $2

An empty result means one of two things, and the gate refuses to distinguish them on purpose: the project is gone, or this caller was never on it. Both become the same 404. The collapse is safe because of a constraint rather than a convention, since the membership row references the project and is deleted with it, so a row naming a project that is no longer there cannot be read.

That second answer is the part worth naming. The function is written to decide what an actor may do, and on the way it establishes that the thing being acted on is there. Every caller downstream inherits it for free: if a role came back, a write referencing that identifier will satisfy its foreign key.

The bypass branch reads the actor, not the graph

Any system with support access has a branch above that read. An instance administrator, a staff role, a break-glass account: whatever the name, it exists so somebody can act inside a tenant they were never invited to, and it is written as the first line of the same function.

if (actor.isInstanceAdmin) return "owner";

The flag was resolved once, when the session or the access token was turned into an actor, so this is a field test rather than a query. It costs nothing and it is right about the role. What it establishes about the identifier it was handed is nothing at all. A well-formed id naming a deleted project, a typo of the right shape, an id copied from another installation: each of them comes back owner.

The branchWhat it readsWhat a returned role proves
MemberThe membership row for this actor and this resourceThe role, and that the resource is there, because the row could not outlive it
BypassA flag on the actor, resolved when the session was mintedThe role. Nothing about the identifier

Half a proof is not a defect by itself. It becomes one when something downstream begins to rely on the half that is missing, and for a long time nothing does.

Deleting the redundant lookup is what publishes the defect

In a handler-per-endpoint design every endpoint loads the resource anyway, to render it, to patch it, or to hang a row off it. The gate runs, the fetch runs, and the fetch is what turns a bad identifier into a 404.

Then comes the refactor every review approves. The checks are scattered across handlers, they disagree, one is missing outright, and the fix is to move the gate inside the service layer where it cannot be forgotten: the operation takes an actor and an identifier, asserts the role at the top, and writes at the bottom.

That per-handler fetch is exactly what the refactor deletes, and it was genuinely redundant: it re-established what the gate had already established, for every caller whose role came out of the join. For the caller whose role came out of a flag, it was the only existence check in the request.

CallerBefore the gate movedAfter
Member, identifier names a live projectWrite landsWrite lands
Member, identifier names nothing404404
Non-member, identifier names a live project404404
Bypass role, identifier names nothing404, from the handler’s own fetchForeign key violation, surfaced as a server error

We found this rule in our own code rather than in anybody’s write-up: three project-scoped writes moved behind a shared gate and began answering a constraint error where they had answered a clean 404, for instance administrators alone. The project gate’s branch now takes a primary-key lookup before it answers owner, which closes those three.

Two gates, two graphs, and no shared conditional

Once the rule is visible in one function the audit is mechanical. For every branch that can return a role, write down what a caller may conclude when it returns. Three findings tend to fall out.

The bypass test itself belongs in one function rather than at each surface that needs it. Reading a field is a one-line temptation every time a new instance-wide surface appears, and hand copies drift: a copy placed inside a handler that runs after the write it was meant to guard is a check that guards nothing. One function is also what keeps the audit finite: access decided in one module turns “every branch that can return a role” into a list somebody can finish reading.

A system usually has more than one membership graph, and each needs the audit separately. A project is one graph. The account that owns the plan, the subscription and the audit log is another, kept apart deliberately: reading a project role to settle an account question would let the owner of one project govern the billing of every sibling beside it. Two graphs mean two joins, two bypass branches, and an existence question answered once per branch rather than once per codebase.

Gates for principals outside the team stay separate even when they look identical. Two client-side principals can carry the same closed capability set, and they get twin functions rather than one function with a conditional, so that if those sets ever diverge the divergence is a diff between two gates instead of a branch inside one. A bypass on either is worth refusing on purpose. A portal principal is bounded by the project it was minted for, which is the same rule that governs what a reporter’s identity does and does not authorize: the permission comes from a proof, never from a value the request carried.

GateWhat its join provesWhat its bypass branch has to prove for itself
A project-scoped gateMembership, and that the project is thereThat the project is there
An account-scoped gateMembership in the account that owns billing and the audit logThat the account is there
A gate for a principal outside the teamThat the signed principal names one projectWhether a bypass belongs on it at all

What the lookup buys, and what it does not

The fix costs a primary-key read, and only on the branch that skips the join. No other request changes shape and the member path keeps the single query it always had. Cost was never the reason the check was missing.

What the lookup does not buy is atomicity, and that is the limit to plan around. A lookup is a point-in-time answer. The membership read it stands in for can be asked for something stronger, a row-level share lock that holds its row against deletion for the length of a transaction, and a primary-key read taken outside one has no such hold. A project deleted between the gate and the write still lands the write on the constraint. There are two ways to close that, and both are decisions rather than reflexes: run the gate and the write inside one transaction with the gate’s read taking the same row-level lock the membership read can take, or catch the constraint violation and map it onto the same 404 the gate would have produced. What does not work is treating the lookup as if it were the join.

So the portable version is a sentence to write beside any function that returns a role: what may the caller conclude when this returns? If one branch concludes less than the others, everything downstream is standing on the weaker answer. The day somebody deletes a check as redundant is the day that difference reaches production, in the one account that can see everything.

Common questions

Should an authorization check return 404 or 403 when the caller may not see the resource?

Answering 404 for a resource outside the caller's tenancy is sound and widely recommended, because a 403 confirms the resource is there. The question worth asking afterwards is whether every branch of that check can still tell.

Why would an administrator who sees every project get a server error where everybody else gets a 404?

Most gates prove existence through the membership join. If the branch for a role that sees everything skips that join, an identifier naming nothing comes back carrying a role, and the write behind it fails on its foreign key instead of answering a clean 404.

Does moving authorization into a service layer change what an API returns?

Moving the gate inward removes the per-handler fetch that used to turn a bad identifier into a 404. For any caller whose role came out of a membership row nothing changes, because that row already proved the resource exists.

How does a membership query prove that a project still exists?

A membership row carries a foreign key to its project and is deleted along with it, so a row naming a project that is gone cannot be read. Getting a role back is therefore evidence that the project is there.

Is an extra existence lookup worth adding to an authorization function?

The lookup costs a primary-key read and is paid only on the branch that skips the membership join, so the member path is unchanged. It restores the 404 rather than the atomicity, which still needs a transaction.

Should an account-wide role and a project role come from the same membership graph?

Keeping them separate is what stops the owner of one project from governing the billing of every sibling project in the account. Each graph then needs its own gate, and each gate its own existence answer.

The Notarus team

The people who build Notarus. We write up the decisions behind the product when the reasoning is worth more than the changelog line.

Keep reading

Reports with the evidence attached.

Start free, one script tag.

<script src="…/widget.js" data-key="pb_pub_…">