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 branch | What it reads | What a returned role proves |
|---|---|---|
| Member | The membership row for this actor and this resource | The role, and that the resource is there, because the row could not outlive it |
| Bypass | A flag on the actor, resolved when the session was minted | The 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.
| Caller | Before the gate moved | After |
|---|---|---|
| Member, identifier names a live project | Write lands | Write lands |
| Member, identifier names nothing | 404 | 404 |
| Non-member, identifier names a live project | 404 | 404 |
| Bypass role, identifier names nothing | 404, from the handler’s own fetch | Foreign 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.
| Gate | What its join proves | What its bypass branch has to prove for itself |
|---|---|---|
| A project-scoped gate | Membership, and that the project is there | That the project is there |
| An account-scoped gate | Membership in the account that owns billing and the audit log | That the account is there |
| A gate for a principal outside the team | That the signed principal names one project | Whether 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.