角色与权限
ShipKit 如何决定谁能在管理后台做什么:权限、角色、内置的 admin,以及怎样给新页面和 server function 加上权限检查。
管理后台的访问控制基于角色。权限是一个点分名称,比如 users.read、billing.write;角色是一组权限的命名集合。每个用户最多持有一个角色,角色名存在 user.role 这一列里——这一列本来就由 better-auth 的 admin 插件维护。
代码里从不问“这个人是不是管理员”,而是问“这个人有没有 billing.read”。所有关键位置问的都是同一个问题:
| 位置 | 写法 | 决定什么 |
|---|---|---|
| 侧边栏 | adminNav 里该行的 permission | 是否显示这个标签页 |
| 路由 | beforeLoad 里调用 requirePermission(queryClient, '<perm>') | 页面是否渲染 |
| Server function | await assertPermission(context.session, '<perm>') | 调用是否执行 |
| Admin API | adminApi(handler, '<perm>') | 这把密钥能否拿到数据 |
路由守卫在浏览器里也会运行,它只是一种便利,避免页面渲染到一半才报错。真正的边界是 server function 和 API 的检查,它们每次调用都会重新校验。
内置角色
有两个角色写在代码里,而不在数据库中:
admin持有*,匹配所有权限。它会出现在角色页面上,但不能编辑或删除。- 无角色(该列为空,或者 better-auth 写入的普通值
user)什么权限都没有。这样的用户可以正常使用应用,但进不了管理后台。
之所以内置,是为了保证无论在角色页面上怎么改,都不会出现“没有任何人能重新进来”的数据库。介于两者之间的角色都是 role 表(src/core/authz/schema.ts)里的一行。如果用户指向的角色名已经不存在,就什么权限也不给——被删掉的角色按“关闭”处理,而不是放行。
第一个管理员需要在登录后手动改自己的那一行,见快速开始。
创建角色
打开 管理后台 → 设置 → 角色权限(/admin/roles)。编辑器把所有已声明的权限按功能分组,列成复选框。保存走的是 src/core/authz/fns.ts 里的saveRoleAdminFn,它会检查几条规则:
- 名称是标识符:小写字母开头,后面是字母、数字、
_或-,最长 40 个字符。admin是保留名。 - 每一项授权都必须是某个功能声明过的权限。拼写错误会被拒绝,而不是留在表里看起来像是给了权限。
- 拒绝
*。完全访问只属于内置的admin角色。 - 还有用户持有的角色不能删除。
保存和删除都会写入审计日志,动作分别是 roles.saved 和 roles.deleted,条目里带着授权列表。
任何需要进入后台的角色都必须有 admin.access:这是底线,每个后台页面和每个admin server function 都要先检查它,再检查更细的权限。一个只读的客服角色可以是 admin.access、users.read 加 billing.read。
请把 roles.write 视同完全访问:能编辑角色的人,就能给自己任何权限。
通配符
授权可以用 .* 结尾,覆盖一整个领域:billing.* 同时覆盖 billing.read和 billing.write。通配符只能是末尾的一整段,所以 use* 什么都匹配不到(src/core/authz/match.ts)。匹配器和保存校验都接受通配符,但编辑器本身只提供单个权限的复选框。公开演示的 demo 角色就是一个在代码里构造的例子:admin.access 加上所有 *.read 权限,直接从注册表推导出来(src/features/demo/config.ts)。
分配角色
在任意后台表格里点开一个用户(会打开用户抽屉),在 角色 旁边的下拉框里选择。可选项是内置的 admin 加上角色页面上的每一行。这次修改会以admin.role_set 写入审计日志。
有一个限制:修改角色和封禁用户走的是 better-auth admin 插件的接口,而这个插件在默认配置下只允许内置的 admin 角色调用。一个持有 users.write 的自定义角色能看到这些控件,但修改会被拒绝。要么把改角色和封禁留给 admin,要么在src/features/auth/server/auth.ts 里配置插件自己的访问控制来下放这两项。
检查权限
后台路由:
export const Route = createFileRoute('/_app/admin/reports')({
beforeLoad: async ({ context }) => {
await requirePermission(context.queryClient, 'reports.read')
},
component: ReportsPage,
})
Server function 从 adminFn 开始(它已经要求 admin.access),再在 handler里收窄:
import { assertPermission } from '@/core/authz/assert'
import { adminFn } from '@/core/server/fn'
export const listReportsAdminFn = adminFn.handler(async ({ context }) => {
await assertPermission(context.session, 'reports.read')
// …查询并返回
})
两者都会重定向到 /dashboard,而不是显示 403:角色被改过的人应该落在一个还能用的页面上。Admin API 则会返回真正的 403,见 API 密钥。
组件里用 src/core/authz/queries.ts 的 useCan('<perm>') 决定是否显示某个控件,加载期间返回 false。它只用来隐藏按钮,不能当作唯一的检查。
有两件事不要做。不要在 src/core/server/fn.ts 里放会访问数据库的普通 helper:这个文件也会被打包进客户端,而构建只会剥离 .server() 回调里引用的东西——这正是 assertPermission 放在 src/core/authz/assert.ts 的原因。也不要把createServerFn 包进 permissionFn(perm) 这样的工厂函数:构建看不穿这层调用,会把整个模块打进浏览器。
新增权限
权限由执行它的功能来声明,写在一个客户端安全的src/features/<name>/permissions.ts 里:
import { registerPermission } from '@/core/authz/events'
import { m } from '@/paraglide/messages'
registerPermission({
key: 'reports.read',
group: 'reports',
groupLabel: () => m.admin_reports_title(),
label: () => m.perm_read(),
})
然后在 src/core/authz/handlers.ts 里加一行 import。角色编辑器会自动出现这个权限,不需要其他改动;删除这个功能时删掉这一行,它的权限也就从编辑器里消失了。
声明的规则:key 是小写的 <area>.<action>,不能含 *;文件不能引入任何仅限服务端的东西(不能有 ./server/,不能有 @/core/db),因为角色编辑器在浏览器里渲染这份注册表。惯例是读用 <area>.read,写用 <area>.write。
核心权限无论保留哪些功能都存在,声明在 src/core/authz/permissions.ts:admin.access、users.read、users.write、roles.write、settings.read、settings.write、api_keys.manage 和 email.read。
修改最多一分钟生效
每个受保护的请求都要读取角色的授权,所以它们在每个 Worker isolate 里缓存60 秒(src/core/authz/store.ts)。改了角色的授权后,发起修改的 isolate立即生效,其他 isolate 最多一分钟内生效。用户的角色名本身是随会话每次重新读取的,所以把某人改成无角色或封禁他,会立即生效;会滞后的只是“某个角色授予了什么”这件事。
由测试来守住规则
src/core/authz/registry.test.ts 随 bun run test 运行,出现以下情况就会失败:
src/routes/_app/admin/下的路由文件没有调用requirePermission(,或者还留着role !== 'admin'这样的检查;src/里任何一个export const … = adminFn没有调用await assertPermission(context.session, …);- 某个
features/*/permissions.ts没有登记在src/core/authz/handlers.ts,或者引入了仅限服务端的东西; - 某个 key 格式不对、重复或没有本地化;
src/core/server/fn.ts导出了普通函数,或在.server()回调之外访问了数据库。
无论是人还是 AI agent 犯的常见错误,都会在上线前被拦下。要端到端地新增一个后台页面,见管理后台。