KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

02 · 数据权限:四种 scope 与「把权限塞进 SQL」的两种做法 — keel 龙骨

前一章的接口权限回答"能不能调这个接口",但它拦不住"能调列表接口的人看见全部行"。这一章讲数据权限:四种数据范围、两种落地做法,以及一个关键判断——数据权限不阻止任何人访问,它只给查询追加一个条件,所以它的失效永远是静默的。

前一章的接口权限回答"能不能调这个接口",但它拦不住"能调列表接口的人看见全部行"。这一章讲数据权限:四种数据范围、两种落地做法,以及一个关键判断——数据权限不阻止任何人访问,它只给查询追加一个条件,所以它的失效永远是静默的。


一、现场:研发看到了全公司的用户列表

一个研发角色配了 data_scope = self(仅本人),但他在用户列表页看到了所有人的数据。

第一反应是"角色没配对"。查了数据库,role.data_scope 确实是 self,
member_role 也确实挂对了。问题出在数据权限没有被应用:查询代码里根本没调用可见范围计算。

这就是数据权限最典型的失效方式——不报错,只是结果变多了。

二、概念边界:数据权限是查询条件,不是访问闸门

先把这个区分说清楚,它决定了后面所有的做法:

功能权限(接口权限):在进入 handler 之前判定,通过 / 403 —— 闸门
数据权限(行级)  :在组装 SQL 时追加 WHERE 条件 —— 过滤器

过滤器可以被绕过:任何一条忘记追加条件的查询、任何一个 ORM 关联查询、
任何一次 raw SQL、任何一个后台定时任务,都会绕过数据权限。功能权限只有一个入口,
数据权限有无数个入口。

这直接推导出数据权限的工程铁律:

数据范围条件必须由机制统一注入,不能靠每个查询作者记得手写。

三、一次完整运行:四种 scope 的实际效果

配套项目里四种 scope 都实现了(project/rbac.py):

scope 语义 实现的可见组织
all 全部数据 None(不追加条件)
org_and_child 本部门及下级 递归查出子树 id
org 仅本部门 [自己的 org_id]
self 仅本人 [自己的 org_id](演示数据里进一步收敛到本人)

实测输出(python demo.py,Acme 租户三个角色):

=== 04 数据范围:不同角色看到多少行 ===
  u_alice  scope=all           orgs=None 可见用户数=3
  u_bob    scope=self          orgs=['o_rnd'] 可见用户数=1
  u_carol  scope=org           orgs=['o_sales'] 可见用户数=1

三行输出说明了三件事:

  1. 管理员看到 3 个用户(租户内全部);
  2. Bob(研发)只看到 1 个——他属于 o_rnd 研发部,该部门只有他一个人;
  3. Carol(运营)也只看到 1 个——她属于 o_sales 销售部。

orgs=None 那行值得注意:all 不是"一个很大的集合",而是"不追加条件"。
这两种写法在代码里必须严格区分——把 None 当成空集合处理,会让管理员什么都看不到。

org_and_child 的实现用的是递归 CTE,一次查询完成:

WITH RECURSIVE subtree(id) AS (
    SELECT id FROM org WHERE id = ?          -- 起点:自己的部门
    UNION ALL
    SELECT o.id FROM org o JOIN subtree s ON o.parent_id = s.id
)
SELECT id FROM subtree

用 CTE 而不是"查一次、取子 id、再查一次"的循环:后者是典型的 N+1,
部门层级深一点就会把查询数放大成树的大小。

四、两种做法:应用层过滤 vs SQL 层过滤

把上面的可见组织列表应用到查询上,有两种写法。

写法一:应用层过滤——取全量数据后在内存里筛。

users = repo.all_users()                      # 取全租户数据
visible = [u for u in users if u.org_id in visible_org_ids]   # 内存筛

问题很直接:数据还是全量取出来了。租户有 100 万用户、当前用户只看 1 条,
这 100 万条都经过了内存和网络。

写法二:SQL 层过滤——把条件拼进查询。

org_ids = visible_org_ids(conn, identity)      # all → None
if org_ids is None:
    sql = "SELECT COUNT(*) FROM app_user"
else:
    placeholders = ",".join("?" * len(org_ids))
    sql = f"SELECT COUNT(*) FROM member m WHERE m.tenant_id = ? AND m.org_id IN ({placeholders})"

配套项目用的是写法二(count_users_visible)。参数用占位符传入,不做字符串拼接租户值——
拼接是 SQL 注入的入口,这条纪律在权限代码里同样适用(权限代码往往是最容易被"快速实现"的一层)。

两种做法的选择:写法定型。管理后台(用户量可控、SQL 复杂)两种都能接受;
面向外部客户的 SaaS 必须用写法二,因为数据量不受你控制。

五、失败注入:把数据权限拆掉

实验:把 visible_org_ids 恒返回 None(相当于所有角色都是 all):

  u_alice  scope=all    orgs=None 可见用户数=3
  u_bob    scope=self   orgs=None 可见用户数=3   ← Bob 看见了 Carol 和 Alice
  u_carol  scope=org    orgs=None 可见用户数=3   ← Carol 看见了研发部的人

接口返回 200,数据变多,没有任何异常。对比实验 A(删接口校验 → 403 变 200),
这类问题更难被发现,因为它的"错误表现"就是正常返回。

配套项目里对应的测试用例把这个不变量钉住了:

def test_data_scope_is_still_tenant_scoped(conn):
    """数据权限只过滤行,绝不跨租户"""
    alice = as_user(conn, "u_alice")
    assert count_users_visible(conn, alice) == 3   # acme 有 3 个 member

这条测试守护的是一个更高优先级的不变量:数据范围再宽,也不能跨租户。
如果 all 的实现不小心退化成了"不加任何条件",那就会把别的租户的数据也带出来——
这是比"看得太多"更严重的一级事故。

六、误判澄清

误解 核对 结论
"配了 data_scope 就生效" 配的是数据,查询时要用它 必须在查询里应用它,否则毫无作用
"all 就是返回全部 id 列表" 实测输出里 orgs=None all 的实现是"不追加条件",与"空集合"语义相反
"数据权限能挡住越权访问" 它只是过滤器 任何忘记追加条件的查询都能绕过,机制统一注入才是解法
"数据范围可以放宽到跨租户" 那是租户隔离,不是数据范围 两者是不同层:data_scope=all 仍然只在本租户内
"前端传 org_id 就能查对应部门" 客户端参数不是授权 可见范围必须由服务端根据身份算

七、生产环境怎样替换

教学替身 生产替换 要点
手写 WHERE org_id IN (...) 统一的数据范围注入机制:ORM 拦截器 / 仓储层基类 / MyBatis 插件 这是本节最重要的建议——机制化,不要靠人记得
org_id 单层 部门树(parent_id)+ 递归 CTE 层级深时注意 CTE 的深度限制
内存过滤 SQL 层过滤 数据量不可控时必须下推到数据库
无缓存 数据范围与权限集合一起缓存 失效机制见 07 章

关于"机制化"的三种落地(按侵入性从低到高):

  1. 仓储层基类:所有列表查询继承同一个基类,自动追加范围条件。直观,但要求"所有查询都走仓储";
  2. ORM 拦截器 / 全局钩子:在查询执行前自动改写。集中,但调试时 SQL 变得不直观;
  3. 数据库行级安全(RLS):在数据库里按会话变量过滤。最强,跨所有应用入口一致生效,但要求每条连接都能拿到"当前身份"。

多数团队停在第 1 层。第 3 层是"多租户平台"的终局形态——因为它把"忘了加条件"这类 bug 变成不可能。

八、练习与验收

练习 1:在你的项目里 grep 所有列表查询,找出其中没有应用数据范围的那些(这是本课最值得做的一次审计)。

练习 2:把配套项目 visible_org_ids 的返回值改成恒 None,跑 test_auth.py,确认哪个测试先失败、失败信息是否指得准。

练习 3:实现 org_and_child 的第二种写法(Python 循环查),用 5 层部门树量一下查询次数,与递归 CTE 对比。

验收点:不看资料,说出数据权限与接口权限的本质区别(闸门 vs 过滤器)、四种 scope 的语义与 all 的实现陷阱、为什么 all 也不能跨租户。


现在能解释什么:数据权限为什么是过滤器而不是闸门、由此带来的"必须机制化注入"结论;四种 scope 的实现差异与 all 的语义陷阱;两种落地写法的取舍。

下一步:第 03 章解决一个工程问题——鉴权代码该写在哪。函数内判定、依赖注入、注解三种形态里,哪一种能让"漏写"从可能变成不可能。

进入 keel 阅读