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:
authenticate(request)— Is the user logged in? → Yes, it is BobrequirePermission(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” → ✅- Handler creates the note
For DELETE /orgs/org-acme/notes/note-1 as Bob:
authenticate(request)→ BobrequirePermission(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?