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

Custom Permissions

Beyond the built-in roles

So far, roles and their permissions are defined in code (ROLE_PERMISSIONS). Adding a role requires a code change and a deployment. This works for apps with fixed roles, but many SaaS products let administrators create custom roles.

An organization owner might want a “Reviewer” role that can read notes and add comments but not edit. Or a “Billing Admin” that can manage billing but not access notes. These roles are specific to each organization.

The custom roles table

Add a table for organization-specific role definitions:

CREATE TABLE IF NOT EXISTS custom_roles (
  id TEXT PRIMARY KEY,
  org_id TEXT NOT NULL,
  name TEXT NOT NULL,
  permissions TEXT NOT NULL,  -- JSON array of permission strings
  FOREIGN KEY (org_id) REFERENCES organizations(id),
  UNIQUE(org_id, name)
);

The permissions column stores a JSON array: ["notes:read", "notes:comment"]. This is simple to parse and flexible enough for any permission set.

Add this to src/db.ts in the db.exec(...) block.

Looking up permissions

Update src/permissions.ts to check custom roles when the built-in ROLE_PERMISSIONS does not have the role:

// src/permissions.ts
import db from "./db.js";

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 getPermissions(role: string, orgId: string): Set<string> {
  // Check built-in roles first
  const builtin = ROLE_PERMISSIONS[role];
  if (builtin) return builtin;

  // Check custom roles for this organization
  const custom = db
    .prepare("SELECT permissions FROM custom_roles WHERE org_id = ? AND name = ?")
    .get(orgId, role) as { permissions: string } | undefined;

  if (custom) {
    try {
      const perms = JSON.parse(custom.permissions) as string[];
      return new Set(perms);
    } catch {
      return new Set();
    }
  }

  return new Set();
}

export function roleHasPermission(role: string, permission: string, orgId?: string): boolean {
  if (orgId) {
    return getPermissions(role, orgId).has(permission);
  }
  // Fallback: built-in roles only
  const perms = ROLE_PERMISSIONS[role];
  return perms ? perms.has(permission) : false;
}

The getPermissions function checks built-in roles first, then looks up custom roles for the specific organization. This means built-in role names (viewer, editor, owner) cannot be overridden — they always use the hardcoded permissions.

Update requirePermission

Pass the orgId through to the permission check:

// src/authorize.ts — update requirePermission
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, orgId)) {
    return Response.json({ error: "Forbidden" }, { status: 403 });
  }

  return true;
}

Creating custom roles

Add a route for organization owners to create custom roles:

// In src/routes/orgs.ts
route.post("/orgs/:orgId/roles", {
  resolve: async (c) => {
    const user = authenticate(c.request);
    if (user instanceof Response) return user;

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

    const body = await c.request.json();
    const { name, permissions } = body;

    if (!name || !Array.isArray(permissions)) {
      return Response.json({ error: "Name and permissions array required" }, { status: 400 });
    }

    // Prevent overriding built-in roles
    if (ROLE_PERMISSIONS[name]) {
      return Response.json({ error: "Cannot override built-in roles" }, { status: 400 });
    }

    const id = crypto.randomUUID();
    db.prepare("INSERT INTO custom_roles (id, org_id, name, permissions) VALUES (?, ?, ?, ?)")
      .run(id, c.params.orgId, name, JSON.stringify(permissions));

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

An owner of Acme Corp can now create a “reviewer” role with ["notes:read"], then assign it to a member by updating their membership’s role field.

Try it

# Log in as Alice (owner of Acme)
# Create a custom "reviewer" role
curl -b cookies.txt -X POST http://localhost:3000/orgs/org-acme/roles \
  -H "Content-Type: application/json" \
  -d '{"name":"reviewer","permissions":["notes:read"]}'

# Update Bob's membership to "reviewer" (requires a route we have not built yet,
# or a direct database update for testing)
sqlite3 app.db "UPDATE memberships SET role = 'reviewer' WHERE user_id = 'user-bob' AND org_id = 'org-acme'"

# Log in as Bob — can read notes (notes:read) but cannot create (no notes:create)

Exercises

Exercise 1: Create a custom “billing” role with permissions ["billing:read", "billing:manage"]. Verify that a user with this role cannot access notes (notes:read is not in the set).

Exercise 2: Try creating a custom role named “owner”. It should be rejected because owner is a built-in role.

Exercise 3: Add a route to list custom roles for an organization: GET /orgs/:orgId/roles. Only owners should be able to see the list.

Why do built-in role names take priority over custom roles?

← Checking Permissions Multi-Tenancy →

© 2026 hectoday. All rights reserved.