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:

~/shared/utils/abilities.ts
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

  1. Type Safety: Full type inference from database to UI
  2. Team Isolation: Data is automatically scoped to teams
  3. Custom Roles: Create and modify roles per team
  4. Performance Optimized: Permissions are cached in the session

Best Practices

  1. Define clear permission boundaries between roles
  2. Use the most restrictive permissions by default
  3. Test authorization logic thoroughly
  4. Keep permission names consistent and meaningful
  5. Document custom roles and their intended use cases

Need Help?

See It in Action

Want to see it in action? Create a team and try managing roles and permissions right here on this site!