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
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.
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:
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.
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.