数据层:一条记录之外,还有一整页列表
Server 层:从 Middleware 到 Repository 的逐层拦截 把 scope 过滤的落点定在了 Repository。这一页接着往下走一层,看这道过滤在真实查询里怎么写:一条记录与一页列表的写法不同,多租户又在外面套了一层,而任何一处缓存都会把已经撤销的权限多留一会儿。
1 · 一条记录与一页列表
assert(key) 回答的是「这个用户一般能不能做」,对象级授权回答「这一条能不能」。两者都只针对单个对象,取到记录再判定即可。列表不同:它要回答「哪些能」,逐行调一次判定就是一次 N+1,一页 20 行换来 20 次判定,并且分页器还需要一个总数,那意味着把全表都判一遍。
可行的写法只有一条:把 scope 编译成查询条件,让数据库替判定做筛选。Self 编译成 owner_id = :me,Group 编译成 group_id = :myGroup,Full 不加条件,None 直接短路返回空集。这也是 scope(key) 与 ScopeResolver
存在的理由:它们的输出不是布尔值,而是一段可以拼进 WHERE 的谓词。
警示 · 本系列 perm.ts 里的 scopeRows() 是反面写法:它先拿到 SAMPLE_ROWS 全表,再在 JS 里逐行标记 vis。五行玩具数据这样写没问题,照搬到真实表上就是「取回全部再丢掉大半」。另一处更隐蔽:effective() 只并 permission key,scope
由 effectiveScopes() 另算,而后者只遍历 role——用户级 Allow 加进来的 key 在 scope 表里查出来是 undefined。这个空值落到 ScopeResolver 手里,默认成 Full 就是提权,默认成 None 就是功能坏掉,两种都不能靠「忘了写就取默认」蒙过去。
2 · 过滤与分页的先后
顺序错了不会报错,只会给出错的页。一张 50 行的表、其中 10 行属于当前用户、每页 5 行:先过滤再分页得到 2 页,两页各 5 行。颠倒过来,先 LIMIT 5 OFFSET … 取一页再在应用层筛 owner,每一页都只剩 1 行,要翻 10 页才看完这 10 条,而分页器根据未过滤的 count(*) = 50 声称有 10
页(数字为 sqlite 3.51 上实跑所得)。
注 · 总数与页数必须和过滤后的集合来自同一次查询。分页器的 total 取自 SELECT count(*) 而数据行取自带 scope 条件的查询,这两个数字就永远对不上:页码点得到、点进去是空页,用户看见的是「系统丢了我的数据」。cursor 分页比 offset
更稳,但它不豁免这条:游标同样要建立在过滤后的序上。
3 · tenant 是最外层的那道闸门
SaaS 系统里,tenant 是包在所有 scope 之外的一层:Full 的含义从来只是「本租户的整张表」。它与 AllowedScope 的四档正交——tenant 切租户,scope 在租户内切 owner,两道条件同时进 WHERE。
危险在于 tenant 条件通常靠人手拼。一处漏写不一定当场出事:漏掉 tenant 但保留 owner_id = :me,以 A 租户身份查仍然只捞到自己的行,只有以 B 租户身份查、或者 scope 放宽到 Full 时,别人的数据才浮上来。
4 · 生效集的缓存与撤销延迟
每次请求都重算生效集要连查 user、role、role_permission 三张表,于是它几乎总会被缓存。缓存一旦存在,撤销就不再即时:管理员移除角色到判定真正收紧之间,隔着一整个 TTL。这与 identity §2 的 stale claims 是同一种病,只是位置从凭证换到了服务端缓存。
按事件失效比按时间过期更准:角色变更、权限调整、用户停用各自发一条失效消息,缓存键按 user + tenant 组织,改一个人只清一个人的。代价是失效消息丢一条就永久脏读,所以事件失效通常还要配一个不长的兜底 TTL。
建议 · 缓存键必须带上所有影响结果的输入。少写 tenant,同一个人在两个租户之间就会串权;少写 scope,Full 的结果会被 Self 的请求读到。判定结果本身(decide 的返回)比生效集更不适合缓存:它的输入里含
context,而时刻与网络位置每次都在变,缓存命中率低而错误代价高。
5 · 让数据库自己当 PEP
把闸门下沉到数据库,漏写就变成不可能。PostgreSQL 的 row level security 给表挂一条策略,之后所有查询都被自动加上这个谓词:
ALTER TABLE article ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON article
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
USING 管可见性(SELECT / UPDATE / DELETE 能看到哪些行),写入侧另有 WITH CHECK。current_setting 的第二个参数给 true,未设置时返回 NULL 而不是报错,谓词随即为假、返回零行——failing closed,比抛异常更安全。
警示 · RLS 有两个绕过它的身份:superuser 与带 BYPASSRLS 属性的角色一律不受策略约束,表的 owner 也默认不受约束,除非显式 ALTER TABLE … FORCE ROW LEVEL SECURITY。而应用连接池里那个用户,往往正好是建表的 owner。这条据 PostgreSQL 官方文档,本仓未实测(核对于
2026-08)。
6 · 参考文献
- PostgreSQL Global Development Group. Row Security Policies.
USING与WITH CHECK的语义,以及 owner 与BYPASSRLS的绕过条件。postgresql.org - 本系列。AllowedScope:同一个 action,对谁做。四档 scope 各自对应的
WHERE条件。 - 本系列。Server 层:分层拦截。
can / assert / scope三个原语的介入点。