Skip to content
Back

Handling Permissions in TypeScript

TypeScript

Role checks scatter. RBAC centralizes action:resource. ABAC types the subject, action, and resource so ownership and status live in one file

if (user.role === "admin") is not a permission system. It is a check that will be copied into every button, every Server Action, and every query. Adding a moderator means hunting the codebase.

This note follows Kyle's walkthrough. Authn is who; authz is what they may do — Web Security and the OWASP Top 10. Org membership and the row are Building a Multi-Tenant Backend with Hono, Better Auth, Drizzle, and Postgres RLS. The UI is not authorization: Security in Next.js. The type-level maps that make the permission table compile are TypeScript Infer, Extends, and Ternaries. This note is the engine.



1. The scattered if

A comment on a page. An admin can delete it. Then a moderator should too. Then the author should delete their own. The check grows in every place that can delete.


ts
const canDelete =
  user.role === "admin" ||
  user.role === "moderator" ||
  user.id === comment.authorId

  • Authentication answers who. Authorization answers what they may do. A login is not permission to delete someone else's comment.
  • The same || lands in the UI, in the Server Action, and in the query. Change what a moderator can do and you walk every site.
  • Roles live in the code, not in a config. There is no one file to read.

Failure: a new role, then a search for === "admin". The miss is a button that still shows, or a handler that still deletes.


2. RBAC

Role-based access control switches the question. A role is a set of permissions. A permission is an action on a resource: view, create, update, delete on comment, todo, article. The usual spelling is resource:action.


ts
type Role = "admin" | "moderator" | "user"

type Permission =
  | "comment:view"
  | "comment:create"
  | "comment:update"
  | "comment:delete"

const rolePermissions: Record<Role, Permission[]> = {
  admin: ["comment:view", "comment:create", "comment:update", "comment:delete"],
  moderator: [
    "comment:view",
    "comment:create",
    "comment:update",
    "comment:delete",
  ],
  user: ["comment:view", "comment:create"],
}

type User = {
  id: string
  role: Role
}

function hasPermission(user: User, permission: Permission): boolean {
  return rolePermissions[user.role].includes(permission)
}

if (hasPermission(user, "comment:delete")) {
  // show the button — the same check runs on the server
}

  • The union is the fence. "comment:explode" does not compile. A stringly-typed "comment:delet" is a silent deny at runtime.
  • Take delete off the moderator list in one object. Every hasPermission(user, "comment:delete") follows.
  • Store the table in code, in a database, wherever. The call site stays one function.

Failure: hasPermission(user, "delete comments") as a string. The typo is a permission that never matches.


3. Many roles, then orgs

A user should be roles: Role[] even if you never assign two. The extra code is .some. The flexibility is cheap.


ts
type User = {
  id: string
  roles: Role[]
}

function hasPermission(user: User, permission: Permission): boolean {
  return user.roles.some((role) => rolePermissions[role].includes(permission))
}

A second axis is the organization. Admin in workspace A, member in workspace B, one account. The role is no longer global; it is a join of user, org, and role.

Kyle wires that through Clerk session claims. This stack already has the membership table: Better Auth organizations, activeOrganizationId, and a verified organizationId on the Hono context. Do not copy a session-token recipe. The identity path is Building a Multi-Tenant Backend with Hono, Better Auth, Drizzle, and Postgres RLS.


text
user ──< member >── organization

              └── role ──< role_permission >── permission

  • One role per user is a special case of many. Model many.
  • One role per product is a special case of one role per org. Model the org when users can be in more than one workspace.
  • The permission list can stay in TypeScript. The membership cannot. Membership is a row.

Failure: a global role on the user, then a second product that needs a different role in a different org. The column was the wrong shape.


4. Where RBAC dies

RBAC answers "does this role include this permission?" It does not answer "is this their comment?" or "is this todo completed?"

The first patch is a new permission: comment:delete:own. The call site grows again.


ts
const canDelete =
  hasPermission(user, "comment:delete") ||
  (hasPermission(user, "comment:delete:own") &&
    user.id === comment.authorId)

Then published articles cannot be edited. Then a block list hides comments. Each attribute becomes another permission string and another && next to hasPermission. The config is no longer the whole story. The call site is back to the scattered if.


Failure: a Permission union that encodes every attribute as a suffix — :own, :completed, :unlocked. The check that still lives at the button is the one you will forget.


5. Resource-level roles

Some products need a role on the row, not only on the org. Google Drive: viewer on this file, editor on that folder, owner on another. The org role still exists. The share is a second join.


text
user ──< user_resource_role >── resource
              │                    │
              └── role             └── type + id

  • One shareable type — blogs, say — can hard-code a join table. That is fine.
  • Many types — files, folders, images — want a generic (userId, resourceType, resourceId, role) rather than a join per table.
  • The name for a graph of "who relates to what" is ReBAC. This note does not build Zanzibar. It names the shape so you know when RBAC-on-the-org is the wrong model.

Failure: copying the org-role join once per resource table, then adding a third shareable type. The generic tuple was cheaper on day two.


6. ABAC, typed

Attribute-based access control asks four things: the subject (almost always the user), the action, the resource, and any extra attributes — org, environment, device. Every one of those has fields. A rule can read user.id, comment.authorId, todo.completed, user.blockedBy.

The TypeScript job is to make an illegal check unrepresentable. Comments have view | create | update. They do not have delete. A todo rule receives a Todo, not a Comment.


ts
type Role = "admin" | "moderator" | "user"

type User = {
  id: string
  roles: Role[]
  blockedBy: string[]
}

type Comment = {
  id: string
  authorId: string
}

type Todo = {
  id: string
  userId: string
  completed: boolean
  invitedUsers: string[]
}

type Permissions = {
  comments: {
    dataType: Comment
    action: "view" | "create" | "update"
  }
  todos: {
    dataType: Todo
    action: "view" | "create" | "update" | "delete"
  }
}

type PermissionCheck<Key extends keyof Permissions> =
  | boolean
  | ((user: User, data: Permissions[Key]["dataType"]) => boolean)

type RolesWithPermissions = {
  [R in Role]: {
    [Key in keyof Permissions]: {
      [Action in Permissions[Key]["action"]]: PermissionCheck<Key>
    }
  }
}

Permissions is a mapped type over resources. PermissionCheck is true for "always" or a function for "this row." The mapped RolesWithPermissions is the same three keywords as TypeScript Infer, Extends, and Ternaries: extends, a key remap, a ternary you do not need here because every action must be present.


ts
const ROLES = {
  admin: {
    comments: { view: true, create: true, update: true },
    todos: { view: true, create: true, update: true, delete: true },
  },
  moderator: {
    comments: { view: true, create: true, update: true },
    todos: {
      view: true,
      create: true,
      update: true,
      delete: (_user, todo) => todo.completed,
    },
  },
  user: {
    comments: {
      view: (user, comment) => !user.blockedBy.includes(comment.authorId),
      create: true,
      update: (user, comment) => comment.authorId === user.id,
    },
    todos: {
      view: (user, todo) => !user.blockedBy.includes(todo.userId),
      create: true,
      update: (user, todo) =>
        todo.userId === user.id || todo.invitedUsers.includes(user.id),
      delete: (user, todo) =>
        todo.completed &&
        (todo.userId === user.id || todo.invitedUsers.includes(user.id)),
    },
  },
} as const satisfies RolesWithPermissions

function hasPermission<Resource extends keyof Permissions>(
  user: User,
  resource: Resource,
  action: Permissions[Resource]["action"],
  data?: Permissions[Resource]["dataType"]
): boolean {
  return user.roles.some((role) => {
    const permission = (ROLES as RolesWithPermissions)[role][resource][action]
    if (typeof permission === "boolean") return permission
    if (data == null) return false
    return permission(user, data)
  })
}

hasPermission(user, "todos", "delete", todo)
hasPermission(user, "todos", "create")
// hasPermission(user, "comments", "delete")
// error — comments have no delete action

  • true means every row. A function means this row. Omit data and a function rule returns false — "all comments" is not the same as "this comment."
  • Ownership, completed, invited, blocked: those attributes live in the rule, not next to the button.
  • satisfies RolesWithPermissions checks the table without widening true to boolean in a way that erases the point. The call site never names a role.

Failure: hasPermission(user, "comments", "delete") as a string overload. The missing action should be a type error, not a runtime undefined.


7. Where it runs

The same function can hide a button and reject a mutation. Only the server check is authorization.


ts
app.delete("/todos/:id", async (c) => {
  const user = c.get("user")
  const todo = await findTodo(c.req.param("id"))
  if (!hasPermission(user, "todos", "delete", todo)) {
    return c.json({ error: "forbidden" }, 403)
  }
  await deleteTodo(todo.id)
  return c.body(null, 204)
})


Failure: checking hasPermission only in the React tree, then trusting the Server Action because the button was hidden.


Takeaway

A role is a set of permissions. A permission is an action on a resource. An attribute of this row does not belong in the role table.

When the question is which engine to write:

  1. Is this a role? Record<Role, Permission[]> and hasPermission(user, "comment:delete"). A string-literal union, not a string.
  2. Is this an attribute of this row? Ownership, status, block lists. ABAC: true or (user, data) => boolean, one file, typed resource and action.
  3. Is this a relationship to another object? Shared files, parent folders. Resource-level roles — ReBAC — not another :own suffix.

Recap Q&A

Read the next note
TypeScript Error Handling