Role-Based Access Control

Team-scoped RBAC using nuxt-authorization, Drizzle and tRPC — type-safe from the database to the UI.

Nuxfire's RBAC is built on nuxt-authorization (a Bouncer-style ability system) with Drizzle and tRPC. There is no Prisma anywhere in this stack — every query, in this feature and every other, goes through Drizzle.

What's Included

  • Team-scoped permissions — a role only ever grants access within one team.
  • System roles (isSystemRole) shared across all teams, alongside teams' own custom roles.
  • A default role (isDefault) new members are assigned automatically.
  • Server-side authorization in tRPC, mirrored client-side with a <Can> component.

Schema

layers/teams/server/database/schema.ts
export const roles = pgTable("Role", {
  id: text("id").$default(() => cuid()).primaryKey().notNull(),
  teamId: text("teamId"), // null = system role, shared across every team
  name: text("name").notNull(),
  description: text("description"),
  isSystemRole: boolean("isSystemRole").default(false).notNull(),
  isDefault: boolean("isDefault").default(false).notNull(),
  // ...timestamps
});

export const permissions = pgTable(
  "Permission",
  {
    title: text("title").notNull(),
    description: text("description"),
    action: text("action").notNull(),
    roleId: text("roleId").notNull().references(() => roles.id),
    // ...timestamps
  },
  (table) => [primaryKey({ columns: [table.action, table.roleId] })],
);

export const teamMemberships = pgTable(
  "TeamMembership",
  {
    id: text("id").$default(() => cuid()).primaryKey().notNull(),
    teamId: text("teamId").notNull(),
    userId: text("userId").notNull(),
    roleId: text("roleId").notNull().references(() => roles.id),
    // ...timestamps
  },
  (table) => [unique().on(table.teamId, table.userId)],
);

Defining Abilities

Abilities live in layers/teams/shared/utils/abilities.ts — shared between client and server since it's under shared/, and defined with nuxt-authorization's defineAbility. Every name is prefixed with the layer that owns it (teams*, kanban*): Nuxt auto-imports every layer's shared/utils/ into one flat namespace keyed by export name, so two layers exporting the same bare name would collide silently.

layers/teams/shared/utils/abilities.ts
import { PERMISSIONS } from "./permissions";

const hasTeamPermission = (user: User | null, teamId: string, permission: string) =>
  !!user?.teams?.includes(teamId) &&
  (user?.permissions?.[teamId] || []).includes(permission);

export const teamsUpdateTeamDetails = defineAbility(
  (user: User | null, teamId: string) =>
    hasTeamPermission(user, teamId, PERMISSIONS.TEAMS.UPDATE),
);

export const teamsRemoveMember = defineAbility(
  (user: User | null, teamId: string, memberId?: string) =>
    hasTeamPermission(user, teamId, PERMISSIONS.TEAMS.MEMBERS.REMOVE) &&
    (!memberId || memberId !== user?.id), // can't remove yourself
);

Using in Components

The <Can> component gates rendering the same way the ability gates the request:

<template>
  <Can :ability="sendInviteAbility" :args="[team?.id || '']">
    <UForm :schema="inviteSchema" :state="inviteForm" @submit="sendInvite">
      <!-- invite form -->
    </UForm>
  </Can>
</template>

<script setup lang="ts">
import { teamsSendInvite as sendInviteAbility } from "@nuxfire/layer-teams/shared/utils/abilities";
</script>

API Authorization

abilityProcedure (built on protectedProcedure in apps/app/server/trpc/trpc.ts) adds authorize/allows/denies to the tRPC context, backed by nuxt-authorization's own authorize/allows/denies. authorize throws — a denial becomes a FORBIDDEN TRPCError and is logged (console.warn("authz denied", ...), visible in Cloudflare Workers Logs) before the error reaches the client:

layers/teams/server/trpc/teams.ts
export default router({
  list: abilityProcedure.query(async ({ ctx: { authorize, user } }) => {
    await authorize(teamsListTeams);

    const teams = await useDB().query.teams.findMany({
      with: { memberships: { where: eq(teamMemberships.userId, user.id) } },
      columns: { id: true, name: true, slug: true, description: true, logo: true },
    });

    return teams.filter((team) => team.memberships.length > 0);
  }),
});

This is deliberately a separate procedure from platformAdminProcedure — see Platform Admin Console. Platform-owner authority is never routed through the tenant ability layer, so a tenant-scoped permission can never grant platform-scoped access.

Nuxfire Production Kit

Ready to build and launch your SaaS?

Get 100% full source code ownership, zero proprietary wrappers, and architecture engineered for millions of requests on Cloudflare.

© 2026 Nuxfire