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

Checking Permissions

The requirePermission function

Now that we have a role-to-permission mapping, we can replace requireRole with requirePermission in our route handlers. The function looks up the user’s role in the organization, then checks if that role includes the required permission.

Update src/authorize.ts:

// src/authorize.ts
import { getMembership } from "./membership.js";
import { roleHasPermission, ROLE_PERMISSIONS } from "./permissions.js";
import type { AuthUser } from "./auth.js";

const ROLE_LEVELS: Record<string, number> = {
  viewer: 1,
  editor: 2,
  owner: 3,
};

// Keep requireRole for backwards compatibility or simple checks
export function requireRole(user: AuthUser, orgId: string, minimumRole: string): true | Response {
  const membership = getMembership(user.id, orgId);
  if (!membership) return Response.json({ error: "Not found" }, { status: 404 });

  const userLevel = ROLE_LEVELS[membership.role] ?? 0;
  const requiredLevel = ROLE_LEVELS[minimumRole] ?? 0;
  if (userLevel < requiredLevel) return Response.json({ error: "Forbidden" }, { status: 403 });

  return true;
}

// The new permission-based check
export function requirePermission(
  user: AuthUser,
  orgId: string,
  permission: string,
): true | Response {
  const membership = getMembership(user.id, orgId);

  if (!membership) {
    return Response.json({ error: "Not found" }, { status: 404 });
  }

  if (!roleHasPermission(membership.role, permission)) {
    return Response.json({ error: "Forbidden" }, { status: 403 });
  }

  return true;
}

Same pattern as requireRole: returns true if authorized, or a Response if not. The difference is what it checks — a specific permission instead of a role level.

Update the routes

Replace requireRole with requirePermission in src/routes/notes.ts:

import { requirePermission } from "../authorize.js";

// List notes — requires notes:read
route.get("/orgs/:orgId/notes", {
  resolve: (c) => {
    const user = authenticate(c.request);
    if (user instanceof Response) return user;

    const perm = requirePermission(user, c.params.orgId, "notes:read");
    if (perm instanceof Response) return perm;

    const notes = db.prepare("SELECT * FROM notes WHERE org_id = ?").all(c.params.orgId);
    return Response.json(notes);
  },
}),

// Create a note — requires notes:create
route.post("/orgs/:orgId/notes", {
  request: { body: CreateNoteBody },
  resolve: (c) => {
    const user = authenticate(c.request);
    if (user instanceof Response) return user;

    const perm = requirePermission(user, c.params.orgId, "notes:create");
    if (perm instanceof Response) return perm;

    if (!c.input.ok) return Response.json({ error: c.input.issues }, { status: 400 });

    const { title, body: noteBody } = c.input.body;
    const id = crypto.randomUUID();
    db.prepare("INSERT INTO notes (id, org_id, created_by, title, body) VALUES (?, ?, ?, ?, ?)")
      .run(id, c.params.orgId, user.id, title, noteBody);

    return Response.json({ id }, { status: 201 });
  },
}),

// Delete a note — requires notes:delete
route.delete("/orgs/:orgId/notes/:noteId", {
  resolve: (c) => {
    const user = authenticate(c.request);
    if (user instanceof Response) return user;

    const perm = requirePermission(user, c.params.orgId, "notes:delete");
    if (perm instanceof Response) return perm;

    const result = db.prepare("DELETE FROM notes WHERE id = ? AND org_id = ?")
      .run(c.params.noteId, c.params.orgId);

    if (result.changes === 0) return Response.json({ error: "Not found" }, { status: 404 });
    return Response.json({ message: "Deleted" });
  },
}),

Notice how readable this is. Each route declares exactly what permission it requires. "notes:read", "notes:create", "notes:delete" — no ambiguity about what “editor” means.

The full authorization flow

For a request like POST /orgs/org-acme/notes:

  1. authenticate(request) — Is the user logged in? → Yes, it is Bob
  2. requirePermission(bob, "org-acme", "notes:create") → a. getMembership("user-bob", "org-acme") → Bob has role “editor” b. roleHasPermission("editor", "notes:create") → editor’s permissions include “notes:create” → ✅
  3. Handler creates the note

For DELETE /orgs/org-acme/notes/note-1 as Bob:

  1. authenticate(request) → Bob
  2. requirePermission(bob, "org-acme", "notes:delete") → a. Bob has role “editor” b. roleHasPermission("editor", "notes:delete") → editor’s permissions do NOT include “notes:delete” → ❌ 403

Bob can create notes but not delete them. The permission model expresses this naturally.

Exercises

Exercise 1: Test all three users against all three operations with the new permission checks. Verify the results match the ROLE_PERMISSIONS mapping.

Exercise 2: Add an update route (PUT /orgs/:orgId/notes/:noteId) that requires notes:edit. Test with all three users.

Exercise 3: What happens if you check a permission that no role has (e.g., "notes:archive")? Everyone gets 403 because no role’s permission set contains it. This is a safe default.

What happens when Bob (editor) tries to delete a note?

← From Roles to Permissions Custom Permissions →

© 2026 hectoday. All rights reserved.