Router Enforcement
How tRPC routers use ctx.ability for RBAC checks, how ABAC conditions are handled, and the architecture test that enforces authorizedProcedure usage.
This page covers how authorization is enforced in practice -- the patterns router handlers follow and the automated checks that prevent mistakes.
The Standard Pattern
Every feature router handler follows the same pattern:
- Start from
authorizedProcedure(which providesctx.ability) - Check the CASL ability for the required action and subject
- Throw
FORBIDDENif the check fails - Proceed with the business logic using
ctx.db(which is already RLS-scoped)
export const organizationRouter = router({
detail: authorizedProcedure.query(async ({ ctx }) => {
if (ctx.ability.cannot("read", "Organization")) {
throw new TRPCError({ code: "FORBIDDEN" });
}
const org = await ctx.db.query.organization.findFirst({
where: eq(organization.id, ctx.organizationId),
});
if (!org) {
throw new TRPCError({ code: "NOT_FOUND", message: "Organization not found" });
}
return org;
}),
});
The CASL check and the RLS-scoped query are independent layers. Even if the CASL check were removed, RLS would still prevent cross-tenant data access. Even if RLS were somehow bypassed, CASL would still block unauthorized actions within the tenant.
RBAC Checks (CASL)
RBAC checks use ctx.ability.can() or ctx.ability.cannot():
// Guard pattern — reject early
if (ctx.ability.cannot("update", "Member")) {
throw new TRPCError({ code: "FORBIDDEN" });
}
// Conditional pattern — branch on permission
if (ctx.ability.can("manage", "Invitation")) {
// admin+ path
} else if (ctx.ability.can("read", "Invitation")) {
// member path (read-only)
}
These checks are resolved entirely from the in-memory CASL ability object. No database queries are made during the check itself -- the role was already loaded when authorizedProcedure built the ability.
ABAC Checks (Imperative)
Some operations require context that CASL does not have -- for example, the role of the target member, or whether the caller is acting on their own record. These are handled as explicit if statements after the CASL check:
// From member.updateRole — prevents demoting owners
updateRole: authorizedProcedure
.input(updateMemberRoleSchema)
.mutation(async ({ ctx, input }) => {
const target = await ctx.db.query.member.findFirst({
where: eq(member.id, input.memberId),
});
if (!target) {
throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" });
}
// RBAC check: does the caller have permission to update members?
if (ctx.ability.cannot("update", "Member")) {
throw new TRPCError({ code: "FORBIDDEN" });
}
// ABAC check: owners cannot be demoted regardless of caller's role
if (target.role === "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Cannot change an owner's role",
});
}
// ... proceed with update
}),
// From member.remove — members can remove themselves
remove: authorizedProcedure
.input(removeMemberSchema)
.mutation(async ({ ctx, input }) => {
const target = await ctx.db.query.member.findFirst({
where: eq(member.id, input.memberId),
});
if (!target) {
throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" });
}
// ABAC check: members can always leave, others need delete permission
const isSelf = target.userId === ctx.session.user.id;
if (ctx.ability.cannot("delete", "Member") && !isSelf) {
throw new TRPCError({ code: "FORBIDDEN" });
}
// ABAC check: owners cannot be removed
if (target.role === "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Cannot remove the organization owner",
});
}
// ... proceed with removal
}),
These ABAC conditions are intentionally not encoded in CASL. See CASL Decision for the rationale.
The Architecture Test
A Vitest architecture test in packages/api/src/__tests__/arch.test.ts enforces that all router files use authorizedProcedure. The test:
- Scans all
.tsfiles inpackages/api/src/routers/(excluding test files) - Asserts each file imports and uses
authorizedProcedure - Fails if any file uses
publicProcedure,protectedProcedure, ortenantProcedureat the call site
Files that intentionally use a lower-privilege procedure must appear in AUTHORIZED_PROCEDURE_ALLOWLIST with a string justification:
const AUTHORIZED_PROCEDURE_ALLOWLIST: Record<string, string> = {
"pat.ts": "PAT management is user-level, not org-scoped — uses protectedProcedure",
"index.ts": "Barrel re-export file",
"ai-logs-helpers.ts": "Pure helper functions, no procedure definitions",
};
This test runs in CI alongside unit tests. The allowlist itself is validated to ensure it is non-empty (prevents accidental clearing).
Allowlist Review
Adding entries to the allowlist is a manual review trigger. The AI review checklist (docs/arch-review-checklist.md) specifically calls out allowlist modifications as requiring human verification. An AI agent could add an entry to silence the test rather than fixing the actual issue.
authorizedProcedure Internals
For a detailed walkthrough of how authorizedProcedure builds the context (member lookup, ability construction, AI helper creation), see Data & Storage -- Procedure Chain. The key point for router authors is that after authorizedProcedure, the following are available on ctx:
| Property | Type | Description |
|---|---|---|
ctx.ability | AppAbility | CASL ability for permission checks |
ctx.member | Member | The caller's membership record |
ctx.db | Database | Tenant-scoped transaction (RLS active) |
ctx.ai | AIHelper | Pre-bound AI helper |
ctx.logger | Logger | Request-scoped structured logger |
ctx.organizationId | string | Active organization ID |
ctx.session | Session | Authenticated user session |
Writing a New Router
When adding a new router:
- Import
authorizedProcedurefrom../trpc - Start every endpoint with a CASL check for the relevant subject
- Add ABAC checks only when the operation requires context about the target resource
- Do not filter queries by
organization_id-- RLS handles tenant scoping - Run
pnpm test --filter @repo/apito verify the architecture test passes
If your router genuinely needs a different procedure level:
- Add the file to
AUTHORIZED_PROCEDURE_ALLOWLISTinpackages/api/src/__tests__/arch.test.ts - Include a clear justification string
- Expect this to be flagged in code review