行级权限只堵了主列表,子列表和搜索还在泄漏
给某后台管理系统做数据权限改造:按部门隔离,不同部门的操作员只能看到自己部门的客户记录。改完主列表接口,跑通测试,上线。结果第二天收到反馈:「我能在搜索框里搜出不属于我部门的客户,还能从子详情页侧边栏看到对方的档案。」
权限漏了——但代码明明加了过滤。
现象
GET /api/customers返回结果正确,只含本部门数据- 全局搜索接口
GET /api/search?q=xxx仍返回跨部门记录 - 客户详情页(Next.js RSC 页加载器直读数据库)展示了跨部门的关联子记录
- 子资源列表接口
GET /api/extractions?customerId=xxx对任意customerId均有返回,无鉴权
根因
行级权限过滤只加在了主实体列表这一个入口,而同一份数据被多条独立的查询路径消费,每条路径各自持有独立的 SQL / ORM 调用,彼此之间没有共享过滤逻辑:
客户主列表 API ← 已加 dept 过滤 ✓
全局搜索 API ← 独立查询,未过滤 ✗
RSC 页面加载器 ← 绕过 API 直读 DB,未过滤 ✗
子资源列表 API ← 按 FK 查子表,未验证父记录可见性 ✗
CSV 导出接口 ← 复用了旧的无鉴权 service 方法 ✗
给主列表加了过滤 ≠ 整个数据域已被保护。每一条独立的查询路径——子资源列表、搜索、SSR 加载器、导出——都有自己的 SQL,必须单独审计。尤其是 Next.js App Router 的 Server Component:它直接调 db.query(),完全绕开了 API 层的权限中间件。
排查
先 grep 主实体的读函数名(如 listCustomers、readRelationalCustomers),找出所有调用点。再 grep 子表的 FK 字段名(如 customer_id),找出所有子资源查询。把所有命中结果逐一过目,标注「是否已带 scope 参数」。
# 找所有消费主实体数据的地方
grep -rn "listCustomers\|readCustomers" src/
# 找所有通过 FK 关联子表的地方
grep -rn "customer_id" src/ --include="*.ts"漏洞面:主列表之外还有 4 个读入口,全部裸奔。
正解
抽单一 scope 过滤函数,所有读路径复用。
// lib/scope.ts — 过滤规则唯一 SSoT
export function filterCustomersByScope(
query: CustomerQuery,
scope: DeptScope | "all"
) {
if (scope === "all") return query; // 只有显式 'all' 才放行
return query.where("dept_id", "=", scope.deptId);
}
// 子资源需要先拿到可见父 ID 集合
export async function listVisibleCustomerIds(scope: DeptScope) {
return db
.select("id")
.from("customers")
.where("dept_id", "=", scope.deptId);
}然后逐一补进每条读路径:
// 1. 主列表 API(已有)
const list = await listCustomers(filterCustomersByScope(baseQuery, scope));
// 2. 搜索 API
const results = await searchCustomers(q, filterCustomersByScope(baseQuery, scope));
// 3. RSC 页加载器 — 必须在 Server Component 里手动解析权限
const user = await getServerUser();
const scope = resolveScope(user);
const customer = await db.query(filterCustomersByScope(..., scope));
// 4. 子资源列表 — 校验父记录归属
const visibleIds = await listVisibleCustomerIds(scope);
if (!visibleIds.includes(customerId)) return forbidden();
const extractions = await listExtractions(customerId);
// 5. 导出接口
const rows = await exportCustomers(filterCustomersByScope(baseQuery, scope));filterCustomersByScope 默认 fail-closed:没有显式传 'all' 就一定带过滤。这样未来新增的调用方如果忘了传 scope,返回的是空集而不是全量数据,把安全漏洞变成功能 bug(容易发现),而不是静默泄漏(很难发现)。
孤儿子记录的边界决策
子表里存在父 FK 为空的记录(上传中、草稿态)。处理策略不能含糊:
- 「总显示」:会把进行中的 PII 数据暴露给所有 scope
- 「总隐藏」:创建者丢失自己进行中的工作
- 最佳:孤儿记录仅对携带了
creator_dept_id匹配当前 scope 的记录可见——即创建时就打上 scope 标签,过滤逻辑不变
验收清单
修完之后,用窄 scope 账号逐一验:
- [ ] 主列表:只见本 scope 数据
- [ ] 全局搜索:搜不出跨 scope 记录
- [ ] 子资源列表:越权
customerId返回 403 - [ ] SSR 页面:不展示跨 scope 关联记录
- [ ] 导出文件:内容与主列表过滤结果一致
- [ ] grep 确认:无裸调 list reader(不带 scope 参数)
一句话外卖
行级权限加在主列表上只是起点,凡是能返回该实体或其子记录的读路径——搜索、子列表、SSR 加载器、导出——都必须穿上同一件 scope 外套,否则就是只堵了正门、侧门全开。