Epic Stack 权限体系重构基于 action:entity:access 的 RBAC 模型实战解析【免费下载链接】epic-stackThis is a Full Stack app starter with the foundational things setup and configured for you to hit the ground running on your next EPIC idea.项目地址: https://gitcode.com/GitHub_Trending/ep/epic-stack本文围绕 Epic Stack 中的权限决策文档docs/decisions/028-permissions-rbac.md展开详细讲解该全栈启动模板如何将原先简陋的Role/Permission名称模型重构为以action:entity:access三元组为核心的细粒度 RBAC基于角色的访问控制体系。读完本文你将掌握 Epic Stack 权限模型的数据结构设计、权限字符串的解析规则、服务端与客户端两侧的校验工具以及如何在实际路由中落地只能操作自己的数据own与可操作任意数据any两种访问粒度。决策背景为什么旧权限模型不实用在 2023-08-14 被采纳的决策文档中Epic Stack 明确记录了此次权限模型重构的动机原先的role和permission模型使用场景非常受限不是基于任何真实世界场景设计的。旧模型的数据结构非常简单model Role { id String id unique default(cuid()) name String unique createdAt DateTime default(now()) updatedAt DateTime updatedAt users User[] permissions Permission[] } model Permission { id String id unique default(cuid()) name String unique createdAt DateTime default(now()) updatedAt DateTime updatedAt roles Role[] }在这个模型中Permission只有一个name字段权限语义完全依赖字符串命名约定例如 can-delete-note。这种设计的核心问题是无法表达访问粒度无法区分删除自己的笔记和删除任意用户的笔记权限判定逻辑无法统一每个权限的检查都必须依赖自定义命名与硬编码判断可扩展性差新增一个业务实体就要发明一套新的命名规则。决策内容采用标准 RBAC 模型决策文档指出业界存在多种权限实现方式而RBACRole-Based Access Control基于角色的访问控制是其中一种常见且灵活的方案因为它是更成熟的体系更容易找到学习资源来理解。其核心思想是用户User拥有角色Role角色拥有权限Permission用户的权限是其所有角色权限的并集。新的 Prisma Schema新模型将权限拆解为action、entity、access三个维度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() createdAt DateTime default(now()) updatedAt DateTime updatedAt roles Role[] unique([action, entity, access]) } model Role { id String id default(cuid()) name String unique description String default() createdAt DateTime default(now()) updatedAt DateTime updatedAt users User[] permissions Permission[] }这一 Schema 在当前仓库中已经完整落地见 prisma/schema.prismaUser模型通过roles Role[]关联RoleRole通过permissions Permission[]关联Permission构成典型的多对多 RBAC 结构。同时Permission上的unique([action, entity, access])组合唯一约束从数据库层面保证了同一实体、同一操作、同一访问粒度的权限记录不会重复创建这也正是后续代码中权限匹配能够精确命中的前提。权限三要素的含义字段取值示例含义actioncreate/read/update/delete允许执行的操作entityuser/note/post等被操作的对象类型accessown或any访问粒度仅自己的数据或任意数据类型安全的权限字符串为了让权限在代码中可读、可校验仓库在 app/utils/user.ts 中定义了PermissionString类型与解析函数type Action create | read | update | delete type Entity user | note type Access own | any | own,any | any,own export type PermissionString | ${Action}:${Entity} | ${Action}:${Entity}:${Access} export function parsePermissionString(permissionString: PermissionString) { const [action, entity, access] permissionString.split(:) as [ Action, Entity, Access | undefined, ] return { action, entity, access: access ? (access.split(,) as ArrayAccess) : undefined, } }从源码可以看出权限字符串支持两种形式省略access的create:note以及带访问粒度的delete:note:own。解析后access会被拆成数组例如own,any会被解析为[own, any]这为允许自己或任意的复合权限提供了表达空间。常见示例create:note:own— 可以创建自己的笔记read:note:any— 可以读取任意笔记delete:user:any— 可以删除任意用户管理员update:note:own— 只能更新自己的笔记。服务端权限校验requireUserWithPermission 与 requireUserWithRole决策文档指出我们可以创建工具函数用于判断用户是否有权执行某项操作并在其没有权限时拒绝执行。这一承诺在 app/utils/permissions.server.ts 中得到了完整实现。按权限校验export async function requireUserWithPermission( request: Request, permission: PermissionString, ) { const userId await requireUserId(request) const permissionData parsePermissionString(permission) const user await prisma.user.findFirst({ select: { id: true }, where: { id: userId, roles: { some: { permissions: { some: { ...permissionData, access: permissionData.access ? { in: permissionData.access } : undefined, }, }, }, }, }, }) if (!user) { throw data( { error: Unauthorized, requiredPermission: permissionData, message: Unauthorized: required permissions: ${permission}, }, { status: 403 }, ) } return user.id }该函数的工作流程值得仔细拆解先通过requireUserId(request)从会话中解析出当前用户 ID用parsePermissionString把delete:note:own这样的字符串解析为{ action, entity, access }在数据库中一次性查询用户 → 其任一角色 → 角色的任一权限中是否存在与action、entity完全匹配且access命中列表中的记录。当权限字符串省略access时如delete:note该条件会被置为undefined即只匹配action与entity两个维度若查无此人没有匹配权限抛出包含status: 403的响应requiredPermission字段还会携带解析后的权限信息便于在错误处理中展示需要什么权限校验通过则返回userId。按角色校验export async function requireUserWithRole(request: Request, name: string) { const userId await requireUserId(request) const user await prisma.user.findFirst({ select: { id: true }, where: { id: userId, roles: { some: { name } } }, }) if (!user) { throw data( { error: Unauthorized, requiredRole: name, message: Unauthorized: required role: ${name}, }, { status: 403 }, ) } return user.id }requireUserWithRole用于粗粒度的角色门禁例如仅允许admin角色访问管理后台。在仓库中app/routes/admin/cache/index.tsx 等管理路由均通过await requireUserWithRole(request, admin)在 loader 入口处拦截非管理员访问。客户端权限校验userHasPermission 与 userHasRole服务端校验是安全底线但 UI 层往往需要根据权限动态渲染操作按钮。仓库为此提供了纯函数形式的客户端工具见 app/utils/user.tsexport function userHasPermission( user: PickReturnTypetypeof useUser, roles | null | undefined, permission: PermissionString, ) { if (!user) return false const { action, entity, access } parsePermissionString(permission) return user.roles.some((role) role.permissions.some( (permission) permission.entity entity permission.action action (!access || access.includes(permission.access)), ), ) } export function userHasRole( user: PickReturnTypetypeof useUser, roles | null, role: string, ) { if (!user) return false return user.roles.some((r) r.name role) }与服务端实现相比userHasPermission在内存中完成同样的实体 操作 访问粒度匹配当access存在时用access.includes(...)判断因此own,any类型的权限可命中own或any未指定access时则只匹配前两个维度。这两个函数结合useUser/useOptionalUser从 root loader 获取当前用户及其角色即可在组件中控制 UI 呈现。实战案例笔记删除的 own / any 双粒度控制在真实路由 app/routes/users/$username/notes/$noteId.tsx 中可以看到这套 RBAC 体系在仅能删除自己的笔记这一典型需求上的完整落地。服务端 action安全底线export async function action({ request }: Route.ActionArgs) { const userId await requireUserId(request) // ...解析表单、查询笔记... const note await prisma.note.findFirst({ select: { id: true, ownerId: true, owner: { select: { username: true } } }, where: { id: noteId }, }) invariantResponse(note, Not found, { status: 404 }) const isOwner note.ownerId userId await requireUserWithPermission( request, isOwner ? delete:note:own : delete:note:any, ) await prisma.note.delete({ where: { id: note.id } }) // ... }这里的核心模式是先显式判定所有权isOwner再选择对应的权限字符串。普通用户只拥有delete:note:own因此只能删除自己拥有的笔记而拥有delete:note:any的管理员则不受所有权限制。注意即便不是笔记所有者代码也显式执行权限检查而非直接拒绝这样系统可以统一支持管理员代删等场景。客户端渲染UI 控制const user useOptionalUser() const isOwner user?.id loaderData.note.ownerId const canDelete userHasPermission( user, isOwner ? delete:note:own : delete:note:any, ) const displayBar canDelete || isOwner客户端同样基于isOwner选择权限字符串通过userHasPermission决定是否渲染删除工具栏。服务端与客户端使用完全一致的权限语义避免出现按钮可见但请求 403或按钮隐藏但接口可调的割裂。种子数据与角色分配仓库的 prisma/seed.ts 展示了 RBAC 数据的初始化方式普通测试用户创建时通过roles: { connect: { name: user } }关联user角色管理员用户kody通过roles: { connect: [{ name: admin }, { name: user }] }同时关联admin与user两个角色直观体现了用户拥有多个角色、权限取并集的模型。而 docs/permissions.md 进一步说明默认开发种子数据创建了user与note两个实体上create/read/update/delete四种操作的细粒度权限并为user与admin两个角色分配了合理的权限组合。你可以在这些基础权限之上自由组合支撑不同用户画像的角色体系。注意Epic Stack 目前没有提供管理权限的 UI文档明确建议通过 Prisma Studio 来建立和维护权限与角色的对应关系。生产环境数据库的角色初始化方式可参考 docs/deployment.md 中关于 seed 的说明。迁移后果与落地要点决策文档明确标注这是一次破坏性变更breaking change任何想采用该权限模型的开发者都需要执行一次数据库迁移。从当前仓库的 prisma/migrations/20250221233640_init/migration.sql 可以看出Permission表最终以action、entity、access三列加unique([action, entity, access])组合唯一约束的形式存在旧模型中仅靠name区分权限的设计已被彻底替换。在 docs/skills/epic-permissions/SKILL.md 中Epic Stack 还沉淀了以下实践原则可视为本次决策的精神延伸显式优于隐式每个权限检查都应在代码调用点清晰可见不要依赖隐式规则例如看起来他是所有者所以能删服务端校验不可省略action/loader中必须做服务端权限校验绝不能只信任客户端判断先判定所有权再选权限在需要own/any分流时先显式计算isOwner再传入对应的权限字符串正确选用工具服务端用requireUserWithPermission/requireUserWithRole客户端用userHasPermission/userHasRole遵循组合唯一约束保持unique([action, entity, access])避免权限记录语义重叠正确处理 403校验工具抛出的错误需要由路由的ErrorBoundary统一兜底呈现。总结Epic Stack 通过决策文档 docs/decisions/028-permissions-rbac.md 记录了一次从命名式权限到结构化 RBAC的模型演进以action:entity:access三元组表达权限、以unique保证数据唯一性、以类型安全的PermissionString贯穿服务端与客户端校验。这套模型既保留了 RBAC 易于理解和学习的优点又通过own/any的访问粒度解决了能否操作他人数据这一真实业务场景中的核心问题是理解并扩展 Epic Stack 权限能力的基石。相关实现与文档可在 app/utils/permissions.server.ts、app/utils/user.ts、prisma/schema.prisma、docs/permissions.md 与 docs/skills/epic-permissions/SKILL.md 中继续深入研读。【免费下载链接】epic-stackThis is a Full Stack app starter with the foundational things setup and configured for you to hit the ground running on your next EPIC idea.项目地址: https://gitcode.com/GitHub_Trending/ep/epic-stack创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考