EngineeringJul 28, 20267 min read

Permission checks that cannot drift

Two call paths reaching the same data will eventually disagree about who can see it. The fix was structural, not procedural.

Marcus Wells

Engineering at RESK

Share
HTTP routeAgent tool
permission_denial()

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.

python
# 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.

Start hereYour next won deal

Never rebuild the same project twice.

We set up your workspace and your first admin. Your team joins by invite, and the next deal you close arrives as a project that is already moving.

  • We set up your workspace
  • Your team joins by invite
  • Bring the pipeline you have

Prefer to talk first?

  1. Deal marked Closed/Won

    One status change

    01
  2. Project, milestones, 5 tasks

    Drafted from the conversation

    02
  3. #harborview-onboarding

    Channel opened, kickoff posted

    03
  4. Owner notified

    In-app and browser push

    04

Every step above runs from one status change.