返回博客

行级权限只堵了主列表,子列表和搜索还在泄漏

给某后台管理系统做数据权限改造:按部门隔离,不同部门的操作员只能看到自己部门的客户记录。改完主列表接口,跑通测试,上线。结果第二天收到反馈:「我能在搜索框里搜出不属于我部门的客户,还能从子详情页侧边栏看到对方的档案。」

权限漏了——但代码明明加了过滤。

现象

  • 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 主实体的读函数名(如 listCustomersreadRelationalCustomers),找出所有调用点。再 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));
正解:fail-closed 的共享 scope helper + 覆盖全部读路径

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 外套,否则就是只堵了正门、侧门全开。