RBAC 权限体系审计与收紧
适用症状
用户这样描述时,几乎可以确定是三层失守(后端 API / 前端页面 / 按钮入口),不要只修一层:
- 「只给了查询权限,页面上的导入导出、清除、增删改查都能用」
- 「不给模块权限、只给首页/运营视图权限,但从首页卡片点进去就能操作」
- 「权限完全没有管理住」
核心方法论:三层同时收紧 + 双维度验证
三层
- 后端:每个路由既要「挂载级依赖兜底」,也要「端点内联依赖精确化」。挂载级只给模块
view权限,写操作必须在端点内联require_perm("<module>:<action>")或require_any_perm(...)。 - 前端页面:统一路由守卫组件,越权渲染「无访问权限」占位页(不要静默跳转,否则用户以为功能坏了)。守卫要放在 layout 的
<main>内部只切换 children,绝不条件性增删 layout 节点(会导致 children 整体卸载重挂、页面状态丢失)。 - 前端按钮/入口:所有写操作按钮、以及首页/仪表盘的模块入口卡片,都要按权限过滤(无权限的卡片置灰不可点,而不是隐藏——保留可见性能降低沟通成本)。
双维度验证(缺一不可)
- 维度一 · 静态:扫描所有端点,判定其保护来源是
inline/mount/PUBLIC/NAKED。目标:NAKED = 0。 - 维度二 · 动态:对运行中的服务打真实 HTTP 接口,用低权限账号断言写操作全部 403、admin 抽样不被误伤。
只做静态或只做动态都会漏。 本项目实测:首轮做完静态收紧后,动态探针仍揪出 3 个真漏洞(一个端点漏挂权限、一个无鉴权信息泄露接口、一个装饰器误绑)。
执行步骤
1. 建立基线
# 列出角色与权限(以常见表名 roles / user_roles / 用户表 为例)
python - <<'PY'
import sqlite3, json
con = sqlite3.connect('<db_path>'); cur = con.cursor()
cur.execute('SELECT name, permissions FROM roles ORDER BY id')
valid = set(<ALL_PERMISSION_IDS>) # 从后端权限目录 import
for name, perms in cur.fetchall():
p = json.loads(perms) if perms else []
bad = [x for x in p if x not in valid]
print(f'{name:16s} 总数={len(p):3d} 失效={bad or "无"}')
PY
先确认「有没有失效/遗留的旧权限 ID」,以及「有没有角色被误授予写权限」。
2. 静态双维度扫描
用 scripts/scan_perm_coverage.py(把 ROOT、挂载解析、路由映射按本项目结构调整):
- 解析主入口的 include_router(..., dependencies=[...]) → 每个 router 的挂载级守卫
- 解析各 router 文件的端点签名块 → 内联守卫
- 二者皆无 = NAKED,必须修
两个高频踩坑:
- 别漏旧别名 require_action(module, action)(等价 require_perm("module:action")),漏了会把整片模块误判成「零权限」。
- router 符号→文件映射若通过包 __init__.py re-export,要解析 from .x import router as y_router,不要只扫 from .routers.x import y_router。
3. 前后端权限口径一致性比对
把所有 canDo("m","a") / hasPerm("m:a") / canAccessModule("m") 与后端 require_perm("m:a") / require_action("m","a") / 权限目录做三方差集,重点看:
- 前端引用但目录里没有 → 按钮永远不显示(拼写错误)
- 前端有按钮、后端无强制 → 后端裸奔
- 后端强制、前端不判断 → 按钮会露出来但点了 403(最影响体验,优先修)
- 目录里定义了但两端都没用 → 死权限
已知高危区(务必逐个翻):设置页里的「用户管理 / 角色权限 / 模型配置」子 Tab 常是另一套独立实现的 UI,容易漏;角色管理页的权限矩阵勾选框;以及"演示/示例"页(*-demo)往往完全没守卫。
4. 动态回归探针
用 scripts/verify_perms_live.py。
关键设计原则:只断言写操作必须 403,查询端点只报告不断言。
原因:管理员可能在你会话进行中通过 UI 手改角色的查看权限,硬编码「查询必须 403」会产生假失败。而「该账号无任何写权限 ⇒ 所有写操作 403」是稳定的。
探针分组:
- WRITE 必须 403(覆盖 POST/PUT/PATCH/DELETE 与 clear/import/upload/export 落地)
- VIEW 只打印实际状态
- ALLOW 必须非 403(该账号确有权限的端点 + 仅需登录的端点)
- GONE 必须 404(已下线的高危/泄露接口)
- ADMIN_SAMPLE 不得被误伤
Token 构造:直接 create_token(SimpleNamespace(id=..., username=..., role=...)),避免依赖密码。role == "admin" 通常直通全量。
5. 前端修复要点
- 新增统一
RouteGuard+NoPermission组件与路由→权限映射表 - 逐页补
canDo(module, action),替换掉散落的hasPerm字符串判断 - 首页/仪表盘:模块卡片按权限置灰(保留卡片 + 禁止点击 + 提示)
- 侧边栏菜单显隐与页面守卫用同一套权限判定函数,否则会出现"菜单可见但页面无权限"的割裂
6. 发布与验证
- 后端:重启后立刻
curl探测(本机探测务必绕过代理,否则可能拿到假 502) - 前端:若用
output: "standalone",build 后必须把.next/static同步到.next/standalone/.next/static,否则页面能开但 JS/CSS 全 404。安全同步法:先拷到.tmp→ 校验非空 → 再替换(不要rm -rf后再cp,中途失败会留下空目录) - 发布后重跑动态探针
常见漏洞模式速查
| 模式 | 表现 | 修法 |
|---|---|---|
挂载级只 require_login |
任何登录用户可调该模块全部接口 | 端点内联精确权限 |
| 模块内共享端点 | 一个 POST 既当新增又当导入 | require_any_perm("m:create","m:import") |
| 调试/临时接口残留 | /debug-* 返回全量用户、租户、token 解析结果 |
直接删除 |
| 装饰器误绑 | @router.post("/logout") 挂在启动种子函数上,真函数成死代码 |
核对装饰器与函数配对 |
| 导出/下载只在客户端 | 后端无对应端点,只有前端按钮控制 | 可接受(数据本已通过 view 权限拿到);但要在报告里说明 |
| 权限目录有、后端未强制 | 前端隐藏了按钮但直接调接口能绕过 | 端点必须加依赖 |
| 只读角色含写权限 | 如 viewer 里混进 x:delete |
修角色数据,view 之外的都剔除 |
输出物清单
交付时给用户这几项,比长篇描述更有说服力:
1. 静态扫描结论(端点数 / NAKED=0 / 仅挂载兜底的写操作清单)
2. 前后端口径差异清单(分四类)
3. 动态探针输出(写操作 N 项全部 403、admin M 项全部 200)
4. 角色权限迁移前后对照表
5. 修复文件清单 + 部署状态 + 「需强刷浏览器」提醒
环境注意事项
- 本机/内网探测接口必须绕过系统代理;
curl加对应 no-proxy 选项,Python 用urllib.request.build_opener(ProxyHandler({})) - 查监听端口用
lsof -nP -iTCP:<port> -sTCP:LISTEN(沙箱内ps常受限) - 不要相信 Bash 里的
grep:某些沙箱会静默返回空结果且退出码 0,会造成"某文件没有权限判断"的误判。一律用专用 Grep 工具或 Python 脚本扫描 - 改动后端后必须重启进程;重启方式优先用进程管理器的 KeepAlive,拉不起来再手动
nohup启动