Skip to content
All writing

NOTES & IDEAS

Who's Allowed to Do What? Permissions Without the Mess

Sprinkling "if role is owner or editor" checks all over your code is how permission bugs are born. Here's how I collapsed all of it into one idea - ranked roles and a single "do you have enough?" check - explained with building keycards.

SecurityNode.jsAuthorizationRBACBackend

Apico (my API testing tool) has shared workspaces - teams work together, and not everyone should be able to do everything. There are three roles: Owner, Editor, and Viewer. The rules are common sense: viewers look, editors change things, owners run the place.

The tempting way to enforce that is a little check at every single endpoint:

if (member.role !== 'OWNER' && member.role !== 'EDITOR') {
  throw new ForbiddenError();
}

This rots fast, and the way it rots is dangerous. Let me explain why with an office building, then show the clean fix.

Why scattered checks are a trap

Imagine a building where every door has its own guard holding a handwritten note: "let people in if they're an Owner or an Editor." Now you've copied that same note to dozens of doors. The day you add a fourth role, you have to track down and rewrite every note. Miss one door, and the wrong person quietly walks into a room they shouldn't - and nothing errors, nothing alarms. That silent "wrong person got in" is the worst kind of security bug.

The lesson: the rule for who's allowed should live in one place, not be copied onto every door.

The insight: these roles are ranked, like clearance levels

Owner, Editor, Viewer aren't just three separate labels - they're a ladder. An Owner can do everything an Editor can, plus more. An Editor can do everything a Viewer can, plus more. So give each one a clearance number:

const ROLE_ORDER: Record<WorkspaceRole, number> = {
  [WorkspaceRole.OWNER]:  3,
  [WorkspaceRole.EDITOR]: 2,
  [WorkspaceRole.VIEWER]: 1,
};

Now the question "are you allowed?" stops being a tangle of "is it this role OR that role" and becomes one clean comparison: is your clearance at least what this door requires?

Two guards, used on every door

That collapses the whole thing into two small helpers. The first just asks "are you even in this building?" (the line between separate customers):

export const requireWorkspaceMember = async (workspaceId: string, userId: string) => {
  const member = await findWorkspaceMember(workspaceId, userId);
  if (!member) throw new ForbiddenError('Access denied');
  return member;
};

The second asks "do you have enough clearance for this?" - and builds on the first, so being a member and having the rank are checked together:

export const requireWorkspaceRole = async (
  workspaceId: string,
  userId: string,
  minRole: WorkspaceRole,
) => {
  const member = await requireWorkspaceMember(workspaceId, userId);
  if (ROLE_ORDER[member.role] < ROLE_ORDER[minRole]) {
    throw new ForbiddenError('Insufficient permissions');
  }
  return member;
};

That one line - ROLE_ORDER[member.role] < ROLE_ORDER[minRole] - is the entire permission system. Every door just states the lowest clearance it needs and forgets the rest:

// reading things - any member
await requireWorkspaceRole(workspaceId, userId, WorkspaceRole.VIEWER);

// editing a collection - editor or above
await requireWorkspaceRole(workspaceId, userId, WorkspaceRole.EDITOR);

// renaming/deleting the workspace, managing people - owner only
await requireWorkspaceRole(workspaceId, userId, WorkspaceRole.OWNER);

A door marked "Editor" automatically lets Owners through too, because 3 is greater than 2. You never write "Owner OR Editor" again - the ladder gives you "and everyone above" for free.

Why this holds up as you grow

  • One source of truth. All the permission logic is two functions and one little map. To audit "who can do what," you read one file, not every controller.
  • Adding a role is one line. Drop in ADMIN: 4 (or a CONTRIBUTOR: 1.5 between Viewer and Editor) and every existing door keeps working sensibly - no door edits.
  • It hands back the member. Both guards return the membership info, so the door gets its "allowed?" answer and the data it needed anyway, in one step.
  • It fails shut. No membership → blocked. Not enough clearance → blocked. There's no accidental way in; you have to explicitly open a door by stating a minimum.

When the ladder doesn't fit

This works because Apico's roles are a genuine ladder - more senior always means strictly more power. Not every system is like that. If you ever get a role that's sideways rather than higher - say a "billing admin" who can manage payments but can't edit content - that's not a higher rung, it's a different key entirely. At that point ranking by number stops fitting, and you move to listing each role's specific permissions. Knowing when the ladder applies is as important as the ladder itself: perfect for ranked roles, wrong tool for sideways ones.

Takeaway

Permission checks copy-pasted as little if conditions are a bug and a breach waiting to happen. When your roles form a ladder, rank them as numbers and reduce every access decision to one question: do you meet the minimum? One map, two guards, and every endpoint becomes a single self-explaining line that states exactly the access it needs.