角色与权限

ShipKit 如何决定谁能在管理后台做什么:权限、角色、内置的 admin,以及怎样给新页面和 server function 加上权限检查。

管理后台的访问控制基于角色。权限是一个点分名称,比如 users.read、billing.write;角色是一组权限的命名集合。每个用户最多持有一个角色,角色名存在 user.role 这一列里——这一列本来就由 better-auth 的 admin 插件维护。

代码里从不问“这个人是不是管理员”,而是问“这个人有没有 billing.read”。所有关键位置问的都是同一个问题:

位置写法决定什么
侧边栏adminNav 里该行的 permission是否显示这个标签页
路由beforeLoad 里调用 requirePermission(queryClient, '<perm>')页面是否渲染
Server functionawait assertPermission(context.session, '<perm>')调用是否执行
Admin APIadminApi(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 犯的常见错误,都会在上线前被拦下。要端到端地新增一个后台页面,见管理后台。