Skip to content

@cfast/permissions

@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.

Terminal window
pnpm add @cfast/permissions

Peer dependency: drizzle-orm (for where clause expressions).

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.

Each grant() call declares that a role can perform an action on a subject. The five actions map directly to database operations:

ActionSQL OperationNotes
"read"SELECTwhere clause filters which rows are visible
"create"INSERTBoolean check only (no existing row to filter)
"update"UPDATEwhere clause restricts which rows can be modified
"delete"DELETEwhere clause restricts which rows can be removed
"manage"All of the aboveShorthand for full CRUD access

The subject is either a Drizzle table reference or the string "all" to apply to every table.

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 posts
grant("update", posts, {
where: (post, user) => eq(post.authorId, user.id),
});
// A grant without where means unrestricted access to all rows
grant("read", posts);

The system has two layers that work together:

  1. Structural layer — “does this role have any grant for update on posts?” This can be checked without concrete values and powers client-side UI adaptation (showing or hiding buttons).

  2. Row-level layer — “does this role’s grant include this specific row?” This is enforced at execution time by @cfast/db when .run() is called with concrete parameter values.

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.

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 pass
result.denied; // PermissionDescriptor[] -- which ones failed
result.reasons; // string[] -- human-readable denial reasons

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 columns
grant("read", posts, {
where: (cols: PostColumns, user) => eq(cols.authorId, user.id),
});
// After: ColumnsOf infers columns automatically
grant("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 from TTables if provided
  • SQL-name string or "all" — falls back to Record<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).

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 }

_can is derived from the user’s grants at query time via SQL CASE expressions:

Grant shapeSQL_can value
Unrestricted (no where)Literal 1true for every row
Restricted (has where)CASE WHEN <condition> THEN 1 ELSE 0 ENDVaries per row
No grantNot queriedfalse
manage grantExpanded{ 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.

can()_can
ScopeTable-levelRow-level
InputGrants array + action + tableAutomatic on every query row
Where it runsClient or server, no DB neededServer, 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.

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.

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 deletable

_can is not added when:

  • The Db was created via db.unsafe() (no permission context)
  • The user passed to createDb() is null

_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.

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";