Authorization
The example's permissions are built on
@tikab-interactive/typescript-rules — composable, async-first predicates
collected in named registries. The same registry is checked on the server
(route guards, server-fn gates) and on the client (the nav), so "what does
site-admin mean" lives in one place instead of drifting between the two.
typescript-rules is a published source package (not fusion--scoped) — it's
installed, not linked, so it needs ssr.noExternal (see
Conventions).
Coming from Django?
The whole model maps cleanly onto Django, so lead with that if it's your background:
| Django | Here |
|---|---|
A has_perm / user.has_perm("app.change_thing") | registry.check("project.change", user, ctx) — a named check against a registry |
An object-level permission method (obj.can_write(u)) | A predicate — predicate(name, (user, obj) => …), composed with .and() / .or() / .not() |
get_queryset() narrowing a list to what you may see | A drizzlePredicate's where(viewer) → a Drizzle WHERE clause, applied in SQL |
Re-checking in the view even after get_queryset() | The mutation server-fn re-checks the object before writing (defence in depth) |
A {% if perms.app.change_thing %} template guard | usePermission(...) / a per-row canEdit flag deciding whether to render the button |
The one idea that doesn't have a tidy Django analogue: here the same predicate
object powers the get_queryset() filter, the view-level guard, and the
template if — there is no second copy of the rule to keep in sync.
A permission is a predicate
The atom is a predicate(name, check) — a named function from
(user, object) → boolean | Promise<boolean>. The factory (predicate in
@tikab-interactive/typescript-rules/core) returns an object that also carries
.and() / .or() / .not(), so predicates compose into new predicates
while keeping a readable name:
const isMember = predicate<User, ProjectContext>(
"processn.project.isMember",
(_user, ctx) => ctx.membership !== null,
);
const memberCanChange = predicate<User, ProjectContext>(
"processn.project.memberCanChange",
(_user, ctx) => ctx.membership?.canChangeProject ?? false,
);Those atomic predicates compose into the registered permissions — viewing needs membership; changing needs membership and the grant:
export const processnRegistry = new Registry<User, ProjectContext>();
processnRegistry.add("project.view", isMember);
processnRegistry.add("project.change", isMember.and(memberCanChange));
processnRegistry.add("project.assignTasks", isMember.and(memberCanAssignTasks));Two properties carry the design:
- Typed in both arguments.
predicate<User, ProjectContext>fixes the user shape and the object-context shape. A predicate written against aProjectContextcan only be added to a registry built for that context, so apoint.viewrule can never be checked with a company context by accident. - Composable and named.
isMember.and(memberCanChange)builds a fresh predicate whose name is"(processn.project.isMember AND processn.project.memberCanChange)"(the factory derives it). Coming from Django, this iscan_write()expressed as data instead of an overridden method — you read the policy off the registry rather than chasing method overrides.
A Registry is just the lookup table from a public name to a predicate.
The names are the app's vocabulary; the predicate behind a name can get narrower
later without touching a single call site. In example/src/lib/permissions.ts
both site-level names point at the same predicate today —
/** Site administrators can administer the whole site. */
export const isSiteAdmin = predicate<AuthUser, unknown>(
"is_site_admin",
(user) => user.isSiteAdmin === true,
);
/**
* The app's permission registry — the vocabulary the rest of the app checks
* against. Names map to composable predicates; today both resolve to "is a site
* admin", but a name like `users.manage` can later point at a narrower predicate
* (a delegated user-manager role) without touching any call site.
*/
export const permissions = new Registry<AuthUser, unknown>();
permissions.add("site.administer", isSiteAdmin);
permissions.add("users.manage", isSiteAdmin);— but users.manage can later be re-pointed at a delegated user-manager role
and every guard that checks "users.manage" inherits the narrower rule for free.
The richer apps build their object context first, then check several names
against it. getProjectPermissions (in pulsn-apps/src/protokolln/permissions.ts)
resolves the viewer's effective, hierarchy-aware grants once and returns the
capability bundle the rest of the app reads:
// [!include ~/snippets/pulsn-apps/src/protokolln/permissions.ts:getProjectPermissions]ProtokollN permissions inherit downward through the project tree:
resolveProjectPerms walks from the project up to the root and ORs the viewer's
(non-removed) membership grants at each level, so a grant on an ancestor applies
to every descendant — the same shape as the Django can_write chain it ports.
The object-specific special cases compose on top: a protocol is editable by its
secretary or a project/series editor (canEditProtocol = isSecretary.or(protocolEditorPerm)),
a point by its assignee or a project editor.
Predicate → Drizzle filter (the key idea)
A boolean check(viewer, object) answers "may this viewer touch this row?"
— but a list page needs "which rows may this viewer see at all?". Looping
over every row and calling check would mean fetching rows you'll throw away
and N round-trips.
The Drizzle adapter (@tikab-interactive/typescript-rules/drizzle) closes that
gap. A drizzlePredicate carries both an in-memory check and a
where(viewer) that returns a Drizzle SQL expression — so the same rule
compiles to a SQL WHERE and the database returns only the visible rows:
const usersView = drizzlePredicate<AuthUser, TargetUser, typeof user>("users.view", {
table: user,
check: (viewer, target) => viewer.isSiteAdmin || viewer.id === target.id,
where: (viewer) => (viewer.isSiteAdmin ? sql`true` : eq(user.id, viewer.id)),
});
/** Registry of row-level list filters — the Drizzle counterpart of `permissions`. */
export const userFilters = new DrizzleRegistry<AuthUser>().add("users.view", usersView);
/** The Drizzle `SQL` scoping which user rows `viewer` may list. */
export function visibleUsersWhere(viewer: AuthUser): SQL {
return userFilters.where("users.view", viewer);
}Read check and where as two encodings of one rule: "an admin sees
everyone, everyone else sees only themselves." check decides it for a single
known target in memory; where pushes the same decision into the query. The
adapter's .and() / .or() / .not() compose at the SQL level (mapping to
drizzle-orm's and() / or() / not()), and it refuses to compose predicates
that target different tables — the SQL would otherwise filter against the wrong
columns silently.
This is exactly Django's get_queryset() filtering — except the predicate is
one typed object rather than queryset logic that lives only in the view.
The filters can be as rich as the SQL allows. ProtokollN's downward inheritance
becomes a recursive CTE that every filter reuses — accessibleProjectIds(viewer)
is "the viewer's memberships ∪ all their descendants", and "is this protocol
visible?" is then a membership test against that set
(pulsn-apps/src/protokolln/filters.ts):
/** Project ids `viewerId` may reach: their memberships plus every descendant. */
function accessibleProjectIds(viewerId: string): SQL {
return sql`(
WITH RECURSIVE accessible AS (
SELECT m.project_id AS id
FROM ${protokollProjectMember} m
WHERE m.user_id = ${viewerId} AND m.removed = false
UNION
SELECT p.id
FROM ${protokollProject} p
JOIN accessible a ON p.parent_id = a.id
)
SELECT id FROM accessible
)`;
}Note the check here delegates back to getProjectPermissions — so the
object-level answer stays authoritative and the where is the set-level
shortcut, the two never drift.
A real list-then-recheck server function
Put it together and a list server-fn is small: the SQL filter does the
visibility, and the loop only attaches the per-row capability. From
pulsn-apps/src/processn/index.ts:
export async function listProcessnProjects(
ctx: AppCtx,
viewerId: string,
): Promise<ProcessnProjectRow[]> {
const { db } = ctx;
const filters = makeProcessnFilters(db);
// Visibility is filtered in SQL; the loop only attaches per-row canChange.
const projects = await db
.select()
.from(processnProject)
.where(filters.visibleProjectsWhere({ id: viewerId }))
.orderBy(processnProject.name);
const rows: ProcessnProjectRow[] = [];
for (const p of projects) {
const perms = await getProjectPermissions(db, viewerId, p.id);
rows.push({
id: p.id,
name: p.name,
description: p.description,
canChange: perms.canChange,
});
}
return rows;
}The viewer never receives a project they can't see — not "fetched and hidden,"
not fetched. The per-row canChange it does ship is what the client uses
to gate the Edit button (next section). ProtokollN's equivalents
(listProtokollnProtocols, listProtokollnPoints in
pulsn-apps/src/protokolln/index.ts) follow the identical shape, carrying
canEdit instead.
Server + client parity
The list ships a per-row capability flag; the client renders against it and
never offers an action the server would reject. ResourceTablePage
(example/src/components/ResourceTablePage.tsx) is the shared shell for every
gated table — it takes a canEdit: (row) => boolean and turns it into an access
badge plus a gated button:
// [!include ~/snippets/example/src/components/ResourceTablePage.tsx:accessAndEditColumns]These two entries are appended to the page's own data columns inside a useMemo. The
access badge is always shown; the Edit button is spread in only when the table isn't
readOnly (the cross-server Flatbox views keep the badge but drop the button). The
route just maps the server's row flag straight in —
canEdit={(r) => r.canEdit} in
example/src/routes/_authed.project.$projectKey.protokolln.protocols.tsx — so
the badge and the button reflect the same getProtocolPermissions result
that gates the write. There is no second client-side rule to drift.
For site-level, object-free permissions the client checks the registry
directly through usePermission (from @tikab-interactive/typescript-rules/react),
so the Admin nav item and the /admin route guard can never disagree. In
example/src/routes/_authed.tsx:
const { allowed: canAdminister } = usePermission(permissions, user, "site.administer", null);The Admin menu item only renders when canAdminister is true. usePermission runs the
registry's async check in a useEffect and returns
{ allowed, loading }; the item appears once the predicate resolves. It checks
the very same permissions registry the server guard does — one definition of
"what site-admin means," read from both sides.
The shape: list (filtered) → mutate (re-check)
Every resource follows the same two-step contract:
- List — the query is scoped by the
wherefilter, so it returns only viewable rows (and a per-row capability flag for the UI). - Mutate — the write server-fn re-resolves the object's permissions and
re-checks before touching the database, regardless of what the list
filtered. From
pulsn-apps/src/processn/index.ts:
export async function updateProcessnProject(
ctx: AppCtx,
viewerId: string,
input: UpdateProcessnProjectInput,
): Promise<{ ok: true }> {
const perms = await getProjectPermissions(ctx.db, viewerId, input.id);
if (!perms.canChange) throw new PermissionError("You can't edit this project.");
await ctx.db
.update(processnProject)
.set({ name: input.name, description: input.description, updatedAt: new Date() })
.where(eq(processnProject.id, input.id));
return { ok: true as const };
}Why re-check if the list was already filtered? Because the two checks defend different things, and a mutation never trusts a prior read:
- A mutation takes an
idfrom the client — it can be guessed or replayed, and it bypasses the list entirely. The list filter never ran for that id. - The client UI flag (
canChange) is advisory — it greys out a button, but a hostile caller hits the server-fn directly. The 403 is the real boundary. - State can have changed between the list render and the write (membership revoked, project moved in the tree). The re-check reads current grants.
This is the same instinct as a Django view that filters with get_queryset()
and still calls get_object_or_404(self.get_queryset(), pk=…) /
has_perm before saving — the queryset shapes what's offered, the per-object
check guards what's done. Here both steps are driven by the same predicate
(getProjectPermissions), so "filter" and "guard" can't encode different
policies.
Two layers, two bundles
1. Isomorphic checks (/core) — the Registry of named predicates the
whole app checks against, on both the server and the client. No database import;
safe in the browser bundle.
2. Row-level list filters (/drizzle) — the DrizzleRegistry whose
where(viewer) compiles to SQL. Keep these two separate: importing the
Drizzle filters pulls in drizzle-orm and the schema, which must stay out of
the client bundle — hence the *-server / *-filters split, the same as
everywhere else. The -filters.ts and -server.ts files are server-only; the
isomorphic checks (example/src/lib/permissions.ts,
example/src/permissions/*.ts) carry no db import and ride along to the
client.
In the example
- Admin — the nav item, the
/adminroute guard, and the admin server fns all checksite.administer. The item only appears onceusePermissionresolves. - Directory (
/directory) — open to every signed-in user, but scoped server-side by theusers.viewfilter (listVisibleUsersinexample/src/lib/directory-server.ts): a site admin lists everyone, anyone else lists only their own row. The row scoping is the access control — there is no role gate on the page at all. - ProcessN / ProtokollN (
/project/$key/…) — the full list-then-recheck shape: SQL-filtered lists, per-rowcanEdit/canChange, and 403-guarded updates, exercising membership grants, downward inheritance, and the secretary / assignee special cases.
isSiteAdmin lives on the user row, not in the Better Auth session, so
fetchSession reads it off the user and puts it on the route context for both the
guards and the nav.