@cfast/permissions
Overview
Section titled “Overview”@cfast/permissions is an isomorphic, Drizzle-native permission system that brings application-level row-level security to Cloudflare D1. Permissions are not just boolean checks — they compile down to Drizzle where clauses that filter data at the query level.
You define permissions once. @cfast/db enforces them as lazy operations. The same definitions that guard your database queries also tell your UI which buttons to show, with zero duplication.
Installation
Section titled “Installation”pnpm add @cfast/permissionsPeer dependency: drizzle-orm (for where clause expressions).
Quick Setup
Section titled “Quick Setup”Define your roles, their grants, and an optional hierarchy:
import { definePermissions, grant } from "@cfast/permissions";import { eq } from "drizzle-orm";import { posts, comments } from "./schema";
export const permissions = definePermissions({ roles: ["anonymous", "user", "editor", "admin"] as const,
grants: { anonymous: [ grant("read", posts, { where: (post) => eq(post.published, true) }), grant("read", comments), ],
user: [ grant("read", posts, { where: (post) => eq(post.published, true) }), grant("create", posts), grant("update", posts, { where: (post, user) => eq(post.authorId, user.id) }), grant("delete", posts, { where: (post, user) => eq(post.authorId, user.id) }), grant("create", comments), ],
editor: [ grant("read", posts), grant("update", posts), grant("create", posts), grant("delete", posts), grant("manage", comments), ],
admin: [ grant("manage", "all"), ], },});This permissions object is passed to createDb() on the server and can be imported on the client for UI introspection.
Core Concepts
Section titled “Core Concepts”Actions and Subjects
Section titled “Actions and Subjects”Each grant() call declares that a role can perform an action on a subject. The five actions map directly to database operations:
| Action | SQL Operation | Notes |
|---|---|---|
"read" | SELECT | where clause filters which rows are visible |
"create" | INSERT | Boolean check only (no existing row to filter) |
"update" | UPDATE | where clause restricts which rows can be modified |
"delete" | DELETE | where clause restricts which rows can be removed |
"manage" | All of the above | Shorthand for full CRUD access |
The subject is either a Drizzle table reference or the string "all" to apply to every table.
Row-Level Filtering with where
Section titled “Row-Level Filtering with where”The where function receives the table’s columns and the current user, returning a Drizzle filter expression. For "read" grants, this expression is automatically appended to every SELECT query on that table. Multiple read grants on the same table are OR’d together — a more permissive grant always wins.
// Users can only update their own postsgrant("update", posts, { where: (post, user) => eq(post.authorId, user.id),});
// A grant without where means unrestricted access to all rowsgrant("read", posts);Two-Layer Permission Model
Section titled “Two-Layer Permission Model”The system has two layers that work together:
-
Structural layer — “does this role have any grant for
updateonposts?” This can be checked without concrete values and powers client-side UI adaptation (showing or hiding buttons). -
Row-level layer — “does this role’s grant include this specific row?” This is enforced at execution time by
@cfast/dbwhen.run()is called with concrete parameter values.
Role Hierarchy
Section titled “Role Hierarchy”Roles can inherit from other roles to avoid repetition. A role’s effective grants equal its own grants plus all inherited grants, resolved recursively:
export const permissions = definePermissions({ roles: ["anonymous", "user", "editor", "admin"] as const,
hierarchy: { user: ["anonymous"], // users get everything anonymous can do editor: ["user"], // editors get everything users can do admin: ["editor"], // admins get everything editors can do },
grants: { anonymous: [ grant("read", posts, { where: (post) => eq(post.published, true) }), ], // Only additional permissions per role user: [ grant("create", posts), grant("update", posts, { where: (post, user) => eq(post.authorId, user.id) }), ], editor: [ grant("read", posts), // unrestricted, overrides anonymous's filtered read grant("update", posts), // unrestricted, overrides user's filtered update grant("delete", posts), ], admin: [ grant("manage", "all"), ], },});When multiple grants apply to the same action and table, their where clauses are OR’d. An unrestricted grant always takes precedence over filtered ones. Circular hierarchies are detected at runtime and throw an error.
Common Patterns
Section titled “Common Patterns”Checking Permissions Programmatically
Section titled “Checking Permissions Programmatically”Use checkPermissions() for custom middleware, admin UIs, or tests:
import { checkPermissions } from "@cfast/permissions";
const result = checkPermissions("user", permissions, [ { action: "update", table: posts }, { action: "create", table: auditLogs },]);
result.permitted; // boolean -- true only if ALL descriptors passresult.denied; // PermissionDescriptor[] -- which ones failedresult.reasons; // string[] -- human-readable denial reasonsHandling Permission Errors
Section titled “Handling Permission Errors”When a permission check fails during Operation.run(), a ForbiddenError is thrown:
import { ForbiddenError } from "@cfast/permissions";
try { await deletePostOp.run({ postId: "abc" });} catch (err) { if (err instanceof ForbiddenError) { err.action; // "delete" err.role; // "user" err.message; // "Role 'user' cannot delete on 'posts'" }}ForbiddenError is JSON-serializable via .toJSON(), so it can cross the server/client boundary in action responses.
Typed Columns in Where Callbacks with ColumnsOf
Section titled “Typed Columns in Where Callbacks with ColumnsOf”The ColumnsOf<TSubject, TTables> utility type gives you fully typed column access inside where callbacks, eliminating manual type casts:
import { type ColumnsOf } from "@cfast/permissions";
// Before: required a cast helper for typed columnsgrant("read", posts, { where: (cols: PostColumns, user) => eq(cols.authorId, user.id),});
// After: ColumnsOf infers columns automaticallygrant("read", posts, { where: (cols, user) => eq(cols.authorId, user.id), // ^-- ColumnsOf<typeof posts, Tables> — fully typed});ColumnsOf resolves differently based on the subject type:
- Table object (e.g.
posts) — full column types with autocompletion - JS-key string (e.g.
"posts") — resolved fromTTablesif provided - SQL-name string or
"all"— falls back toRecord<string, unknown>
This means you get type safety for the common case (table references and JS-key strings) without breaking the escape hatches ("all" and raw SQL names).
Row-Level _can Annotations
Section titled “Row-Level _can Annotations”Every row returned by findMany and findFirst includes a _can object — a Record<string, boolean> that tells you, for each CRUD action, whether the current user is allowed to perform it on that specific row.
const posts = await db.query(postsTable).findMany().run({});
posts[0]._can;// { read: true, create: true, update: true, delete: false }How _can Is Computed
Section titled “How _can Is Computed”_can is derived from the user’s grants at query time via SQL CASE expressions:
| Grant shape | SQL | _can value |
|---|---|---|
Unrestricted (no where) | Literal 1 | true for every row |
Restricted (has where) | CASE WHEN <condition> THEN 1 ELSE 0 END | Varies per row |
| No grant | Not queried | false |
manage grant | Expanded | { read: true, create: true, update: true, delete: true } |
The read action is always true on returned rows because the permission WHERE clause already filters out rows the user cannot read.
Difference from can()
Section titled “Difference from can()”can() | _can | |
|---|---|---|
| Scope | Table-level | Row-level |
| Input | Grants array + action + table | Automatic on every query row |
| Where it runs | Client or server, no DB needed | Server, evaluated in SQL |
| Use case | ”Show or hide the Create button" | "Show or hide the Edit button on this row” |
can(grants, "update", posts) answers “does the user have any update grant on posts?” — a structural check. post._can.update answers “does the grant’s WHERE clause match this row?” — a row-level truth.
resolveTablePermissions()
Section titled “resolveTablePermissions()”For batch table-level checks, resolveTablePermissions() evaluates all four CRUD actions for every table in the schema at once:
import { resolveTablePermissions } from "@cfast/permissions";import * as schema from "../db/schema";
const perms = resolveTablePermissions(grants, schema);// { posts: { read: true, create: true, update: false, delete: false }, ... }This is used internally by cfastJson() from @cfast/actions to embed table permissions in loader data. You rarely need to call it directly.
UI Patterns
Section titled “UI Patterns”With useCfastLoader() (recommended) — wraps _can into permission methods:
import { useCfastLoader } from "@cfast/actions/client";import { ActionButton } from "@cfast/joy";
function PostList() { const { posts } = useCfastLoader<typeof loader>(); return posts.map((post) => ( <tr key={post.id}> <td>{post.title}</td> <td> <ActionButton action={post.canEdit()} href={`/posts/${post.id}/edit`} whenForbidden="hide">Edit</ActionButton> <ActionButton action={post.canDelete()} input={{ _action: "delete", id: post.id }} whenForbidden="hide">Delete</ActionButton> </td> </tr> ));}Manual _can checks — use _can to conditionally render actions per row:
function PostRow({ post }: { post: WithCan<Post> }) { return ( <tr> <td>{post.title}</td> <td> {post._can.update && <button>Edit</button>} {post._can.delete && <button>Delete</button>} </td> </tr> );}For column visibility, check _can across all rows:
const anyCanDelete = posts.some((p) => p._can.delete);// Only render the "Actions" column if at least one row is deletableWhen _can Is Not Present
Section titled “When _can Is Not Present”_can is not added when:
- The
Dbwas created viadb.unsafe()(no permission context) - The
userpassed tocreateDb()isnull
Performance
Section titled “Performance”_can adds N computed columns per query, where N is the number of distinct granted CRUD actions (typically 3—5). Unrestricted grants use literal 1 and are essentially free. There is no opt-in required and no opt-out — permissions are first-class on every row.
Client-Side Imports
Section titled “Client-Side Imports”Import from @cfast/permissions/client in client bundles to avoid pulling in server-only code. The client entrypoint exports only types and the ForbiddenError class:
import { ForbiddenError } from "@cfast/permissions/client";import type { PermissionDescriptor } from "@cfast/permissions/client";