新加了一个导出接口,代码评审通过了,测试也过了,两周后安全扫描发现它对所有登录用户开放。复盘会上的解释是:接口上没写权限注解,而框架对"没写"的处理是放行。没有人做错决定,决定是缺省值做的。
权限系统最容易被忽略的一层是:它的真实行为不由权限表决定,而由"没配的时候会发生什么"决定。权限表是写下来的规则,缺省姿态是没人写时系统自己走的那条路——没有评审记录、没有测试用例、没有告警,直到有人发现某个不该被访问的东西能访问。
1. 两种体系:缺省允许与缺省拒绝
世界上只有两种权限系统,区别不在功能列表,在缺省方向。
| 维度 | 缺省允许(opt-out) | 缺省拒绝(opt-in) |
|---|---|---|
| 没配权限时 | 可访问 | 拒绝 |
| 出错形态 | 泄漏 | 不可用 |
| 发现方式 | 事后,常由外部发现 | 立刻,自测时就撞上 |
| 成本位置 | 后置,事故时一次性支付 | 前置,每次开发都要支付 |
| 累积风险 | 随接口数量单调上升 | 基本持平 |
| 谁承担成本 | 未来的运维与安全 | 当下的开发者 |
两种体系的成本结构不同,所以"哪个更好"没有统一答案,只有"这种成本你愿意在哪里支付"。缺省允许把成本推给未来和看不见的人;缺省拒绝把成本留在当下、留在写代码的人手里。
多数系统的实际姿态是缺省允许,原因不是有人决策过,而是默认模板就是这样:随便一个 Web 框架,加个路由就通,鉴权是之后的事。系统从第一天起就是这个姿态,之后每个接口都继承它。
2. 为什么"忘了配"没被看见
因为缺省允许的失效不产生任何本地信号。接口写完了,本地测通了,测试通过,部署成功,日志显示请求成功——所有反馈都是正向的。
| 时刻 | 缺省允许下的信号 | 缺省拒绝下的信号 |
|---|---|---|
| 本地开发 | 一切正常 | 访问被拒,必须补声明 |
| Code Review | 看不出来,diff 里没有异常 | 缺声明显得可疑 |
| 自动化测试 | 通过(测试账号权限足够) | 低权限账号跑会失败 |
| 上线 | 成功 | 需要显式放行 |
关键在第四行的反向版本:缺省允许下,"上线成功"与"权限配对了"完全无关;缺省拒绝下,上线本身是一次隐式检查。
更麻烦的是"忘了配"与"故意开放"在日志里长得一模一样——两者都是"该接口被某个身份访问成功",日志里没有意图字段。
| 观察项 | 忘了配 | 故意开放 | 能否区分 |
|---|---|---|---|
| 访问日志 | 有成功记录 | 有成功记录 | 不能 |
| 代码 diff | 没有权限声明 | 有显式公开标记 | 能,前提是存在公开标记 |
| 权限表 | 查不到该接口 | 有一条 public 记录 | 能,前提是缺省拒绝 |
第二、三行的"前提"就是全部差别所在。要区分这两者,系统必须给"开放"一个可写的形状——一个显式标记、一条显式记录。没有形状的开放,与遗漏不可区分。
// 有形状的开放:公开是声明出来的,不是省略出来的
@GetMapping("/api/export/orders")
@RequirePermission("order:export") // 需要什么权限,写在代码里
public byte[] export() { ... }
@GetMapping("/api/health")
@PublicEndpoint(reason = "LB 探测,无业务数据") // 公开必须具名
public String health() { return "ok"; }
这段代码的价值不在注解本身,而在于让"两个注解都没写"变成评审中可见的事:扫一遍 Controller,没有声明的方法就是需要人回答的地方。
3. 从缺省允许改成缺省拒绝,需要五件配套
直接把拦截器改成默认拒绝,第二天全站不可用。姿态切换是工程改造,不是配置项翻转。
| 配套 | 作用 | 缺失后的后果 |
|---|---|---|
| 默认模板 | 新建接口自带权限声明占位 | 开发者继续漏写 |
| 继承规则 | 子资源/子路由继承父级声明 | 每接口单独配,工作量爆炸 |
| 上线检查 | CI 或启动期扫描未声明接口 | 漏写在合并后才发现 |
| 权限基线 | 可复用的角色与权限集合 | 每接口发明一套权限名 |
| 灰度姿态 | 先告警不拦截,再拦截 | 一次性切换导致大面积不可用 |
第一件最容易被忽略:新建接口的模板里没有权限行占位,"缺省拒绝"只是把事故从泄漏换成上线阻塞,而且阻塞得随机——取决于谁先撞上。
// 缺省拒绝:没有声明就不放行,公开必须显式
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) {
if (!(handler instanceof HandlerMethod hm)) return true;
if (hm.hasMethodAnnotation(PublicEndpoint.class)) return true;
RequirePermission rp = hm.getMethodAnnotation(RequirePermission.class);
if (rp == null) {
// 缺省拒绝,并打点:这类请求本身就是要修的问题
Metrics.counter("authz.undeclared_deny", "path", req.getRequestURI()).increment();
resp.setStatus(403);
return false;
}
return Authz.check(req, rp.value());
}
authz.undeclared_deny 是姿态切换期最重要的指标:它的值从"很大"降到"接近零"的过程,就是改造真正完成的进度条。
4. 三个常被漏掉的细节:继承、服务间调用、留痕
继承与合并规则没被写下来。 一个用户同时属于两个角色,权限是并集还是交集?子资源是否继承父资源声明?这些规则通常只存在于拦截器代码里,于是每个人凭印象假设。
| 规则点 | 常见实现 | 风险 |
|---|---|---|
| 多角色叠加 | 并集(多数框架默认) | 只读角色 + 写角色 = 可写 |
| 子资源继承 | 取决于路由匹配方式 | 父级收紧后子级仍开放 |
| 通配符权限 | order:* 覆盖全部动作 |
新增动作时自动被授权 |
| 拒绝优先 | 少见,需要显式实现 | 缺少"一条 deny 覆盖所有 allow" |
| 服务账号 | 独立体系或复用用户体系 | 复用 = 服务能做人能做的一切 |
第三行值得单独说:order:* 在新增动作时会自动扩权。今天它覆盖读写,明天加一个"退款"动作,它同样覆盖。这是缺省允许在权限命名层面的复制品。
服务间调用与用户权限混在一起。 A 调 B 时往往复用同一套 token:要么用高权限服务账号,要么透传用户 token。前者意味着 A 的任何漏洞都能以最高权限打 B,后者意味着 B 无法区分"用户主动发起"与"A 代表用户发起"。
// 服务身份与用户身份分开表达,只授予真正需要的动作
service_identity:
name: order-service
allowed:
- inventory:reserve // 只允许预留,不允许直接改库存
- payment:query
deny_unlisted: true // 未列出的动作一律拒绝
on_behalf_of_user: true // 代表用户发起时必须携带用户上下文
权限变更要留痕。 这与审计证据链是两件事:审计关心"谁访问了什么",这里关心"谁改了谁能访问什么"。后者缺失时,复盘会卡在"这条权限谁加的、什么时候加的"。
// 权限变更事件:没有它,复盘只能靠猜
{
"event": "authz.policy.changed",
"target": "ENDPOINT_OR_ROLE",
"change": "grant|revoke|create|delete",
"actor": "OPERATOR_ID",
"ticket": "CHANGE_ID",
"auto_expire_at": "TIMESTAMP" // 临时授权必须有到期时间
}
auto_expire_at 最容易被省略、也最值得存在:临时授权没有到期时间就会变成永久授权,而没人记得它是临时的。
度量控制在四个数,多了没人看:
─── 1. 缺省姿态覆盖率:显式声明权限的接口 / 全部接口
─── 2. 未配置接口数:既无权限声明也无公开标记的接口
─── 3. 权限相关事故数:根因为"没配/配宽"的复盘次数
─── 4. 权限变更审批率:有工单号的变更 / 全部变更
第 2 个数要在 CI 里跑,而不是季度盘点时跑。季度盘点的结果是"有 47 个接口需要确认",而能确认它的人早已换岗。
5. 排错:五种"权限看起来生效了其实没有"
| 现象 | 常见原因 | 处理方向 |
|---|---|---|
| 加了注解但仍能访问 | 校验被代理层绕过(内部调用、异步线程) | 把校验下沉到网关或统一入口 |
| 低权限账号能看到数据 | 接口无声明,走缺省放行 | 扫描未声明接口,先告警后拦截 |
| 前端隐藏按钮但接口可直连 | 权限只做在展示层 | 以服务端判定为准 |
| 加新动作后老角色自动有权 | 通配符权限扩权 | 改用动作枚举,禁止通配 |
| 服务间调用权限过大 | 服务账号复用管理员角色 | 拆分服务身份,按动作授权 |
排错顺序:先确认请求在服务端被哪段代码判定 → 再确认判定用的是哪条规则 → 最后怀疑角色配置。多数"权限没生效"的问题出在第一步:请求根本没走到判定逻辑。
6. 边界:这些场景不需要正式的姿态机制
| 场景 | 简化做法 | 理由 |
|---|---|---|
| 内部单租户工具,使用者同一团队 | 登录即准入 | 权限粒度收益低于维护成本 |
| 调用方只有几个固定服务 | 白名单 + 网络隔离 | 身份体系是过度设计 |
| 权限粒度只有一个层级 | 单一布尔开关,显式写在路由上 | 不需要角色与继承模型 |
| 只读公开数据 | 明确标 public,不做权限 | 但要防止成为"忘了配"的先例 |
| 短生命周期系统 | 明确写"不做鉴权"并登记下线日期 | 前提是下线日期真实存在 |
最危险的是最后一行:临时系统最容易变成永久系统。判断标准很简单——如果这个系统没有下线日期,它就不属于"短生命周期",姿态机制就得补上。
自测
- 权限系统的真实行为由什么决定,而不是由权限表决定?
- 缺省允许与缺省拒绝,成本各落在谁身上、什么时间点?
- 为什么"忘了配"与"故意开放"在日志里无法区分?需要什么才能区分?
- 切换到缺省拒绝时,缺哪件配套会导致上线阻塞而不是泄漏?
- 通配符权限为什么是一种隐藏的缺省允许?
- 临时授权为什么必须有到期时间字段?