agskills.dev
MARKETPLACE

epic-permissions

Guide on RBAC system and permissions for Epic Stack

epicweb-dev5.5k463

Prévia

SKILL.md
Metadata
name
epic-permissions
description
Guide on RBAC system and permissions for Epic Stack

Epic Stack: Permissions

When to use this skill

Use this skill when you need to:

  • Implement role-based access control (RBAC)
  • Validate permissions on server-side or client-side
  • Create new permissions or roles
  • Restrict access to routes or actions
  • Implement granular permissions (own vs any)

Patterns and conventions

Permissions Philosophy

Following Epic Web principles:

Explicit is better than implicit - Always explicitly check permissions. Don't assume a user has access based on implicit rules or hidden logic. Every permission check should be visible and clear in the code.

Example - Explicit permission checks:

// ✅ Good - Explicit permission check export async function action({ request }: Route.ActionArgs) { const userId = await requireUserId(request) // Explicitly check permission - clear and visible await requireUserWithPermission(request, 'delete:note:own') // Permission check is explicit and obvious await prisma.note.delete({ where: { id: noteId } }) } // ❌ Avoid - Implicit permission check export async function action({ request }: Route.ActionArgs) { const userId = await requireUserId(request) const note = await prisma.note.findUnique({ where: { id: noteId } }) // Implicit check - not clear what permission is being checked if (note.ownerId !== userId) { throw new Response('Forbidden', { status: 403 }) } // What permission does this represent? Not explicit }

Example - Explicit permission strings:

// ✅ Good - Explicit permission string const permission: PermissionString = 'delete:note:own' // Clear: action (delete), entity (note), access (own) await requireUserWithPermission(request, permission) // ❌ Avoid - Implicit or unclear permissions const canDelete = checkUserCanDelete(user, note) // What permission is this checking? Not explicit

RBAC Model

Epic Stack uses an RBAC (Role-Based Access Control) model where:

  • Users have Roles
  • Roles have Permissions
  • A user's permissions are the union of all permissions from their roles

Permission Structure

Permissions follow the format: action:entity:access

Components:

  • action: The allowed action (create, read, update, delete)
  • entity: The entity being acted upon (user, note, etc.)
  • access: The access level (own, any, own,any)

Examples:

  • create:note:own - Can create own notes
  • read:note:any - Can read any note
  • delete:user:any - Can delete any user (admin)
  • update:note:own - Can update only own notes

Prisma Schema

Models:

model Permission { id String @id @default(cuid()) action String // e.g. create, read, update, delete entity String // e.g. note, user, etc. access String // e.g. own or any description String @default("") roles Role[] @@unique([action, entity, access]) } model Role { id String @id @default(cuid()) name String @unique description String @default("") users User[] permissions Permission[] } model User { id String @id @default(cuid()) // ... roles Role[] }

Validate Permissions Server-Side

Require specific permission:

import { requireUserWithPermission } from '#app/utils/permissions.server.ts' export async function action({ request }: Route.ActionArgs) { const userId = await requireUserWithPermission( request, 'delete:note:own', // Throws 403 error if doesn't have permission ) // User has the permission, continue... }

Require specific role:

import { requireUserWithRole } from '#app/utils/permissions.server.ts' export async function loader({ request }: Route.LoaderArgs) { const userId = await requireUserWithRole(request, 'admin') // User has admin role, continue... }

Conditional permissions (own vs any) - explicit:

export async function action({ request }: Route.ActionArgs) { const userId = await requireUserId(request) // Explicitly determine ownership const note = await prisma.note.findUnique({ where: { id: noteId }, select: { ownerId: true }, }) const isOwner = note.ownerId === userId // Explicitly check the appropriate permission based on ownership await requireUserWithPermission( request, isOwner ? 'delete:note:own' : 'delete:note:any', // Explicit permission string ) // Permission check is explicit and clear // Proceed with deletion... }

Validate Permissions Client-Side

Check if user has permission:

import { userHasPermission, useOptionalUser } from '#app/utils/user.ts' export default function NoteRoute({ loaderData }: Route.ComponentProps) { const user = useOptionalUser() const isOwner = user?.id === loaderData.note.ownerId const canDelete = userHasPermission( user, isOwner ? 'delete:note:own' : 'delete:note:any', ) return ( <div> {canDelete && ( <button onClick={handleDelete}>Delete</button> )} </div> ) }

Check if user has role:

import { userHasRole } from '#app/utils/user.ts' export default function AdminRoute() { const user = useOptionalUser() const isAdmin = userHasRole(user, 'admin') if (!isAdmin) { return <div>Access Denied</div> } return <div>Admin Panel</div> }

Create New Permissions

En Prisma Studio o seed:

// prisma/seed.ts await prisma.permission.create({ data: { action: 'create', entity: 'post', access: 'own', description: 'Can create their own posts', roles: { connect: { name: 'user' }, }, }, })

Permiso con múltiples niveles de acceso:

await prisma.permission.createMany({ data: [ { action: 'read', entity: 'post', access: 'own', description: 'Can read own posts', }, { action: 'read', entity: 'post', access: 'any', description: 'Can read any post', }, ], })

Assign Roles to Users

When creating user:

const user = await prisma.user.create({ data: { email, username, roles: { connect: { name: 'user' }, // Assign 'user' role }, }, })

Assign multiple roles:

await prisma.user.update({ where: { id: userId }, data: { roles: { connect: [{ name: 'user' }, { name: 'moderator' }], }, }, })

Permissions and Roles Seed

Seed example:

// prisma/seed.ts // Create permissions const permissions = await Promise.all([ // User permissions prisma.permission.create({ data: { action: 'create', entity: 'note', access: 'own', description: 'Can create own notes', }, }), prisma.permission.create({ data: { action: 'read', entity: 'note', access: 'own', description: 'Can read own notes', }, }), prisma.permission.create({ data: { action: 'update', entity: 'note', access: 'own', description: 'Can update own notes', }, }), prisma.permission.create({ data: { action: 'delete', entity: 'note', access: 'own', description: 'Can delete own notes', }, }), // Admin permissions prisma.permission.create({ data: { action: 'delete', entity: 'user', access: 'any', description: 'Can delete any user', }, }), ]) // Create roles const userRole = await prisma.role.create({ data: { name: 'user', description: 'Standard user', permissions: { connect: permissions.slice(0, 4).map((p) => ({ id: p.id })), }, }, }) const adminRole = await prisma.role.create({ data: { name: 'admin', description: 'Administrator', permissions: { connect: permissions.map((p) => ({ id: p.id })), }, }, })

Permission Type

Type-safe permission strings:

import { type PermissionString } from '#app/utils/user.ts' // Tipo: 'create:note:own' | 'read:note:own' | etc. const permission: PermissionString = 'delete:note:own'

Parsear permission string:

import { parsePermissionString } from '#app/utils/user.ts' const { action, entity, access } = parsePermissionString('delete:note:own') // action: 'delete' // entity: 'note' // access: ['own']

Common examples

Example 1: Proteger action con permiso

// app/routes/users/$username/notes/$noteId.tsx export async function action({ request }: Route.ActionArgs) { const userId = await requireUserId(request) const formData = await request.formData() const { noteId } = Object.fromEntries(formData) const note = await prisma.note.findFirst({ select: { id: true, ownerId: true, owner: { select: { username: true } } }, where: { id: noteId }, }) if (!note) { throw new Response('Not found', { status: 404 }) } const isOwner = note.ownerId === userId // Validate permiso según si es propietario o no await requireUserWithPermission( request, isOwner ? 'delete:note:own' : 'delete:note:any', ) await prisma.note.delete({ where: { id: note.id } }) return redirect(`/users/${note.owner.username}/notes`) }

Example 2: Mostrar UI condicional basada en permisos

export default function NoteRoute({ loaderData }: Route.ComponentProps) { const user = useOptionalUser() const isOwner = user?.id === loaderData.note.ownerId const canDelete = userHasPermission( user, isOwner ? 'delete:note:own' : 'delete:note:any', ) const canEdit = userHasPermission( user, isOwner ? 'update:note:own' : 'update:note:any', ) return ( <div> <h1>{loaderData.note.title}</h1> <p>{loaderData.note.content}</p> {(canEdit || canDelete) && ( <div className="flex gap-2"> {canEdit && ( <Link to="edit"> <Button>Edit</Button> </Link> )} {canDelete && ( <DeleteNoteButton noteId={loaderData.note.id} /> )} </div> )} </div> ) }

Example 3: Ruta solo para admin

// app/routes/admin/users.tsx export async function loader({ request }: Route.LoaderArgs) { await requireUserWithRole(request, 'admin') const users = await prisma.user.findMany({ select: { id: true, email: true, username: true, }, }) return { users } } export default function AdminUsersRoute({ loaderData }: Route.ComponentProps) { return ( <div> <h1>All Users</h1> {loaderData.users.map(user => ( <div key={user.id}>{user.username}</div> ))} </div> ) }

Example 4: Create new permission and assign it

// Migración o seed async function setupPostPermissions() { // Create post permissions const createOwn = await prisma.permission.create({ data: { action: 'create', entity: 'post', access: 'own', description: 'Can create own posts', }, }) const readAny = await prisma.permission.create({ data: { action: 'read', entity: 'post', access: 'any', description: 'Can read any post', }, }) // Assign to user role await prisma.role.update({ where: { name: 'user' }, data: { permissions: { connect: [{ id: createOwn.id }, { id: readAny.id }], }, }, }) }

Common mistakes to avoid

  • Implicit permission checks: Always explicitly check permissions - make permission requirements visible in code
  • Not validating permissions on server-side: Always validate permissions in action/loader, never trust client-side only
  • Forgetting to verify own vs any: Explicitly determine if user is owner before validating permission
  • Not using correct helpers: Use requireUserWithPermission for server-side and userHasPermission for client-side - explicit helpers
  • Not creating unique permissions: Use @@unique([action, entity, access]) in schema - explicit permission structure
  • Assuming permissions instead of verifying: Always verify explicitly, even if you think user has the permission
  • Not handling 403 errors: Helpers throw errors that must be handled by ErrorBoundary
  • Not using types: Use PermissionString type for type-safety - explicit types
  • Hidden permission logic: Don't hide permission checks in utility functions - make them explicit at the call site

References