Role Hierarchy
The problem from the previous lesson
requireRole(user, orgId, "viewer") checks for an exact match. An owner is not a viewer — the check fails. But conceptually, an owner can do everything a viewer can do, and more.
We need a hierarchy: owner > editor > viewer. Checking “at least viewer” passes for owners, editors, and viewers. Checking “at least editor” passes for owners and editors. Checking “at least owner” passes only for owners.
Define the hierarchy
Update src/authorize.ts:
// src/authorize.ts
import { getMembership } from "./membership.js";
import type { AuthUser } from "./auth.js";
const ROLE_LEVELS: Record<string, number> = {
viewer: 1,
editor: 2,
owner: 3,
};
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;
} Each role has a numeric level. requireRole checks if the user’s level is at least the required level. An owner (level 3) passes any check. An editor (level 2) passes “at least viewer” and “at least editor” but not “at least owner.” A viewer (level 1) only passes “at least viewer.”
The ROLE_LEVELS object is the hierarchy definition. Adding a new role means adding one line (like moderator: 2 to place it at the same level as editor).
No code changes needed in routes
The route handlers from the previous lesson do not change. They already use requireRole:
// View: any member (at least viewer)
requireRole(user, orgId, "viewer");
// Create: editors and above
requireRole(user, orgId, "editor");
// Delete: owners only
requireRole(user, orgId, "owner"); The meaning changed (from exact match to minimum level), but the call sites stay the same. This is a good sign — the abstraction is clean.
Try it
# Log in as Alice (owner at Acme)
curl -c cookies.txt -X POST http://localhost:3000/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"password123"}'
# List notes — now works (owner >= viewer)
curl -b cookies.txt http://localhost:3000/orgs/org-acme/notes
# Create a note — works (owner >= editor)
curl -b cookies.txt -X POST http://localhost:3000/orgs/org-acme/notes \
-H "Content-Type: application/json" \
-d '{"title":"New Note","body":"Created by Alice"}'
# Log in as Carol (viewer at Acme)
curl -c cookies.txt -X POST http://localhost:3000/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"password123"}'
# List notes — works (viewer >= viewer)
curl -b cookies.txt http://localhost:3000/orgs/org-acme/notes
# Create a note — 403 (viewer < editor)
curl -b cookies.txt -X POST http://localhost:3000/orgs/org-acme/notes \
-H "Content-Type: application/json" \
-d '{"title":"Should Fail","body":"Carol cannot create"}' When hierarchy is not enough
Role hierarchy works well when permissions are strictly layered: every higher role can do everything a lower role can do. But what if you want an editor who can edit notes but cannot manage members? With a hierarchy, if member management requires “at least editor,” then all editors can manage members.
The next section introduces permissions, which decouple specific actions from the role hierarchy. An editor might have notes:create and notes:edit but not members:invite.
Exercises
Exercise 1: Test all three users (Alice, Bob, Carol) against all three operations (list, create, delete) in Acme. Verify the hierarchy works: Alice can do everything, Bob can list and create, Carol can only list.
Exercise 2: Log in as Alice and try to access Globex notes. She is a viewer there. She should be able to list but not create or delete.
Exercise 3: What happens if a membership has a role not in ROLE_LEVELS (e.g., "guest")? The ?? 0 fallback gives it level 0, which is below viewer. The user cannot do anything. This is a safe default.
Why do we use numeric levels instead of checking the role string directly?