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 (
ownvsany)
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 notesread:note:any- Can read any notedelete: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
ownvsany: Explicitly determine if user is owner before validating permission - ❌ Not using correct helpers: Use
requireUserWithPermissionfor server-side anduserHasPermissionfor 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
PermissionStringtype for type-safety - explicit types - ❌ Hidden permission logic: Don't hide permission checks in utility functions - make them explicit at the call site
References
- Epic Stack Permissions Docs
- Epic Web Principles
- RBAC Explained
app/utils/permissions.server.ts- Server-side permission utilitiesapp/utils/user.ts- Client-side permission utilitiesprisma/schema.prisma- Permission and Role modelsprisma/seed.ts- Permission seed examples