If you're building a multi-tenant SaaS in Nuxt 3, you'll need a robust permissions system.
Here's how I built a type-safe RBAC system that scales from small teams to enterprise, using Prisma and tRPC.
The Stack
- nuxt-authorization for defining abilities
- Prisma for the database layer
- tRPC for type-safe API calls
- Works with any auth provider (sidebase/nuxt-auth in this example)
Basic Setup
First, install the authorization module:
pnpx nuxi@latest module add nuxt-authorization
Client-Side Authorization
Set up a plugin to resolve the user on the client:
export default defineNuxtPlugin({
name: "authorization-resolver",
parallel: true,
setup() {
return {
provide: {
authorization: {
resolveClientUser: () => useAuth().data.value?.user,
},
},
};
},
});
Server-Side Authorization
Similarly for the server:
import { getServerSession } from "#auth";
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook("request", async (event) => {
event.context.$authorization = {
resolveServerUser: async () => {
return (await getServerSession(event))?.user;
},
};
});
});
Defining Type-Safe Abilities
Here's how we define shared abilities that work on both client and server:
interface User {
id: string;
teams?: string[];
permissions?: Record<string, string[]>;
}
const hasTeamPermission = (
user: User | null,
teamId: string,
permission: string,
): boolean =>
!!user?.teams?.includes(teamId) &&
(user?.permissions?.[teamId] || []).includes(permission);
export const listTeams = defineAbility(() => true);
export const getTeamDetails = defineAbility(
(user: User, teamId: string) => !!(teamId && user?.teams?.includes(teamId)),
);
export const updateTeamDetails = defineAbility(
(user: User | null, teamId: string) =>
hasTeamPermission(user, teamId, PERMISSIONS.TEAMS.UPDATE),
);
Database Schema
Your Prisma schema needs to support roles and permissions:
model TeamMembership {
id String @id @default(cuid())
role Role @relation(fields: [roleId], references: [id])
// [...]
}
model Role {
id String @id @default(cuid())
teamId String?
name String
description String?
isDefault Boolean @default(false)
isSystemRole Boolean @default(false)
permissions Permission[]
// [...]
}
model Permission {
id String @id @default(cuid())
title String
description String?
action String
roleId String
// [...]
}
Using Abilities in Components
Check permissions in your Vue components:
<Can :ability="deleteTeamAbility" :args="[team?.id || '']">
<!-- Protected content here -->
</Can>
Type-Safe API Authorization
Create a tRPC procedure for checking abilities:
export const abilityProcedure = protectedProcedure.use(async (opts) => {
const { ctx } = opts;
return opts.next({
ctx: {
...ctx,
allows: async function allow<Ability extends BouncerAbility<any>>(
ability: Ability,
...args: BouncerArgs<Ability>
) {
return await allows(ctx.event, ability, ...args);
},
authorize: async function auth<Ability extends BouncerAbility<any>>(
ability: Ability,
...args: BouncerArgs<Ability>
) {
try {
await authorize(ctx.event, ability, ...args);
} catch (error) {
throw new TRPCError({
code: "FORBIDDEN",
message: error instanceof Error ? error.message : "Not authorized",
});
}
},
},
});
});
Use it in your API routes:
{
get: abilityProcedure
.input(
z.object({
teamIdentifier: z.string(),
}),
)
.query(async ({ ctx: { authorize, user, prisma }, input }) => {
await authorize(getTeamDetails, team.id);
// Protected logic here
}),
}
Why this works well
- Fully type-safe from database to UI
- No external authorization service needed
- Works seamlessly with any auth provider
- Scales from simple to complex permission structures
Try it yourself
Want to see this RBAC system in action? This exact implementation is part of my Nuxt SaaS boilerplate.
If you're building a multi-tenant SaaS, check it outโit comes with everything you need: type-safe APIs using tRPC, team management, authentication, billing, and more. Every feature is built with the same attention to developer experience as this permissions system.
Top comments (0)