hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Authorization with @hectoday/http

Beyond Authentication

  • Authentication vs. Authorization
  • Project Setup

Role-Based Access Control (RBAC)

  • Roles and What They Mean
  • Checking Roles in Route Handlers
  • Role Hierarchy

Permission-Based Access Control

  • From Roles to Permissions
  • Checking Permissions
  • Custom Permissions

Organization Scoping

  • Multi-Tenancy
  • Switching Organizations
  • Inviting Members

API Keys and Scoping

  • API Keys
  • Scoped API Keys

Putting It All Together

  • Policy Functions
  • Audit Logging
  • Authorization Checklist
  • Capstone: Multi-Tenant Notes API

From Roles to Permissions

Why roles alone are not enough

In the previous lesson, role hierarchy let us check “at least editor.” But the hierarchy is rigid. Every role at a given level has the same capabilities. What if you want:

  • An editor who can edit notes but cannot delete them (deletion is owner-only — this works)
  • An editor who can edit notes but cannot invite members (this also works if invites require owner)
  • A “reviewer” role that can read notes and add comments, but not edit note content

That last one breaks the hierarchy. A reviewer is more than a viewer (can comment) but less than an editor (cannot edit). There is no level between 1 and 2 where it fits cleanly.

The solution: permissions. Instead of checking “is this user at least an editor?”, check “does this user have the notes:edit permission?”

The permission model

Permissions are strings that describe specific actions: notes:read, notes:create, notes:edit, notes:delete, members:invite, members:remove, org:settings.

Each role maps to a set of permissions:

// src/permissions.ts
export const ROLE_PERMISSIONS: Record<string, Set<string>> = {
  viewer: new Set(["notes:read"]),
  editor: new Set(["notes:read", "notes:create", "notes:edit"]),
  owner: new Set([
    "notes:read",
    "notes:create",
    "notes:edit",
    "notes:delete",
    "members:invite",
    "members:remove",
    "members:role",
    "org:settings",
    "org:delete",
  ]),
};

export function roleHasPermission(role: string, permission: string): boolean {
  const perms = ROLE_PERMISSIONS[role];
  if (!perms) return false;
  return perms.has(permission);
}

Now the relationship between roles and capabilities is explicit. You can see at a glance what each role can do. An editor can read, create, and edit notes — but not delete them or manage members.

Why this is better than hierarchy alone

With role hierarchy, “at least editor” is a single number comparison. But it does not tell you which specific actions the editor can perform — you have to know the convention.

With permissions, the route handler says exactly what it needs:

// With hierarchy (implicit)
requireRole(user, orgId, "editor");
// "editor" means... create notes? Edit notes? Both? Invite members?

// With permissions (explicit)
requirePermission(user, orgId, "notes:create");
// Unambiguous: this route needs the notes:create permission

The permission version is self-documenting. A new developer reading the route knows exactly what access is required.

The naming convention

Permission strings follow the pattern resource:action:

  • notes:read — read notes
  • notes:create — create notes
  • notes:edit — edit existing notes
  • notes:delete — delete notes
  • members:invite — invite new members
  • members:remove — remove members
  • members:role — change a member’s role
  • org:settings — change organization settings
  • org:delete — delete the organization

This convention is not enforced by code — it is a naming pattern. You could use any string. But resource:action is clear, consistent, and easy to extend.

Building on the hierarchy

Permissions do not replace roles. Roles are still stored in the memberships table. The permission set is derived from the role. This means:

  • Roles remain the unit of assignment (you give a user a role, not individual permissions)
  • Permissions are the unit of checking (the code checks for specific permissions)
  • The mapping between them is in one place (ROLE_PERMISSIONS)

In the custom permissions lesson (later in this section), we will let organizations define their own roles with custom permission sets. But even then, the role → permissions mapping is the core model.

Exercises

Exercise 1: Look at the ROLE_PERMISSIONS object. Which permissions does an editor have that a viewer does not? Which permissions does an owner have that an editor does not?

Exercise 2: Where would a “reviewer” role fit? Define its permission set: notes:read and a hypothetical notes:comment. It does not need notes:create or notes:edit. Add it to ROLE_PERMISSIONS.

Why do we check permissions in route handlers instead of checking roles?

← Role Hierarchy Checking Permissions →

© 2026 hectoday. All rights reserved.