2993 字
约 9 分钟
16
S22-A13 · 缺省权限与默认姿态:没人配的时候能做什么

新加了一个导出接口,代码评审通过了,测试也过了,两周后安全扫描发现它对所有登录用户开放。复盘会上的解释是:接口上没写权限注解,而框架对"没写"的处理是放行。没有人做错决定,决定是缺省值做的。

权限系统最容易被忽略的一层是:它的真实行为不由权限表决定,而由"没配的时候会发生什么"决定。权限表是写下来的规则,缺省姿态是没人写时系统自己走的那条路——没有评审记录、没有测试用例、没有告警,直到有人发现某个不该被访问的东西能访问。


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,不做权限 但要防止成为"忘了配"的先例
短生命周期系统 明确写"不做鉴权"并登记下线日期 前提是下线日期真实存在

最危险的是最后一行:临时系统最容易变成永久系统。判断标准很简单——如果这个系统没有下线日期,它就不属于"短生命周期",姿态机制就得补上。


自测

  1. 权限系统的真实行为由什么决定,而不是由权限表决定?
  2. 缺省允许与缺省拒绝,成本各落在谁身上、什么时间点?
  3. 为什么"忘了配"与"故意开放"在日志里无法区分?需要什么才能区分?
  4. 切换到缺省拒绝时,缺哪件配套会导致上线阻塞而不是泄漏?
  5. 通配符权限为什么是一种隐藏的缺省允许?
  6. 临时授权为什么必须有到期时间字段?
S22-A13 · 缺省权限与默认姿态:没人配的时候能做什么
https://www.clxhxhhr.top/posts/6371/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。