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?