Role-Based Access Control

Enterprise-grade RBAC with type-safe permissions using Drizzle and tRPC. Flexible team-based authorization that scales to enterprise.

Nuxflare Pro comes with a a type-safe RBAC system using nuxt-authorization + Drizzle + 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

  • 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

Nuxflare Role Based Access Control Screenshot

Here's how roles and permissions are structured in Drizzle:

export const teamMemberships = sqliteTable(
  "TeamMembership",
  {
    id: text("id")
      .$default(() => cuid())
      .primaryKey()
      .notNull(),
    teamId: text("teamId").notNull(),
    userId: text("userId").notNull(),
    roleId: text("roleId")
      .notNull()
      .references(() => roles.id),
    createdAt: text("created_at")
      .notNull()
      .default(sql`(CURRENT_TIMESTAMP)`),
    updatedAt: text("updated_at")
      .notNull()
      .default(sql`(CURRENT_TIMESTAMP)`)
      .$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
  },
  (table) => [unique().on(table.teamId, table.userId)],
);

export const teamMembershipsRelations = relations(
  teamMemberships,
  ({ one }) => ({
    role: one(roles, {
      fields: [teamMemberships.roleId],
      references: [roles.id],
    }),
    user: one(users, {
      fields: [teamMemberships.userId],
      references: [users.id],
    }),
    team: one(teams, {
      fields: [teamMemberships.teamId],
      references: [teams.id],
    }),
  }),
);

export const permissions = sqliteTable(
  "Permission",
  {
    title: text("title").notNull(),
    description: text("description"),
    action: text("action").notNull(),
    roleId: text("roleId")
      .notNull()
      .references(() => roles.id),
    createdAt: text("created_at")
      .notNull()
      .default(sql`(CURRENT_TIMESTAMP)`),
    updatedAt: text("updated_at")
      .notNull()
      .default(sql`(CURRENT_TIMESTAMP)`)
      .$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
  },
  (table) => [primaryKey({ columns: [table.action, table.roleId] })],
);

export const roles = sqliteTable("Role", {
  id: text("id")
    .$default(() => cuid())
    .primaryKey()
    .notNull(),
  teamId: text("teamId"),
  name: text("name").notNull(),
  description: text("description"),
  isSystemRole: integer("isSystemRole", { mode: "boolean" })
    .default(false)
    .notNull(),
  isDefault: integer("isDefault", { mode: "boolean" }).default(false).notNull(),
  createdAt: text("created_at")
    .notNull()
    .default(sql`(CURRENT_TIMESTAMP)`),
  updatedAt: text("updated_at")
    .notNull()
    .default(sql`(CURRENT_TIMESTAMP)`)
    .$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
});

export const rolesRelations = relations(roles, ({ many }) => ({
  permissions: many(permissions),
}));

export const permissionsRelations = relations(permissions, ({ one }) => ({
  role: one(roles, {
    fields: [permissions.roleId],
    references: [roles.id],
  }),
}));

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;
    }),
});