返回博客

权限从 fail-open 翻到 fail-closed,上线后用户看到空白页

写一个安全收口 PR 不难——把 if (!perm) return true 改成 if (!perm) return false,方向绝对正确。难的是你改完之后,系统里已经存在的一批角色,之前靠着"无配置 = 默认放行"活得好好的,这次上线直接从"全部可见"变成"什么都看不到"。用户打开后台,页面空白,打电话问是不是系统挂了。

现象

某后台管理系统的报表权限谓词长期是 fail-open 写法:

// 旧逻辑:缺配置 = 放行
function canView(reportId, perms, activeRoleId) {
  if (!activeRoleId) return true         // 角色还没加载完,放行
  const p = perms[reportId]
  if (!p) return true                    // ← 没有配置行,默认允许
  if (p.viewRoles.length === 0) return true  // ← 空数组,默认允许
  return p.viewRoles.includes(activeRoleId)
}

安全收口 PR 把它改成 fail-closed:

// 新逻辑:缺配置 = 拒绝
function canView(reportId, perms, activeRoleId) {
  if (!activeRoleId) return true         // loading 态保持放行(这没问题)
  const p = perms[reportId]
  if (!p) return false                   // ← 没有配置行,直接拒绝
  return p.viewRoles.includes(activeRoleId)  // 空数组也是拒绝
}

改动本身逻辑无误,PR review 通过,测试用例绿。上线后,几个真实角色的用户反映报表列表全空了。

根因

问题出在爆炸半径的隐形性:权限表(report_role_permissions)里从来没有给某些角色写配置行——历史原因,这些角色当初"没什么要限制的,就不配置了"。fail-open 时代这没问题,但 fail-closed 一上线,perms[reportId] 找不到行就立即返回 false,整个报表列表对这些角色归零。

这类爆炸半径的反直觉之处在于:你改的是新逻辑,受影响的是旧数据。测试环境数据库往往是干净的,不会暴露生产里这些"空白配置"角色。

坑:安全收口上线前没做存量角色审计

fail-open → fail-closed 是正确的安全方向,但凡是"缺配置 = 放行"活了一段时间的系统,一定存在靠这个隐式默认活着的角色。上线前如果不做存量审计,这些角色的用户会在生产环境里撞到空白页。

正解

上线前,先跑一条审计 SQL,找出所有在线活跃角色中、在权限表里零配置行的角色,并统计它们绑定的活跃用户数:

SELECT
  r.id,
  r.code,
  r.name,
  COUNT(p.id) AS config_count,
  (
    SELECT COUNT(*)
    FROM user_role_binding ur
    WHERE ur.role_id = r.id AND ur.is_deleted = 0
  ) AS active_users
FROM roles r
LEFT JOIN report_role_permissions p
  ON p.role_id = r.id AND p.is_deleted = 0
WHERE r.is_deleted = 0
  AND r.is_active = 1
GROUP BY r.id, r.code, r.name
HAVING config_count = 0
ORDER BY active_users DESC;

拿到结果后,对每个受影响角色做出明确决策,在 PR 合并前完成补救:

| 情况 | 对策 | |---|---| | 重要操作角色 + 有活跃用户 | 补写基线配置行(明确该角色能看哪些报表) | | 角色刻意留空(权限未决) | 上线前先合 UX 空状态 PR("无权限,请联系管理员")作兜底 | | 角色零活跃用户 | 风险低,记录在 PR 描述里即可,不急处理 |

部署顺序同样关键:先补数据 → 再上 UX 空状态 → 最后上 fail-closed 谓词。顺序反了会有一个窗口期,用户直接撞空白格而不是友好提示。

正解:先审计存量角色,再合 fail-closed PR

对任何将"缺配置 = 放行"翻转为"缺配置 = 拒绝"的 PR,上线前必须执行:1) 审计零配置活跃角色;2) 决定是补配置还是上空状态 UX;3) 按正确顺序部署。审计本身只是一条 SQL,5 分钟能做,但省掉的是生产事故。

一句话外卖

安全收口改的是代码默认值,但受害者是生产数据库里那些"依赖旧默认值活着"的存量数据——上线前跑一次角色审计,把隐性依赖变成显性决策。