For a while we had two places that decided whether you could see a record. The HTTP dependency that guarded the routes, and the helpers our agent tools called. They agreed, until they did not.
How drift happens
Nobody sets out to write two permission systems. You write one, then you add a second entry point — an integration, a tool call, a background job — and it needs the same decision in a slightly different shape. Copying twelve lines is faster than refactoring, and it is correct on the day you do it.
The drift arrives with the next change. Someone adds a company-level module gate to the route guard. The tool path does not get it, because nothing in the codebase says these two things are the same decision.
One function, two callers
The fix was to make the decision itself the shared thing, rather than the wrapper around it. The permission check lives in one place and returns a denial or nothing. The HTTP layer turns a denial into a 403; the tools turn it into a refusal message. Neither reimplements the rule.
# core/permissions.py — the one implementationdenial = permission_denial(user, module, required) # routers/deps.pyif denial: raise HTTPException(403, denial) # agent tools call the same function directlyif denial: return ToolError(denial)Order is load-bearing
The gate runs in a fixed order, and the order is the security property. The company ceiling is checked before role, so a module switched off for the company cannot be reopened by an admin, an access role, or a per-module override. If role were checked first, a super admin flag would quietly punch through a company-level restriction.
- Is the module disabled for the company? Stop.
- Is the caller an admin or super admin? Pass.
- Does an override exist for this module? Use it. Otherwise use the role.
- Is the level high enough for what is being attempted?
Row scoping is a separate layer
Passing the gate means the module is reachable, not that every record in it is. Scoping runs after, and it is deliberately narrow: a person sees rows they own, are assigned, collaborate on, or share a team with. Admins see everything, and that is the only exception.
The practical result is that adding a new entry point is no longer a security review. It calls the same function everything else calls, and it inherits every rule automatically — including the ones added after it was written.
Marcus Wells
Engineering at RESK
Works on permissions, visibility, and the API surface at RESK.