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