Skip to content
Fusion

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:

DjangoHere
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 predicatepredicate(name, (user, obj) => …), composed with .and() / .or() / .not()
get_queryset() narrowing a list to what you may seeA 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 guardusePermission(...) / 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:

pulsn-apps/src/processn/permissions.ts
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:

pulsn-apps/src/processn/permissions.ts
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 a ProjectContext can only be added to a registry built for that context, so a point.view rule 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 is can_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

example/src/lib/permissions.ts
/** 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:

pulsn-apps/src/protokolln/permissions.ts
// [!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:

example/src/lib/permissions-filters.ts
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):

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:

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:

example/src/components/ResourceTablePage.tsx
// [!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:

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:

  1. List — the query is scoped by the where filter, so it returns only viewable rows (and a per-row capability flag for the UI).
  2. 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:
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 id from 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.

Loading diagram...

Two layers, two bundles

Loading diagram...

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 /admin route guard, and the admin server fns all check site.administer. The item only appears once usePermission resolves.
  • Directory (/directory) — open to every signed-in user, but scoped server-side by the users.view filter (listVisibleUsers in example/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-row canEdit / 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.