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
三行输出说明了三件事:
- 管理员看到 3 个用户(租户内全部);
- Bob(研发)只看到 1 个——他属于
o_rnd研发部,该部门只有他一个人; - 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 章 |
关于"机制化"的三种落地(按侵入性从低到高):
- 仓储层基类:所有列表查询继承同一个基类,自动追加范围条件。直观,但要求"所有查询都走仓储";
- ORM 拦截器 / 全局钩子:在查询执行前自动改写。集中,但调试时 SQL 变得不直观;
- 数据库行级安全(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 章解决一个工程问题——鉴权代码该写在哪。函数内判定、依赖注入、注解三种形态里,哪一种能让"漏写"从可能变成不可能。