Skip to content

Permission

@vafast/permission:在路由上声明 permission,通过角色表展开 grants,在 handler 之前做 RBAC 校验。

它解决的是「这个身份能不能调用这个接口」,而不是「这个请求是谁」(那是 @vafast/auth-middleware 的事)。

设计对齐 Webhook:路由扩展字段、路径推导 key、从 RouteRegistry 收集目录。与具体组织 / 租户模型解耦。

和 Webhook 挂载的差别

WebhookPermission
时机next() 之后异步发事件handler 之前拦截
挂法可以 server.use(webhook(...))必须在认证后:middleware: [authWithApp, orgPermission]

不要 server.use(orgPermission):全局中间件早于路由 auth,读不到 userInfo / app

先搞清几个概念(给新用户)

Permission key 是什么?

一次权限校验对应一个 权限键(如 billing.points.adjust)。建议形态:

text
{domain}.{module}.{action}

与 webhook 的 eventKey 一样:默认从路径自动生成,也可显式覆盖。

路径pathPrefix自动 key
/billing/points/adjust(无)billing.points.adjust
/restfulApi/auth/signIn/restfulApiauth.signIn

Grant(持有) 侧可带通配:

Grant能匹配的需求
billing.points.adjust仅自身
billing.points.*billing.points 及其所有下级
billing.*billing 及其所有下级
*一切

角色、grants、路由需求如何串起来?

text
认证(authWithApp)
  → getRole 得到角色(owner / admin / …)
  → roles 表展开成 grants
  → 与路由 permission 比对
  → 放行或 401 / 403

业务侧封装一次 createPermissionMiddleware;需要管控的路由写 permission: true。未声明则只认证、不查权限。

推荐分层(长期固定)

职责写法
认证你是谁、哪个 appauthWithApp
硬授权整个接口进不进门路由 permission + 中间件
业务规则条件字段 / 数据范围handler / assert 辅助函数

硬授权的 grants 可以来自多处,在同一个中间件合并:

ts
createPermissionMiddleware({
  roles: defineRoles({ owner: ['*'], admin: ['*'], member: [] }),
  getRole,           // 组织角色 → 角色表
  getExtraGrants,    // 平台/应用权限串,与角色并集(OR)
})
场景怎么做
管理接口必须组织 adminpermission: true(角色表给 *
组织 admin 平台权限同上 + getExtraGrants;或显式 permission: 'platform_xxx'
仅当 body 某字段才要权限(如 accessScope=all_apps不要整路由挂 permission;handler 里用与中间件同一套 grants 判断
能进门但数据范围不同不写 permission;handler 软过滤

失败码:401 vs 403

场景状态码
无角色且无额外 grants(无主体)401
有主体但 grants 不覆盖需求403

安装

bash
npm install @vafast/permission

快速开始

typescript
import { Server, defineRoute, defineRoutes, serve } from 'vafast'
import {
  createPermissionMiddleware,
  defineRoles,
} from '@vafast/permission'

/** 业务封装一次;挂在认证之后的路由组上 */
export const orgPermission = createPermissionMiddleware({
  // 有统一 API 前缀时写,对齐 webhook.pathPrefix
  pathPrefix: '/api',
  roles: defineRoles({
    owner: ['*'],
    admin: ['billing.*', 'users.*'],
    finance: ['billing.points.*'],
    member: ['billing.points.read'],
  }),
  // 示例用请求头;生产改为查组织角色 / JWT claims / DB
  getRole: (req) => req.headers.get('x-role'),
  // 默认不缓存。需要时可开:
  // cache: { cacheKey: cacheKeyFromUserAndApp, ttlMs: 60_000 },
})

const routes = defineRoutes([
  defineRoute({
    path: '/api',
    // 生产:middleware: [authWithApp, orgPermission]
    middleware: [orgPermission],
    children: [
      defineRoute({
        method: 'GET',
        path: '/billing/points/read',
        name: '积分余额',
        permission: true, // → billing.points.read
        handler: () => ({ balance: 100 }),
      }),
      defineRoute({
        method: 'POST',
        path: '/billing/points/adjust',
        name: '调整积分',
        permission: true, // → billing.points.adjust
        handler: () => ({ ok: true }),
      }),
    ],
  }),
])

const server = new Server(routes)
serve({ fetch: server.fetch, port: 3000 })
bash
curl -H 'x-role: finance' http://localhost:3000/api/billing/points/read
curl -H 'x-role: member' -X POST http://localhost:3000/api/billing/points/adjust
# member → 403

用法

路由字段

typescript
permission: true                      // 推荐:路径推导
permission: {}                        // 同 true
permission: 'billing.points.adjust'   // 显式单 key
permission: { key: 'billing.points.adjust' }

permission: {
  anyOf: ['billing.points.adjust', 'billing.points.batchAdjust'],
}

permission: {
  allOf: ['billing.orders.read', 'billing.orders.refund'],
}

anyOf / allOf / 显式 key 较少用;日常管理接口写 permission: true 即可。

与 Auth Middleware 组合

typescript
import { authWithApp } from '@vafast/auth-middleware'
import {
  createPermissionMiddleware,
  defineRoles,
  cacheKeyFromUserAndApp,
} from '@vafast/permission'

export const orgPermission = createPermissionMiddleware({
  pathPrefix: '/billingRestfulApi',
  roles: defineRoles({
    owner: ['*'],
    admin: ['*'],
    member: [],
  }),
  async getRole(req) {
    const locals = (req as {
      __locals?: { userInfo?: { id: string }; app?: { id: string } }
    }).__locals
    if (!locals?.userInfo?.id || !locals?.app?.id) return null
    const { role } = await getOrgRole(locals.userInfo.id, locals.app.id)
    return role
  },
})

defineRoute({
  path: '/billingRestfulApi/refund',
  middleware: [authWithApp, orgPermission],
  children: [
    defineRoute({
      method: 'POST',
      path: '/approve',
      permission: true, // → refund.approve
      handler: () => ({ ok: true }),
    }),
  ],
})

组织 / 租户只需在 getRole 里调自己的用户中心,不必把组织概念写进本包。

复合权限(组织角色 OR 平台权限)

typescript
export const orgPermission = createPermissionMiddleware({
  pathPrefix: '/onesRestfulApi',
  roles: defineRoles({
    owner: ['*'],
    admin: ['*'],
    member: [],
    app_member: [],
    none: [], // 已认证无组织 → 403(不是 401)
  }),
  async getRole(req) { /* getOrgRole → owner/admin/none */ },
  async getExtraGrants(req, role) {
    if (role === 'owner' || role === 'admin') return []
    // 查 auth-server checkPermission
    if (await hasPlatformManage(req)) {
      // 目录写服务可再并入路径通配,使平台管理员能过 permission: true
      // 例如 billing: businessLine.* / ai: modelCatalog.*
      return ['platform_resource_access:manage', 'modelCatalog.*']
    }
    return []
  },
})

// 整路由硬拦截:
permission: 'platform_resource_access:manage'
// owner/admin 有 * 也能过;仅有平台权限的人靠 getExtraGrants

条件场景(例如只有 accessScope === 'all_apps' 才校验)请在 handler 里复用同一套 grants 解析(assertCanManageAllAppsResourceAccess),写库前预检,而不是给整个 create/update 挂 permission

Ones 平台落地分层见仓库 misc/docs/permission-system.md 第十二节。

可选缓存

默认不缓存(角色变更立即生效)。高 QPS 时可开:

typescript
createPermissionMiddleware({
  roles,
  getRole,
  cache: {
    cacheKey: cacheKeyFromUserAndApp, // userId:appId
    ttlMs: 60_000,
  },
})

类型扩展(withContext)

typescript
import { withContext } from 'vafast'
import type { PermissionRouteExtensions } from '@vafast/permission'

const defineAppRoute = withContext<
  { userInfo: { id: string } },
  PermissionRouteExtensions
>()

defineAppRoute({
  method: 'POST',
  path: '/users/invite',
  permission: true,
  handler: ({ userInfo }) => ({ id: userInfo.id }),
})

管理端级联 UI

typescript
import {
  getPermissionCatalog,
  getAllPermissionDefinitions,
  buildPermissionTree,
} from '@vafast/permission'

// 从已注册路由收集(需 Server 已创建)
const catalog = getPermissionCatalog('/billingRestfulApi')
const flat = getAllPermissionDefinitions('/billingRestfulApi')

// 不依赖路由:纯 key → 树
const tree = buildPermissionTree([
  'billing.points.adjust',
  'billing.points.read',
  'users.invite',
])

树节点形如:{ key, path, children, permissions },便于 cascader / 多选授权。

底层 API(一般不用)

多数业务只需 createPermissionMiddleware。仍导出:

API说明
permission({ resolve, pathPrefix? })自带 Resolver 时的底层中间件
requirePermission(key, { resolve })单路由显式校验
createRoleResolver / createLocalsResolver / createStaticResolverResolver 工厂
createCachedResolver底层缓存(优先用选项 cache

纯函数(单测友好)

函数说明
matchPermission(grant, required)单 grant 是否覆盖
hasPermission / checkRequirement集合 / anyOf / allOf
generatePermissionKey(path)路径 → key
resolvePermissionConfig(value, path)解析路由字段(含 true 推导)
cacheKeyFromUserAndAppuserId:appId

失败响应

默认 JSON:

json
{
  "code": 403,
  "message": "权限不足",
  "required": "billing.points.adjust",
  "requiredKeys": ["billing.points.adjust"],
  "mode": "single",
  "currentRole": "member",
  "grants": ["billing.points.read"]
}

可用 message / onDenied 覆盖。

注意事项

  • 授权 ≠ 认证:顺序必须是认证 → 权限。
  • 推荐 permission: truepathPrefix 与同服务 webhook 对齐。
  • 通配规则与 webhook 订阅的 eventKey 通配一致(* / prefix.*)。
  • 包本身不包含 Ones / 组织模型;那是业务 getRole 的实现细节。

相关链接