权限
理解权限模型、静态权限、动态权限、直接 URL 授权、超级角色和 403
这一页解释 apps/admin 的权限是怎么判定的。先把“权限是什么”讲清楚,再看静态模式和动态模式两条数据来源,最后是 403 的来源和排查。
判定发生在守卫链路里,但守卫只负责“判定失败就跳 /403”。守卫的整体流程见 路由守卫。
权限模型
先记住四条,后面所有细节都是它的展开:
| 维度 | 当前模型 |
|---|---|
| 权限是什么 | 角色码,例如 R_SUPER、R_ADMIN。不是细粒度操作码,也不是接口权限。 |
| 怎么判定 | 用户 roles 命中任意一个路由 permissions(OR 语义),或拥有超级角色,即通过。 |
| 作用域 | 路由级。matched routes 上的 permissions 必须全部通过(matches.every),不只是叶子页面。 |
| 静态 vs 动态 | 只是 permissions 这份数据的来源不同(前端 staticData / 后端路由节点),判定函数是同一套。 |
判定逻辑集中在 packages/web/admin-layouts/src/features/menus/permissions.ts,只有三个函数:
export function hasAnyRoutePermission(permissions, userInfo) {
if (!permissions?.length) {
return true;
}
const roles = userInfo?.roles ?? [];
const { permissionSuperRole } = getAdminLayoutsOptions();
if (permissionSuperRole && roles.includes(permissionSuperRole)) {
return true;
}
return permissions.some(permission => roles.includes(permission));
}
export function hasRoutePermission(routeMeta, userInfo) {
return hasAnyRoutePermission(routeMeta?.permissions, userInfo);
}
export function hasMatchedRoutePermission(matches, userInfo) {
return matches.every(match => hasRoutePermission(match.staticData, userInfo));
}| 路由配置 | 用户角色 | 结果 |
|---|---|---|
未配置 permissions | 任意 | 允许。 |
配置 permissions | 命中任意一个权限 | 允许。 |
配置 permissions | 包含超级角色 | 允许。 |
配置 permissions | 都不命中 | 403。 |
当前项目没有按钮级 / 接口级权限。
permissions只控制路由能不能进入。如果未来需要更细粒度的权限,应在业务模块里单独设计,而不是复用这套路由级判定。
当前事实
| 事实 | 当前实现 |
|---|---|
| 静态权限判断 | hasMatchedRoutePermission(matches, userInfo)。 |
| 动态直达授权 | hasAuthorizedRoutePath(currentRoutePath, userInfo)。 |
| 超级角色 | .env 的 VITE_STATIC_SUPER_ROLE,当前为 R_SUPER。 |
| 路由模式 | .env 的 VITE_AUTH_ROUTE_MODE,当前默认为 static。 |
| 注入超级角色 | bootstrap.tsx 的 setupAdminLayouts({ permissionSuperRole })。 |
适用场景
| 场景 | 重点位置 |
|---|---|
| 静态路由进入 403 | 页面 staticData.permissions、用户 roles、VITE_STATIC_SUPER_ROLE |
| 动态菜单模式直接输入 URL 进入 403 | 后端路由树、quickReferenceMenus、hasAuthorizedRoutePath() |
| 给某个页面加访问角色 | staticData.permissions(静态)或后端节点 permissions(动态) |
| 某角色应能访问全部页面 | VITE_STATIC_SUPER_ROLE |
静态权限
静态模式由 .env 控制:
VITE_AUTH_ROUTE_MODE=static在静态模式下,守卫会检查当前 matched routes 上所有 staticData.permissions:
if (import.meta.env.VITE_AUTH_ROUTE_MODE === 'static' && !hasMatchedRoutePermission(matches, userInfo)) {
throw redirect({ to: '/403' });
}hasMatchedRoutePermission() 会对每个 match 调用 hasRoutePermission(),规则就是上面的权限模型。
超级角色来自:
VITE_STATIC_SUPER_ROLE=R_SUPER例如角色管理页当前需要超级角色:
staticData: {
title: 'role',
i18nKey: 'route.manage_role',
menu: {
icon: 'carbon:user-role',
order: 2
},
permissions: ['R_SUPER']
}如果父 layout route 和子页面都配置了 permissions,静态模式会要求 matched route 全部通过。也就是说,子页面有权限但父级无权限,仍然会进入 403。这正是 matches.every 的体现。
动态权限和直接 URL 授权
动态模式下,菜单和授权索引来自后端路由接口。bootstrap.tsx 配置:
setupAdminLayouts({
loadDynamicRoutes: loadAdminDynamicRoutes,
routeMode: globalConfig.routeMode
});useMenus().initMenus() 在 dynamic 模式下会加载后端路由:
const routeData = await loadDynamicRoutes();
const { allMenus, home, quickReferenceMenus } = menuGenerator.generate({
backendRoutes: routeData.routes,
home: routeData.home,
userInfo
});守卫随后检查当前路由 path:
const currentRoutePath = getCurrentRoutePath(matches);
if (currentRoutePath && !hasAuthorizedRoutePath(currentRoutePath, userInfo)) {
throw redirect({ to: '/403' });
}hasAuthorizedRoutePath() 只有在 dynamic 模式下才真正检查:
if (routeMode !== 'dynamic') {
return true;
}
const menu = getQuickReferenceMenuByPath(path);
return Boolean(menu && hasRoutePermission(menu, userInfo));这解决的是动态菜单模式下的“直接输入 URL”问题:即使某个页面组件存在,只要后端没有把这个 path 返回到当前用户的授权菜单树中,用户直接访问也会被拦到 /403。
动态模式必须满足的条件
| 条件 | 原因 |
|---|---|
后端返回的 path 要和前端 route path 一致 | quickReferenceMenus 按规范化 path 查找当前路由。 |
后端节点要带正确的 permissions | 动态模式的直接 URL 授权读取后端节点权限。 |
| 前端仍然要有真实页面文件 | TanStack Router 只渲染前端 route tree 中存在的组件。 |
后端返回的 home 应是可访问 route path | 登录后首页会用动态 home 覆盖默认首页。 |
403 的来源
当前进入 /403 主要有两个来源:
| 来源 | 触发条件 |
|---|---|
| 静态权限失败 | VITE_AUTH_ROUTE_MODE=static,matched routes 的 staticData.permissions 未通过。 |
| 动态授权失败 | VITE_AUTH_ROUTE_MODE=dynamic,当前 path 不在 quickReferenceMenus 中,或节点权限未通过。 |
用户信息初始化失败不会进 /403,而是清理认证后回 /login。未登录也不会进 /403,而是回 /login。这两类属于守卫流程,见 路由守卫。
排查顺序
| 现象 | 先检查 |
|---|---|
| 静态模式某页面 403 | 页面和父 layout 的 staticData.permissions,用户 roles,VITE_STATIC_SUPER_ROLE。 |
| 动态模式菜单里没有该页面 | 后端路由树是否返回该 path,path 是否和前端规范化 path 一致。 |
| 动态模式菜单有但直接访问 403 | 后端节点 permissions 是否和用户 roles 匹配。 |
| 动态模式点击菜单 404 | 前端是否真的有对应 apps/admin/src/pages 页面和生成的 route tree。 |
| 超级角色仍然 403 | VITE_STATIC_SUPER_ROLE 是否和用户 roles 中的值完全一致,setupAdminLayouts({ permissionSuperRole }) 是否注入。 |
常见误区
| 误区 | 正确理解 |
|---|---|
| 静态模式只检查最后一个页面的权限 | 当前使用 matches.every,父级 layout route 的权限也会参与。 |
| 动态模式只要前端页面存在就能访问 | 必须在后端授权路由树中存在当前 path。 |
| 超级角色只影响菜单显示 | 超级角色在 hasAnyRoutePermission() 中统一生效,菜单生成和守卫都会使用。 |
permissions 是按钮权限 | 这里是路由访问权限;按钮级或接口级权限应在业务模块里单独设计。 |
多个 permissions 需要同时满足 | 是 OR 语义,命中任意一个即可通过。 |