Role-Based Access Control
Enterprise-grade RBAC with type-safe permissions using Prisma and tRPC. Flexible team-based authorization that scales from startups to enterprise.
I've implemented what I believe is the ideal setup: a type-safe RBAC system using nuxt-authorization + Prisma + tRPC.
It gives you fine-grained control over permissions at both team and user levels, while maintaining full type safety throughout your application.
What's Included
The boilerplate comes with a complete RBAC system designed for multi-tenant SaaS:
- Team-based permissions management
- Pre-configured roles (Owner, Admin, Member)
- Custom role creation and dynamic permissions
- Fine-grained permission controls
- Server and client-side authorization
Everything is type-safe from your database all the way to your UI components.
The Basic Setup
Here's how roles and permissions are structured in Prisma:
model TeamMembership {
id String @id @default(cuid())
teamId String
userId String
roleId String
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role Role @relation(fields: [roleId], references: [id])
createdAt DateTime @default(now())
@@unique([teamId, userId])
}
model Permission {
id String @id @default(cuid())
title String
description String?
action String
roleId String
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@unique([action, roleId])
}
model Role {
id String @id @default(cuid())
teamId String?
name String
description String?
isDefault Boolean @default(false)
isSystemRole Boolean @default(false)
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
permissions Permission[]
memberships TeamMembership[]
invites TeamInvite[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([teamId, name])
}
Defining Abilities
Abilities are defined in a type-safe way and shared between client and server:
export const hasTeamPermission = (
user: User | null,
teamId: string,
permission: string,
): boolean =>
!!user?.teams?.includes(teamId) &&
(user?.permissions?.[teamId] || []).includes(permission);
export const updateTeamDetails = defineAbility(
(user: User | null, teamId: string) =>
hasTeamPermission(user, teamId, PERMISSIONS.TEAMS.UPDATE),
);
export const inviteTeamMember = defineAbility(
(user: User | null, teamId: string) =>
hasTeamPermission(user, teamId, PERMISSIONS.TEAMS.INVITE_MEMBER),
);
export const removeTeamMember = defineAbility(
(user: User | null, teamId: string, memberId: string) =>
user?.id !== memberId && // Can't remove yourself
hasTeamPermission(user, teamId, PERMISSIONS.TEAMS.REMOVE_MEMBER),
);
Using in Components
Check permissions in your Vue components using the Can
component:
<template>
<Can :ability="updateTeamDetailsAbility" :args="[team.id]">
<UCard :ui="{ ring: 'ring-2 ring-red-400 dark:ring-red-400' }">
<div class="py-2 text-xl">Delete Team</div>
Once you delete a team, there is no going back. Please be certain.
<template #footer>
<div class="w-full gap-2 flex justify-end">
<UButton color='red' @click="showDeleteConfirm = true">Delete this team</UButton>
</div>
</template>
</UCard>
</Can>
</template>
<script setup lang="ts">
import { updateTeamDetails as updateTeamDetailsAbility } from "#shared/utils/abilities";
</script>
API Authorization
Protect your API routes with type-safe authorization:
export const teamsRouter = createTRPCRouter({
get: abilityProcedure
.input(
z.object({
teamIdentifier: z.string(),
}),
)
.query(async ({ ctx: { authorize, user, prisma }, input }) => {
const team = await prisma.team.findFirst({
// [...]
});
if (!team) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Team not found or you do not have access",
});
}
await authorize(getTeamDetails, team.id);
return team;
}),
});
Cool Features
- Type Safety: Full type inference from database to UI
- Team Isolation: Data is automatically scoped to teams
- Custom Roles: Create and modify roles per team
- Performance Optimized: Permissions are cached in the session
Best Practices
- Define clear permission boundaries between roles
- Use the most restrictive permissions by default
- Test authorization logic thoroughly
- Keep permission names consistent and meaningful
- Document custom roles and their intended use cases
Need Help?
- Check out the nuxt-authorization docs for more features
- Join our Discord Community for support and discussions
- Browse example implementations in the code
See It in Action
Want to see it in action? Create a team and try managing roles and permissions right here on this site!