Trovella Wiki

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:

  1. Start from authorizedProcedure (which provides ctx.ability)
  2. Check the CASL ability for the required action and subject
  3. Throw FORBIDDEN if the check fails
  4. 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:

  1. Scans all .ts files in packages/api/src/routers/ (excluding test files)
  2. Asserts each file imports and uses authorizedProcedure
  3. Fails if any file uses publicProcedure, protectedProcedure, or tenantProcedure at 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:

PropertyTypeDescription
ctx.abilityAppAbilityCASL ability for permission checks
ctx.memberMemberThe caller's membership record
ctx.dbDatabaseTenant-scoped transaction (RLS active)
ctx.aiAIHelperPre-bound AI helper
ctx.loggerLoggerRequest-scoped structured logger
ctx.organizationIdstringActive organization ID
ctx.sessionSessionAuthenticated user session

Writing a New Router

When adding a new router:

  1. Import authorizedProcedure from ../trpc
  2. Start every endpoint with a CASL check for the relevant subject
  3. Add ABAC checks only when the operation requires context about the target resource
  4. Do not filter queries by organization_id -- RLS handles tenant scoping
  5. Run pnpm test --filter @repo/api to verify the architecture test passes

If your router genuinely needs a different procedure level:

  1. Add the file to AUTHORIZED_PROCEDURE_ALLOWLIST in packages/api/src/__tests__/arch.test.ts
  2. Include a clear justification string
  3. Expect this to be flagged in code review

On this page