Epic Stack 权限体系重构:基于 action:entity:access 的 RBAC 模型实战解析
2026/9/18 1:46:33 网站建设 项目流程

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 明确记录了此次权限模型重构的动机:原先的rolepermission模型使用场景非常受限,不是基于任何真实世界场景设计的。旧模型的数据结构非常简单:

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 模型

决策文档指出,业界存在多种权限实现方式,而RBAC(Role-Based Access Control,基于角色的访问控制)是其中一种常见且灵活的方案,因为它是更成熟的体系,更容易找到学习资源来理解。其核心思想是:用户(User)拥有角色(Role),角色拥有权限(Permission),用户的权限是其所有角色权限的并集

新的 Prisma Schema

新模型将权限拆解为actionentityaccess三个维度:

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.prisma:User模型通过roles Role[]关联RoleRole通过permissions Permission[]关联Permission,构成典型的多对多 RBAC 结构。同时Permission上的@@unique([action, entity, access])组合唯一约束,从数据库层面保证了"同一实体、同一操作、同一访问粒度"的权限记录不会重复创建,这也正是后续代码中权限匹配能够精确命中的前提。

权限三要素的含义

字段取值示例含义
actioncreate/read/update/delete允许执行的操作
entityuser/note/post被操作的对象类型
accessownany访问粒度:仅自己的数据,或任意数据

类型安全的权限字符串

为了让权限在代码中可读、可校验,仓库在 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 Array<Access>) : undefined, } }

从源码可以看出,权限字符串支持两种形式:省略accesscreate: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 }

该函数的工作流程值得仔细拆解:

  1. 先通过requireUserId(request)从会话中解析出当前用户 ID;
  2. parsePermissionString'delete:note:own'这样的字符串解析为{ action, entity, access }
  3. 在数据库中一次性查询:用户 → 其任一角色 → 角色的任一权限中,是否存在与actionentity完全匹配,且access命中列表中的记录。当权限字符串省略access时(如delete:note),该条件会被置为undefined,即只匹配actionentity两个维度;
  4. 若查无此人(没有匹配权限),抛出包含status: 403的响应,requiredPermission字段还会携带解析后的权限信息,便于在错误处理中展示"需要什么权限";
  5. 校验通过则返回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.ts):

export function userHasPermission( user: Pick<ReturnType<typeof 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: Pick<ReturnType<typeof useUser>, 'roles'> | null, role: string, ) { if (!user) return false return user.roles.some((r) => r.name === role) }

与服务端实现相比,userHasPermission在内存中完成同样的"实体 + 操作 + 访问粒度"匹配:当access存在时用access.includes(...)判断(因此own,any类型的权限可命中ownany),未指定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' }] }同时关联adminuser两个角色,直观体现了"用户拥有多个角色、权限取并集"的模型。

而 docs/permissions.md 进一步说明:默认开发种子数据创建了usernote两个实体上create/read/update/delete四种操作的细粒度权限,并为useradmin两个角色分配了合理的权限组合。你可以在这些基础权限之上自由组合,支撑不同用户画像的角色体系。

注意:Epic Stack 目前没有提供管理权限的 UI,文档明确建议通过 Prisma Studio 来建立和维护权限与角色的对应关系。生产环境数据库的角色初始化方式可参考 docs/deployment.md 中关于 seed 的说明。

迁移后果与落地要点

决策文档明确标注这是一次破坏性变更(breaking change):任何想采用该权限模型的开发者都需要执行一次数据库迁移。从当前仓库的 prisma/migrations/20250221233640_init/migration.sql 可以看出,Permission表最终以actionentityaccess三列加@@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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询