藏起来的按钮,拦不住旧客户端发来的请求。
一、按钮藏起来了,接口还开着
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。以下链接用于核对授权模型、越权分类与数据库行级策略的公开口径,本文未逐条引用其内容。
- PostgreSQL Row Security Policies :用于核对数据库层按行过滤与会话变量的机制。
- OWASP Top 10 A01:2021 Broken Access Control :用于核对越权与 IDOR 的分类和测试建议。
- Zanzibar: Google’s Consistent, Global Authorization System :用于核对关系型授权模型的适用范围与代价。
引用口径。 文中 120 个付费账户、344 个登录账号、20 个组织与 24 个工作区均为本系列统一的教学基线,不对应任何真实客户;27 条用例与 25 分钟耗时是复算口径,不是基准测试结果。
核验范围。 机制层面核对到函数签名、三层职责与三类漏洞的测试方法;未核对任何具体框架的中间件实现、云厂商授权服务的定价,也未做跨数据库的 RLS 性能对比。
如何自行复核。 用两个测试租户各造 3 条项目数据,用只读账号的 token 依次请求详情、列表、导出与批量接口,并把 URL 与请求体里的资源 id 换成另一租户的;记录每条请求的状态码与响应体是否包含另一租户的数据。27 条用例全部拒绝且不泄露存在性,才算通过。
系列导航
- 上一篇:四个角色与一张权限矩阵:最小权限怎么写
- 下一篇:数据隔离:一行 workspace_id 与四道防线
- 回到目录:一个人的多租户:组织、邀请、角色与数据隔离
- 相关阅读:API key 管理:签发、最小权限、轮换与泄露响应
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。