权限判定的三层校验:UI、API 与数据

前端藏了按钮不等于权限生效。本文用 can(actor, action, resource) 统一判定入口,按 UI、API、数据三层校验,并给出一套完整的 27 条跨租户用例、5 条首轮失败与 25 分钟季度复跑口径。

藏起来的按钮,拦不住旧客户端发来的请求。

一、按钮藏起来了,接口还开着

A 团队档客户有三个成员:所有者、编辑、只读。只读成员的界面上,导出按钮被前端按角色隐藏了,产品验收时也确认过「只读看不到导出」。这条改动被写进发布说明,措辞是「修复只读角色可导出项目的问题」。

三周后,一位只读成员用半年前安装的桌面客户端 1.8 发了一次导出请求。旧客户端没有角色判断,导出按钮一直都在;接口 /api/v1/projects/export 只校验了登录态,没有校验角色,也没有校验请求里的 workspace_id 是否属于当前用户。他拿到了一份 CSV。

更麻烦的是,他把 URL 里的 workspace_id 换成了另一家客户公开演示时留下的 id,接口照样返回数据。前端藏的按钮、发布说明里的「已修复」、以及一次点击验收,都没有挡住这条路径。

以下情境为教学假设,人物、引语和数字不代表经核实的真实案例。

前端隐藏是体验,不是权限。 权限必须在服务端每次请求上重新成立,而不是在某个版本的界面里成立一次。

这件事的根因不是某个人粗心,而是判定被放在了错误的位置。按钮的显隐是渲染逻辑,接口的放行是契约逻辑,两者之间没有共享的判定入口,所以改了一边不会影响另一边。

旧客户端只是把问题暴露得更彻底。即使没有旧客户端,curl、脚本、浏览器里改一个请求、第三方集成,都会绕开界面。只要接口没关,界面藏得再干净也只是延迟被发现。

测试之所以没发现,是因为验收只看界面。点一遍只读账号,看不到按钮,就算通过;没有人用只读账号的 token 直接请求导出接口。缺少的是按「角色 × 动作 × 资源归属」组织的用例,而不是更多的点击。

二、把判定收拢到一个函数

把判定收拢到一个函数,比在二十个 handler 里各写一遍 if 更难写错。

规则具体要求违反后的典型症状
默认拒绝没有匹配到允许规则时返回拒绝新端点忘了声明,反而对所有人开放
单一入口所有读写先经过 can(),handler 不自己判断角色两处判断不一致,改角色时漏改一处
返回原因结果带 reason 与缺失的作用域,便于日志与提示只返回 403,客户和你都不知道缺什么
可测试判定函数是纯函数,只依赖 actor、action、resource只能靠点界面验证,回归成本高

权限判定要像计费一样对待:单一入口、可审计、可测试。 散落在各处的条件判断,改一次角色就要全仓库搜索,漏一处就是一条越权路径。

签名建议固定为 can(actor, action, resource) -> { allowed, reason }。actor 至少包含 user_id、workspace_id 与角色集合;action 用枚举而不是字符串拼接;resource 是已经从数据库读出的对象,至少带 workspace_id 与 owner_id。

第一个规则是默认拒绝。没有显式匹配到允许规则时返回拒绝,新增端点忘了声明权限的后果是「没人能用」,而不是「所有人都能用」。这个默认值决定了漏配的方向。

第二个规则是单一入口。HTTP handler、后台任务、CLI、导出任务都调用同一个函数。不要出现「后台任务信任内部调用所以跳过校验」的写法,内部调用同样可能带着错误的租户参数。

第三个规则是返回原因。拒绝时给出 reason,例如缺少 project.export 或资源不属于当前工作区。原因进日志、进测试断言,必要时脱敏后给用户看;只返回一个 403,排查时只能靠猜。

第四个规则是可测试。判定函数是纯函数,不读全局会话、不发网络请求;actor 与 resource 由调用方组装。这样才能用表驱动测试覆盖角色矩阵,也才能在 CI 里跑 27 条跨租户用例。

不要在 handler 里手写 if role == "owner" or role == "editor"。这类判断第一次写很快,第二次复制,第三次就开始分叉:有的地方允许 owner,有的地方把 admin 也算进去,有的地方忘了判断资源归属。统一入口的价值不是优雅,而是让不一致无处藏身。

三、三层各查什么

三层校验不是把同一个判断写三遍,而是分别回答「看不看得见」「准不准做」「拿不拿得到」。

层查什么漏了会怎样可否省略
UI 层当前角色是否显示入口、按钮是否禁用用户看到不该看的入口,点了才报错可以,只影响体验
API 层actor 是否有 action 权限、资源是否属于同一 workspace旧客户端、脚本、直接调用都能越权不可省略
数据层每条查询必须带 workspace_id 作用域,必要时加 RLS一条漏写 where 的查询把别家数据带出来不可省略

API 层是唯一不可省略的契约层,数据层是最后一道兜底。 UI 层可以为了体验做得更严,但不能被当作权限本身。

UI 层查的是当前角色与入口的映射:只读不显示导出,编辑不显示账单。它决定用户会不会误点,不决定请求会不会成功。省略它的代价是体验差和误报,不是数据泄露。

API 层查的是 actor 是否有权对该资源执行该动作。它必须同时校验动作权限与资源归属:有 project.export 不等于可以导出任意工作区的项目。旧客户端、脚本和第三方集成都停在这一层。

数据层的检查落在查询本身:每条读写都必须带 workspace_id 作用域,列表、聚合、导出、批量更新都一样。这里的典型事故不是判定写错,而是某条查询少写了一个 where。

三层的顺序不能倒。先有 UI 的引导,再有 API 的拒绝,最后有数据层的兜底;如果只有数据层,用户会在界面上点进一个必然失败的流程;如果只有 UI 层,等于没有权限。

三层都通过,才叫「这条路径被拦住」。只测其中一层,得到的结论是「这一层没问题」,不能外推成「权限没问题」。

四、三类漏洞与怎么测

跨租户事故里,绝大多数不是精巧的攻击,而是三类普通漏洞的重复出现。

类型典型表现怎么测
IDOR:改 URL 里的 id/projects/1842 改成 1843,返回别家项目用 A 租户账号遍历 B 租户资源 id,断言 404 而非 403
列表接口漏过滤列表默认全表,靠前端筛选两个租户各造 3 条数据,断言只返回本租户 3 条
批量接口逐个校验缺失批量更新只校验第一个 id混入一个别家 id,断言整批拒绝且无部分写入
用例矩阵 = 9 类跨租户场景 × 3 层 = 27 条
首轮结果 = 5 条失败(列表接口 4 条 + 批量接口 1 条)
修复后 = 0 条失败
每季度重跑 = 27 条 × 约 55 秒 ≈ 25 分钟

漏洞的形状很稳定,测试也应该按形状组织。 每一种漏洞都对应一条可以直接写进 CI 的断言,而不是一次性的手工检查。

IDOR 的关键不是猜 id,而是服务端是否把「资源存在」和「资源属于你」分开处理。正确行为是查不到就 404,不泄露「这个 id 存在但不属于你」;如果返回 403,反而暴露了资源存在性。

列表接口漏过滤通常发生在「先查全表、再在前端筛选」的实现里。修复要落在仓储层:列表方法的签名强制要求 workspace_id,没有这个参数就编译不过或直接报错,而不是靠每个调用方自觉。

批量接口的问题更隐蔽:循环里只校验了第一个 id,或者只在入口校验了一次权限,后续条目直接更新。测试要混入一个别家资源,断言整批拒绝且没有任何部分写入,事务边界也要一并验证。

首次跑 27 条用例时出现 5 条失败:4 条来自列表接口,1 条来自批量接口。这个分布并不意外——详情接口因为路径里有 id,最容易被想到;列表和批量因为「看起来是内部逻辑」,最容易被跳过。

27 条用例的价值不在数量,而在组合:9 类场景覆盖读、写、导出、批量、跨工作区邀请等路径,每一类都在 UI、API、数据三层各有一条断言。组合固定下来,季度的 25 分钟才可重复。

五、行动清单与复算验收

清单按「先能测,再能拦」排序,前四项决定判定入口是否成立。

  • 所有写操作与敏感读操作都经过 can(actor, action, resource)
  • 角色到 action 的映射写成表,代码里不出现散落的条件判断
  • actor 的 workspace_id 来自会话,不信任请求体
  • resource 加载时带上 workspace_id 条件,查不到即 404
  • 列表查询在仓储层强制注入 workspace_id,业务代码不能绕过
  • 批量接口逐条校验,任一条越权则整批拒绝
  • 27 条跨租户用例进 CI,合并前必须全绿
  • 每季度人工重跑一次,并记录耗时与失败原因

验收标准:能拿出 27 条用例,证明 UI、API、数据三层都拦得住;只要还存在「只在界面上藏了」的路径,就不算通过。

使用边界与实操验收

本文讲的是同一套应用内的租户边界,不覆盖企业单点登录、SCIM 同步与跨组织委托。

权限系统的复杂度应该跟客户规模匹配。 20 个团队、24 个工作区的体量,不需要 Zanzibar 式的通用关系图,先把三层校验和 27 条用例做扎实。

这套结构的上限也要说清楚。角色是粗粒度的,一旦出现「同一工作区内按项目授权」「外部顾问只能看某几个文件」的需求,四个角色和一张矩阵就不够了,需要引入资源级关系或策略语言。

20 个团队里有 4 个各多一个工作区,共 24 个工作区。多工作区意味着同一个用户可能在不同工作区有不同角色,actor 的 workspace_id 必须来自当前请求的上下文,而不能从用户资料里取一个默认值。

UI 层的校验可以省略,但省略之后要接受体验成本:用户会点进无权页面再看到 403。对只有 100 个个人档用户的体量,这个成本可以接受;对团队档客户,至少要把入口藏掉并给出解释。

数据层的兜底不能替代 API 层。有人会认为「反正查询都带 workspace_id,API 少判一次没关系」,但数据层只能保证不返回别家数据,不能保证「本工作区的只读成员没有执行导出」这类动作级权限。

如果你现在只有几个客户,最值得先做的不是策略语言,而是把 can() 抽出来、把 27 条用例写下来。这两件事的边际收益最高,也最不容易在后续加功能时被绕过。

关键参考与核验范围

核验日期:2026-10-25。以下链接用于核对授权模型、越权分类与数据库行级策略的公开口径,本文未逐条引用其内容。

引用口径。 文中 120 个付费账户、344 个登录账号、20 个组织与 24 个工作区均为本系列统一的教学基线,不对应任何真实客户;27 条用例与 25 分钟耗时是复算口径,不是基准测试结果。

核验范围。 机制层面核对到函数签名、三层职责与三类漏洞的测试方法;未核对任何具体框架的中间件实现、云厂商授权服务的定价,也未做跨数据库的 RLS 性能对比。

如何自行复核。 用两个测试租户各造 3 条项目数据,用只读账号的 token 依次请求详情、列表、导出与批量接口,并把 URL 与请求体里的资源 id 换成另一租户的;记录每条请求的状态码与响应体是否包含另一租户的数据。27 条用例全部拒绝且不泄露存在性,才算通过。

系列导航

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Indie」更多文章

  1. 开源项目验收:十项能力、四维评分与三种不该开源的项目
  2. 治理、fork 与退出:17 位贡献者、82% 的提交与一次改协议的代价
  3. 双许可与企业版:九家询价、两家成交与 4,800 美元