概述
凌晨 3 点 17 分,我在某电商平台的手机响了。支付成功率从 99.7% 掉到 89%,订单链路开始堆积。
值班同学很快定位到根因:上一轮灰度发布时,一条网络 ACL 规则被误删,导致支付回调流量打不到后端集群。修复动作本身不复杂——把那条规则加回去,或者回滚那次配置提交,3 分钟能搞定。
但这 3 分钟的动作,卡在了权限上。生产网络设备的写权限收在三个人手里:项目负责人、安全负责人、平台主管。三个电话打过去,一个关机,一个在飞机上,一个在外地开会静音。等第三个回电话并签完字,已经是 3 点 57 分。
40 分钟过去,止血窗口错过了。当天 MTTR 从常态的 8 分钟拉到 48 分钟,事后算下来,这段延时的经济损失比那条规则本身大得多。
复盘会上有人提了一个很自然的方案:给值班工程师开常备权限,不就快了吗?也有人提了另一个极端方案:所有变更一律事前审批,谁也别想绕过。这两种方案我在不同项目里都见过,而且都翻过车。
真正成熟的做法,是把这两个极端中间那条路走通——零常备权限(Zero Standing Access,ZSA)+ JIT 临时提权(Just-In-Time,用时才给、到点就收)+ 预置的 break-glass 应急通道 + 全链路操作审计。
break-glass 这个词来自消防箱:玻璃罩着灭火器,平时谁都拿不到,真着火了砸碎玻璃就能用。它解决的问题是"正常情况下权限必须收紧,但极端情况下不能等审批"。而操作审计解决的问题是"砸了玻璃之后,你必须说清楚你拿了灭火器干了什么"。
这套体系我在等保 2.0 合规改造、K8s 迁移和多地生产环境里落地过几轮,从每月 40 次非计划提权事件收敛到 6 次,中间踩过的坑比预想的多。这篇文章把它拆成 6 个工程决策:
- 常态零权限 + JIT 临时提权:为什么常备 root 必须收敛,收敛到什么程度
- break-glass 通道必须预置:凭证封存、独立第二通道、定期演练、用即告警
- 限时最小范围:TTL 取 15–30 分钟而不是 4–8 小时,以及到期自动回收的完整实现
- 触发即广播:SEV1 免审批只广播、SEV3 走审批的分级决策表
- 全链路审计留痕:堡垒机入口、命令级审计、会话录制、与事件时间线关联
- 事后强制复盘与超期权限巡检:24 小时内复盘,自动找出忘了回收的权限
如果你正在做权限收敛、等保合规或者 On-Call 机制建设,值班和故障分级是应急响应的另一半,这篇讲的是权限和审计这一半,两半合起来才是一套完整的应急响应能力。
一、常态零权限:为什么常备 root 是最大的技术债
先讲清楚一个前提:应急访问的问题,根子不在应急,而在常态。如果平时就没有常备高权限,那么"故障时谁能提权"才是一个需要被设计的问题;如果平时人人都是 root,那"提权"根本不是问题,问题变成了"谁都能在生产上乱来"。
常备权限(Standing Access)的代价
常备权限指账号长期持有生产写权限,登录即有。它的隐性成本有四层:
- 不可归因:多人共用账号时,audit log 里的
admin到底是张三还是李四,查不出来 - 凭证泄露面放大:账号 A 泄露会影响全量资产,而不是它本来该管的那一小块
- 权限腐化:一个人三个月前做过一次网络变更,留下的高权限没人收,半年后调岗了权限还在
- 合规不合规:等保 2.0 明确要求"最小权限"和"操作可审计",常备 root 满足不了这两条
我在某新能源物流平台做 120+ 微服务 K8s 迁移时,最初为了方便,给平台组 6 个人配了带 cluster-admin 的 kubeconfig。迁移 3 个月零故障,但权限审计一盘点,发现问题:6 个 kubeconfig 有 2 个被拷到了跳板机共享目录,1 个的 token 从来没轮换过。迁移的稳定性成绩漂亮,权限卫生一塌糊涂。后来花了两周把 cluster-admin 全部换成命名空间级 Role + JIT 提权,才补上这一课。
Google 在 Emergency Response 里强调,事故响应需要有明确授权和清晰的操作边界,而不是"谁都能碰生产"。它讲的是响应流程,但底层前提就是权限必须可控。
ZSA 权限收敛模型
我的收敛模型分三层,从下到上权限递增、持有时间递减:
| 层级 | 权限形态 | 持有时间 | 典型范围 | 谁有 |
|---|---|---|---|---|
| L0 常态只读 | 只读账号 / 只读 Role | 长期 | 全部生产(日志、指标、只读 API) | 全体研发与 SRE |
| L1 常态操作 | 受控自助操作(平台化封装的幂等动作) | 长期 | 重启、扩缩容、拉日志 | On-Call 工程师 |
| L2 临时提权 | JIT 提权 / break-glass | 15–30 分钟 | 单主机、单命名空间、单命令集 | 按需签发,用完回收 |
L0 是关键设计。很多人以为"收紧权限"就是让工程师什么都看不到,结果排查时两眼一抹黑,只能去找有权限的人代查,效率更低。正确做法是把只读权限放大到足够大——日志、指标、分布式追踪、只读 API 全部开放,写权限才收紧。在我负责的项目里,把生产只读权限对全体研发开放之后,故障定位时间从 30 分钟压到 8 分钟,因为再也不用"等人开权限看日志"了。
这套分层和微软在 RBAC best practices 里的建议是同一个思路:权限绑到角色而不是绑到人,因为角色可枚举、可复核,人和临时小组都会漂移。区别只在于它讲的是云上身份体系的角色分配,这里讲的是生产运维通道的收敛。
相关文章:从“群里 @ 运维”到自助执行:20+ 运维操作平台化的 6 层架构与 4 个落地阶段 说的就是 L1 这一层——把高频、幂等、低风险的运维动作封装成平台自助操作,工程师自己点,不用找人。L2 是这篇文章的重点。
三种权限模型的取舍
主流方案有三种,没有一种是银弹。这张表是我在选型时实际拿来对照取舍的:
| 维度 | 常备高权限 | 全量事前审批 | JIT + break-glass |
|---|---|---|---|
| 事故时生效速度 | 秒级 | 分钟到小时(取决于审批人在不在) | 秒级到分钟级 |
| 权限收敛度 | 差(权限长期外露) | 好 | 好(每次只放一个短窗口) |
| 可归因性 | 差(共享账号) | 好 | 好(每次签发对应一个工单/事件) |
| 运维体验 | 爽(但背锅) | 差(天天等审批) | 中等(首次有学习成本) |
| 长期技术债 | 高 | 中(催生绕流程行为) | 低 |
| 适用边界 | 只有极小的、无生产写权限的隔离环境 | 强合规但变更低频、可等待的系统 | 绝大多数有 On-Call 的生产系统 |
我推荐:只要你的系统有 7×24 的 On-Call 和明确的可用性目标,就用 JIT + break-glass。全量事前审批看起来最安全,实际会催生两种坏行为:一是把紧急变更伪装成"日常变更"提前批掉,二是事故时干脆不修、等审批——两种都让风险更大。常备高权限只在一种场景下成立:环境完全隔离、数据可重建、故障影响面为零的沙箱。生产环境上,我不接受常备高权限。
有一点必须说清楚:JIT 和 break-glass 不是一回事,很多团队混着用,结果两者都做不好。它们的区别是:
| 维度 | JIT 临时提权 | break-glass 应急通道 |
|---|---|---|
| 触发条件 | 计划内的、可等待的(如例行变更、排查) | 事故中、审批链不可用(SEV1/SEV2) |
| 审批 | 事前审批(快审批,自动或人工) | 免审批、事后审计 |
| 通道 | 走主认证系统(SSO/IdP) | 必须有独立于主认证系统的第二通道 |
| 额度 | 常规额度 | 严格限量,用即告警 |
| 心理预期 | 日常工具 | 核按钮 |
设计原则一句话:JIT 是日常通勤工具,break-glass 是消防斧。消防斧不能天天拿来开快递箱,但也不能在着火时发现箱子里是空的。
二、break-glass 通道必须"预置",不能临时造
第一条铁律:应急通道必须命题作文,不能考场现写。
我见过最典型的翻车是这样的:某次故障,值班同学发现主认证系统 SSO 挂了(很多故障会连带 IdP 一起挂),于是想绕过 SSO 走本地账号——结果发现本地应急账号从来没启用过,密码在某个离职同事的密码管理器里。等他找到账号、重置密码、确认权限范围,一个小时过去了。
为什么临时造通道一定失败
因为"造通道"这个动作本身依赖于一个很可能也在故障中的系统。主认证系统挂、网络分区、CI/CD 挂了、堡垒机挂了——这些都可能同时发生。而应急通道必须是在这套系统全都挂掉时依然可用的那条路。所以它必须:
- 独立于主认证系统(不能是"另一个 SSO 集群",而是一套完全独立的凭证体系)
- 独立于主授权链路(不依赖 CI/CD、不依赖平台 API)
- 独立存储(不能和其他凭证放在同一个 Vault 里,否则 Vault 挂了它一起挂)
凭证封存(Sealed Envelope)
break-glass 的核心实现叫"封存信封":把应急凭证离线封存,正常状态下无人知道,使用需要打破封条,而打破封条的动作本身就会触发告警。
我在项目里的做法是三段式:
- 凭证生成:一次性生成高熵凭证(如 32 字节随机密码或带
expirationSeconds的短期证书),不入库、不展示给任何人 - 封存:用 GPG 对凭证加密,加密后的密文存入一个独立的、被严格监听的存储桶;解密私钥由 2–3 名核心成员分片持有(Shamir 秘密共享,例如 3 取 2)
- 监封:对密文对象的任何读取、下载、元数据访问都触发实时告警,直达值班群和安全负责人
这里的关键是第 2 步的"分片":任意单个成员都解不开,需要至少两人协作。这既是技术上的双人控制,也是合规上等保要求的"重要的操作需多人授权"。
独立第二通道
独立通道不只是"另一个登录入口",还包括网络路径和操作入口:
- 不经过主堡垒机(主堡垒机可能也挂了),而是一条独立的跳板路径
- 凭证不是"主账号的超级权限",而是预先定义好的一小组具体权限——比如"只能改网络 ACL 和 K8s 的 Deployment 回滚",而不是 cluster-admin
我推荐:break-glass 用独立第二通道,而不是"把 RBAC 放宽一点点"。放宽 RBAC 的代价是它一直放宽着——你为了应急多给的那点权限,会在 99% 的正常时间里潜伏着、被日常误用、被渗透利用。独立通道是"平时关闭、紧急打开",它的存在不改变常态权限面。代价是应急时多一步走独立通道的动作,但这多出来的一步换来了常态的干净。
定期演练:不演练的应急通道等于没有
这是我在实际项目里踩过最贵的一个坑:我们部署了完善的 break-glass 封存机制,两年都没用过。直到某次真实演练,打开封存信封才发现——封存凭证已经 18 个月没轮换,而它绑定的那个应急账号,因为公司组织架构调整,早就被停用了。
演练要做的事很清楚:
- 每季度至少一次,亲自走完"解封 → 登录 → 执行一个无害的验证动作 → 重新封存 → 轮换凭证"
- 每次演练都验证:封存是否完好、私钥分片是否齐全、凭证是否有效、目标系统是否可达、告警是否触发
- 演练后强制轮换凭证并重新封存(演练本身就是一次"使用",用过就必须换)
下面这个脚本可以直接放进 Cron,做封存凭证的完整性巡检:
#!/usr/bin/env bash
# file: seal-check.sh
# 作用:巡检 break-glass 封存凭证的完整性、有效期与轮换时间
# 依赖:gpg, jq, awscli(或 s3cmd, 视存储后端而定), curl(用于告警 webhook)
# 输入:环境变量 SEAL_BUCKET / SEAL_KEY / ALERT_WEBHOOK
# 输出:标准输出打印巡检结果;异常时退码 1 并推送到告警 webhook
# 建议频率:每日一次。为什么每日:凭证轮换本身不频繁,但"被封存对象被误删/被改动"这类
# 事故需要快速发现,日巡检能在低成本下把最大发现延迟压到 24 小时内。
set -euo pipefail
SEAL_BUCKET="${SEAL_BUCKET:-sre-breakglass-seal}"
SEAL_KEY="${SEAL_KEY:-prod/emergency/envelope.gpg}"
ALERT_WEBHOOK="${ALERT_WEBHOOK:-}"
STALE_DAYS=180 # 超过 180 天未轮换即判为异常。为什么是 180:等保要求密钥类凭据定期轮换,
# 而应急凭据使用频率极低,半年是一个既能覆盖常见合规口径、
# 又不至于把轮换成本抬得过高的折中值。
EXIT=0
alert() {
echo "[ALERT] $1"
if [[ -n "${ALERT_WEBHOOK}" ]]; then
# 用 --data-urlencode 避免消息里的特殊字符破坏 JSON
curl -sS -X POST -H 'Content-Type: application/json' \
--data-urlencode "text=break-glass 封存巡检异常: $1" \
"${ALERT_WEBHOOK}" >/dev/null || true
fi
EXIT=1
}
# 1) 封存对象是否存在
if ! aws s3api head-object --bucket "${SEAL_BUCKET}" --key "${SEAL_KEY}" >/dev/null 2>&1; then
alert "封存对象不存在: s3://${SEAL_BUCKET}/${SEAL_KEY}"
exit "${EXIT}"
fi
# 2) 读取对象元数据:最后修改时间 + 是否被标记为最新版本
META=$(aws s3api head-object --bucket "${SEAL_BUCKET}" --key "${SEAL_KEY}" \
--query '{lm:LastModified,ver:VersionId}' --output json)
LAST_MODIFIED=$(echo "${META}" | jq -r '.lm')
echo "封存对象最后修改时间: ${LAST_MODIFIED}"
# 3) 距上次轮换的天数
LAST_TS=$(date -u -jf "%Y-%m-%dT%H:%M:%S" "${LAST_MODIFIED%%.*}" +%s 2>/dev/null || \
date -u -d "${LAST_MODIFIED}" +%s)
NOW_TS=$(date -u +%s)
AGE_DAYS=$(( (NOW_TS - LAST_TS) / 86400 ))
echo "距上次轮换: ${AGE_DAYS} 天"
if (( AGE_DAYS > STALE_DAYS )); then
alert "封存凭证已 ${AGE_DAYS} 天未轮换,超过阈值 ${STALE_DAYS} 天"
fi
# 4) 私钥分片是否齐全(分片存于不同成员的独立存储,这里只校验对象存在)
for i in 1 2 3; do
if ! aws s3api head-object --bucket "${SEAL_BUCKET}" --key "shares/shard-${i}.gpg" >/dev/null 2>&1; then
alert "私钥分片缺失: shard-${i}"
fi
done
# 5) 校验密文可解密性(仅用公钥试解,不落盘明文)
# 为什么只做“可解密性”校验:完整解密会产生明文材料,巡检不应持有明文。
# 这里用 gpg 的 --list-packets 检查密文结构完整,不输出明文。
if ! gpg --list-packets <(aws s3 cp "s3://${SEAL_BUCKET}/${SEAL_KEY}" - 2>/dev/null) >/dev/null 2>&1; then
alert "封存密文结构异常,可能已损坏"
fi
echo "巡检完成,退码 ${EXIT}"
exit "${EXIT}"
用即告警:一次使用必须是一次可见事件
封存对象的"读"操作必须实时告警,且要告到不该被同一个人忽略的多渠道:值班群 + 安全负责人 + 事件管理看板。原因很直接:break-glass 的使用意味着"有人正在绕过常规权限体系做生产变更",这本身就是需要被知情的最高级别事件之一。
Managing Incidents 里讲事故沟通的第一原则是"让正确的人知道正在发生什么"。break-glass 告警其实就是把"权限正在被打破"作为一条一等事故信息播出去。
三、限时最小范围:TTL 15–30 分钟,到期自动回收
确定了"用即告警",下一个决策是:给多久?给多大范围?
TTL 为什么是 15–30 分钟,不是 4–8 小时
早期版本我把 TTL 设成了 8 小时,理由是"一个班次内持续可用,省得反复申请"。这个决定后来让我付出了代价:巡检发现有 3 个"僵尸应急账号"长期存活——它们对应的故障早就复盘完了,权限却还挂着,其中一个账号的凭证出现在了一次未授权扫描的尝试里。
8 小时的问题有三个:
- 覆盖了大部分工作时段,实质上退化成"准常备权限"
- 故障复盘通常在故障后 1–2 小时就基本定型,“还需要继续改"的情况很少真正用到 8 小时
- 一旦忘记回收(人一定会忘),它就成了僵尸权限
改成 15–30 分钟后,非计划提权事件从每月 40 次收敛到 6 次。这个数字看起来反常——收紧权限,提权事件反而变少?原因在于:TTL 短了之后,工程师倾向于在提权窗口内把事做完,而不是"先申请着备用”。申请了不用的权限大幅减少,未回收的权限几乎归零。
同样的判断不只出现在运维通道上。微软的 Just-in-Time (JIT) VM access 把 JIT 的核心设计定为"按需放行 + 到点自动关闭",而不是"长期开放 + 事后审计"——本质就是承认人一定会忘记回收,所以回收必须由系统做,不能靠人做。
我推荐:常规应急访问 TTL 取 15 分钟;涉及跨主机批量操作或需要验证的复杂变更,取 30 分钟。不推荐 4–8 小时,也不推荐 5 分钟以下——TTL 太短会逼着工程师在窗口中途反复重申请,反而制造操作碎片和更多审计记录噪声。判断标准很简单:TTL 应该略大于"完成这个动作实际需要的时间",而不是"我这班还想干多少事"。
最小范围:三个维度同时收
TTL 管的是时间,范围管的是空间。范围要在三个维度同时收:
- 对象:单主机 / 单命名空间 / 单资源名(用
resourceNames白名单,而不是整个 API 组) - 动作:只给完成当前任务需要的 verb(如
patch+deployment,不给delete) - 身份:一次签发只对应一个人、一个事件,不允许"一个凭证多人共用"
这三点里,第三点最容易被忽视,也最致命——共享凭证等于放弃了可归因性,前面所有审计投入全部作废。
完整实现:一个限时提权与自动回收服务
下面这套代码是一个零外部依赖、可直接运行的 JIT 授权服务,用 Python 标准库写成(http.server + sqlite3),方便你在任何机器上 30 秒内跑起来验证逻辑,再按需替换后端。
#!/usr/bin/env python3
# file: jit_grantd.py
"""
jit-grantd —— 最小可运行的限时提权(JIT)与自动回收服务。
【依赖】仅 Python 3.9+ 标准库,无需 pip 安装任何包。
生产环境建议替换 http.server 为 FastAPI/uvicorn,并在前置网关做 mTLS,
但核心的“签发-到期-回收-审计”状态机与本文完全一致。
【运行】
python3 jit_grantd.py # 监听 127.0.0.1:8787
【输入】
POST /grant {"subject":"alice","resource":"ns/payment","verbs":["patch"],
"ttl_seconds":900,"reason":"INC-20261027-0317","severity":"SEV1"}
POST /revoke {"grant_id":"g-xxxx"}
GET /grants 列出当前有效授权
【输出】
JSON。每次操作都写入 audit_log 表,包含 who/what/when/why,供事后审计。
"""
import json
import sqlite3
import secrets
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
DB_PATH = "jit_grants.db"
LISTEN = ("127.0.0.1", 8787)
# TTL 上下限。为什么在服务端强制夹取:客户端参数不可信,如果允许任意 TTL,
# 一次“手滑”填了 86400 就退化成常备权限,所以边界必须在服务端收口。
TTL_MIN, TTL_MAX = 300, 1800 # 5 分钟 ~ 30 分钟
def init_db():
con = sqlite3.connect(DB_PATH)
con.executescript(
"""
-- grants:当前有效的临时授权
CREATE TABLE IF NOT EXISTS grants (
grant_id TEXT PRIMARY KEY,
subject TEXT NOT NULL, -- 谁
resource TEXT NOT NULL, -- 对什么(最小范围)
verbs TEXT NOT NULL, -- 能做什么(JSON 数组)
issued_at INTEGER NOT NULL,
expire_at INTEGER NOT NULL, -- 到点必须回收
severity TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active' -- active/expired/revoked
);
-- audit_log:只追加,不允许 UPDATE/DELETE,保证审计链完整
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
action TEXT NOT NULL, -- grant/revoke/expire
grant_id TEXT,
subject TEXT,
detail TEXT NOT NULL -- JSON:包含 reason 等“为什么”
);
CREATE INDEX IF NOT EXISTS idx_grants_expire ON grants(expire_at, status);
"""
)
con.commit()
con.close()
def audit(con, action, grant_id, subject, detail):
"""审计写入与业务写入必须在同一事务里,避免“授了权但没留痕”。"""
con.execute(
"INSERT INTO audit_log(ts, action, grant_id, subject, detail) VALUES (?,?,?,?,?)",
(int(time.time()), action, grant_id, subject, json.dumps(detail, ensure_ascii=False)),
)
def issue(subject, resource, verbs, ttl_seconds, reason, severity):
ttl = max(TTL_MIN, min(TTL_MAX, int(ttl_seconds)))
now = int(time.time())
gid = "g-" + secrets.token_hex(6)
con = sqlite3.connect(DB_PATH)
with con: # 事务:业务与审计同生共死
con.execute(
"INSERT INTO grants(grant_id,subject,resource,verbs,issued_at,expire_at,severity)"
" VALUES (?,?,?,?,?,?,?)",
(gid, subject, resource, json.dumps(verbs), now, now + ttl, severity),
)
audit(con, "grant", gid, subject,
{"resource": resource, "verbs": verbs, "ttl": ttl, "reason": reason})
con.close()
return {"grant_id": gid, "expire_at": now + ttl, "ttl": ttl}
def revoke(gid, by="system"):
con = sqlite3.connect(DB_PATH)
with con:
cur = con.execute(
"UPDATE grants SET status='revoked' WHERE grant_id=? AND status='active'", (gid,)
)
if cur.rowcount:
audit(con, "revoke", gid, by, {"by": by})
con.close()
return {"grant_id": gid, "revoked": True}
def sweeper():
"""
到期回收线程:每 5 秒扫一次,把过期的 active 授权置为 expired 并留痕。
为什么要独立线程而不是在请求里顺手回收:授权可能在没有任何后续请求的情况下
自然到期(例如工程师提权后什么都没干),如果只靠请求触发回收,
这个“没被用到的权限”就会一直挂着,正是我们要消灭的僵尸权限。
"""
while True:
try:
con = sqlite3.connect(DB_PATH)
now = int(time.time())
rows = con.execute(
"SELECT grant_id, subject FROM grants WHERE status='active' AND expire_at<=?",
(now,),
).fetchall()
if rows:
with con:
for gid, subject in rows:
con.execute("UPDATE grants SET status='expired' WHERE grant_id=?", (gid,))
audit(con, "expire", gid, subject, {"expired_at": now})
con.close()
except Exception as exc: # 回收线程绝不能因单次异常死掉
print("[sweeper] error:", exc)
time.sleep(5)
class Handler(BaseHTTPRequestHandler):
def _json(self, code, payload):
body = json.dumps(payload, ensure_ascii=False).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if urlparse(self.path).path != "/grants":
return self._json(404, {"error": "not found"})
con = sqlite3.connect(DB_PATH)
rows = con.execute(
"SELECT grant_id,subject,resource,verbs,expire_at,severity FROM grants"
" WHERE status='active' AND expire_at>?", (int(time.time()),),
).fetchall()
con.close()
self._json(200, {"grants": rows})
def do_POST(self):
path = urlparse(self.path).path
length = int(self.headers.get("Content-Length", 0))
try:
data = json.loads(self.rfile.read(length) or b"{}")
except json.JSONDecodeError:
return self._json(400, {"error": "invalid json"})
if path == "/grant":
missing = [k for k in ("subject", "resource", "reason") if not data.get(k)]
if missing:
# reason 必填:没有“为什么”的授权在事后审计里毫无价值
return self._json(400, {"error": f"missing: {','.join(missing)}"})
return self._json(200, issue(
data["subject"], data["resource"], data.get("verbs", ["patch"]),
data.get("ttl_seconds", 900), data["reason"], data.get("severity", "SEV3"),
))
if path == "/revoke":
return self._json(200, revoke(data.get("grant_id", ""), data.get("by", "system")))
self._json(404, {"error": "not found"})
def log_message(self, *args):
pass # 默认访问日志噪声大,审计由 audit_log 表承担
if __name__ == "__main__":
init_db()
threading.Thread(target=sweeper, daemon=True).start()
print(f"jit-grantd listening on http://{LISTEN[0]}:{LISTEN[1]} (db={DB_PATH})")
ThreadingHTTPServer(LISTEN, Handler).serve_forever()
跑起来验证一遍:
# 1) 申请一个 15 分钟的临时提权(TTL 会被服务端夹到 300~1800 秒区间内)
curl -s -X POST http://127.0.0.1:8787/grant \
-H 'Content-Type: application/json' \
-d '{"subject":"alice","resource":"ns/payment","verbs":["patch"],
"ttl_seconds":900,"reason":"INC-20261027-0317","severity":"SEV1"}'
# => {"grant_id":"g-3f9a1c2b7e01","expire_at":1761500000,"ttl":900}
# 2) 查看当前有效授权
curl -s http://127.0.0.1:8787/grants
# 3) 一个 10 分钟 TTL 的授权,10 分钟后会被 sweeper 置为 expired,并留下 expire 审计记录
这个服务本身不直接操作系统,它是一个"授权账本"。真正的放行由下游执行器读取本服务状态后决定(例如一个 K8s 准入控制器、或者一个堡垒机的命令代理)。这样的分层很重要:授权逻辑和执行逻辑解耦之后,回收就变成"改账本状态"而不是"追着各个系统删权限"。
落地到 K8s:RBAC 没有 TTL,需要控制器兜底
K8s 原生 RBAC 的致命缺陷是它没有过期时间。RoleBinding 一旦创建就永久有效,所以 JIT 必须在 RBAC 之上加一层控制器。两种做法:
- 短期 Token:用
TokenRequestAPI 签发带expirationSeconds的 ServiceAccount token,token 过期即失效。注意 K8s 允许的最小有效期是 600 秒(10 分钟),所以 TTL 别低于这个值。 - 控制器回收:一个 CronJob 或自研 Controller,扫描带 TTL 注解的 RoleBinding,过期即删除。
# file: jit-rolebinding.yaml
# 一次性、带 TTL 注解的命名空间级授权。注意 verbs 只给 patch,
# 不给 delete——应急恢复配置绝大多数只需要 patch 或 rollout restart,不需要删除权。
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: jit-patch-ns-payment
namespace: payment
annotations:
sre.wang/issued-for: "alice"
sre.wang/incident: "INC-20261027-0317"
sre.wang/ttl-seconds: "900"
sre.wang/expire-at: "1761500000" # 由 jit-grantd 回填,控制器据此回收
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["patch", "get", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: jit-patch-ns-payment-binding
namespace: payment
annotations:
sre.wang/ttl-seconds: "900"
sre.wang/expire-at: "1761500000"
subjects:
- kind: User
name: alice
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: Role
name: jit-patch-ns-payment
apiGroup: rbac.authorization.k8s.io
用 OPA Gatekeeper 或 Kyverno 拦一道,防止有人创建"没有 TTL 注解"的绑定绕过体系:
# file: require-ttl-rolebinding.yaml (Kyverno 策略)
# 为什么要在准入层拦:如果只靠事后巡检回收,巡检周期内的窗口(可能长达数小时)
# 就是漏洞。准入层保证“不符合规范的授权根本创建不出来”,是唯一能在创建瞬间收口的点。
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-ttl-on-jit-rolebinding
spec:
validationFailureAction: Enforce
rules:
- name: jit-binding-must-have-ttl
match:
any:
- resources:
kinds: ["RoleBinding"]
preconditions:
all:
- key: "{{ request.object.metadata.annotations.\"sre.wang/issued-for\" || '' }}"
operator: NotEquals
value: ""
validate:
message: "JIT RoleBinding 必须带 sre.wang/expire-at 注解,且有效期不超过 1800 秒"
pattern:
metadata:
annotations:
sre.wang/expire-at: "?*"
sre.wang/ttl-seconds: "?*"
四、触发即广播:知情优先于阻断
第三个高频争论是:应急提权要不要走审批?我的答案取决于严重级别,不能一刀切。
为什么 SEV1 不该等审批
SEV1 的标准是"核心链路中断、影响大量用户"。此时每多等一分钟审批,损失是线性的、可量化的。前面那个 40 分钟的案例,代价远超一次误操作的潜在风险。
但"不等审批"不等于"不告知"。这里有个关键转变:在 SEV1 场景下,我们放弃的是"事前阻断",换成的是"即时广播 + 事后审计"。 从来就没有"既严格管控又零延迟"的方案,工程决策的本质是选一个代价可接受的那一边。
我推荐:SEV1 走"免审批、触发即广播",事后 24 小时内强制审计复盘;SEV3 及以下走正常 JIT 审批。理由:审批是一种"事前阻断",它的价值在于拦住那些"不必要但想省事"的提权,而这个场景在低级别故障里才常见。到了 SEV1,“是否必要"已经由事故本身回答了——此时再阻断,拦下的不是风险,是止血时间。广播的价值在于它把"事后审计"变成了"实时监督”:所有人都能看到谁在什么时间用了什么权限,滥用会在几分钟内被人问出来,而不是等一个月后的审计报告。
SEV 分级决策表
| 严重级别 | 判定标准 | 审批 | 通道 | TTL | 广播范围 | 复盘时限 |
|---|---|---|---|---|---|---|
| SEV1 | 核心链路中断,影响大量用户 | 免审批 | break-glass 独立通道 | ≤30 min | 值班群 + 安全负责人 + 管理层 + 事件看板 | 24h 内 |
| SEV2 | 核心功能降级或有扩散趋势 | 单人快审(On-Call Lead 即可) | JIT 主通道 | ≤30 min | 值班群 + 事件看板 | 48h 内 |
| SEV3 | 非核心功能异常 | 正常审批 | JIT 主通道 | 15 min | 值班群 | 72h 内 |
| SEV4 | 无用户影响,例行维护 | 正常审批 | JIT 主通道 | 15 min | 无需 | 按变更流程 |
这张表的每一列都对应一个工程约束,别随意改动某一列而不动其他列。比如把 SEV1 的 TTL 从 30 分钟拉到 4 小时,就得同时把广播范围扩大到包含持续监控——否则一个"免审批 + 长 TTL + 广播做不到位"的组合,等于把生产写权限长时间开在无人看管的状态。
广播内容设计:广播必须包含可核验的字段
一条合格的 break-glass 广播不能只有"有人用了应急权限"这句话,必须包含:
- 谁(subject,必须是人,不能是共享账号)
- 对什么(resource,精确到命名空间/主机/设备)
- 能做什么(verbs)
- 为什么(关联的事件号 INC-xxxx 和一句话原因)
- 到什么时候(expire_at)
这里我做了一个不显眼但很管用的设计:广播里的 expire_at 是一个绝对时间戳,而不是"15 分钟后"这类相对描述。 因为值班群里的人散落各地,相对描述会让人各算各的,绝对时间戳才能让"到点为什么还没回收"这件事被一眼发现。
用 Prometheus 就能把"应急授权在用"变成一条可告警的规则,与现有告警体系无缝整合:
# file: breakglass-alert-rules.yaml
# 依赖:jit-grantd 暴露 /metrics(本文的示例服务可加一个 exporter),
# 或由巡检脚本把授权数量推成 Pushgateway 指标。
groups:
- name: break-glass.rules
rules:
# 规则一:任何一次 break-glass 使用,立即告警(不等 for 时间)
# 为什么 for: 0s:应急权限的使用本身就是一等事件,不需要“持续 N 分钟”才报。
- alert: BreakGlassGrantIssued
expr: increase(jit_grant_issued_total{channel="breakglass"}[5m]) > 0
for: 0s
labels:
severity: critical
category: privileged_access
annotations:
summary: "生产 break-glass 提权被使用"
description: >-
过去 5 分钟出现 {{ $value }} 次 break-glass 提权。
subject={{ $labels.subject }} resource={{ $labels.resource }}
incident={{ $labels.incident }}。请立即到事件群确认操作内容。
runbook_url: "https://www.sre.wang/posts/sre-break-glass-emergency-access/"
# 规则二:授权数量异常升高(可能有人在批量申请,或系统被滥用)
- alert: JitActiveGrantsTooMany
expr: jit_grant_active > 20
for: 5m
labels:
severity: warning
annotations:
summary: "当前有效 JIT 授权数量异常({{ $value }} 条)"
description: "正常水位应低于 5 条。请检查是否存在批量申请或授权未回收。"
# 规则三:授权到期后仍在使用(回收失效 = 最危险的信号)
# 为什么单独设这一条:到期未回收意味着账本状态和实际能力出现漂移,
# 这比“权限给多了”更严重——因为它说明回收机制本身坏了。
- alert: JitGrantOverdueStillActive
expr: jit_grant_overdue_active > 0
for: 1m
labels:
severity: critical
annotations:
summary: "存在 {{ $value }} 条已过期但仍处于 active 的授权"
description: "自动回收可能失效,请立即手工 revoke 并排查 sweeper。"
# 规则四:应急提权使用后 24 小时未提交复盘
- alert: BreakGlassReviewOverdue
expr: jit_breakglass_without_review_seconds > 86400
for: 10m
labels:
severity: warning
annotations:
summary: "break-glass 使用已超过 24 小时未提交复盘"
description: "incident={{ $labels.incident }},请补齐事后复盘记录。"
五、全链路审计留痕:从堡垒机入口到事件时间线
权限设计得再好,如果审计留痕不完整,事后依然什么都说不清。我在某次复盘里就吃过这个亏——因为当时命令级审计没做全,只记录了"某台跳板机上有人执行了变更",但无法确定具体改的是哪一台后端主机,导致复盘卡在"根因定位"上整整两天。
统一入口:所有生产操作只从一个门进
审计的第一原则是单点入口。所有生产写操作(包括 break-glass)必须经过同一个入口,这个入口是堡垒机,或者至少是唯一的命令代理。理由:审计链条只有在单入口下才是完整的——如果存在两条以上的进入路径,你就永远无法确定"有没有绕过审计的操作"。
在实战里,我会用网络层强制这件事:生产主机的管理端口只允许堡垒机的源 IP 访问,任何绕过堡垒机直连的行为在网络上就不通。这样审计入口就成了一道无法绕过的结构,而不是一条靠制度约束的规范。
相关文章:Linux 安全加固清单:从最小化到合规 里提到的"管理面网络隔离 + 最小化开放",和这里的单入口设计是同一个思路的两面:一面减少攻击面,一面保证审计完整性。
命令级审计 + 会话录制
审计分三层,逐层加深:
| 层级 | 记录内容 | 作用 | 成本 |
|---|---|---|---|
| 账号级 | 谁、何时、从哪个 IP 登录 | 定位到人 | 极低 |
| 命令级 | 执行了哪些命令、退出码、耗时 | 定位到动作 | 中 |
| 会话级 | 完整终端回放(asciinema 风格) | 定位到上下文和意图 | 较高(存储) |
只有账号级审计是不够的。前面那个"无法确定改的是哪台机器"的坑,就是因为只做了账号级、没做命令级。命令级审计里要特别注意记录目标对象标识——比如 kubectl 命令的 -n namespace、--target 参数、SSH 的目标主机、API 调用的资源 ID。没有它,审计记录就只是"某人敲了某些字",不是"某人对某个对象做了某事"。
会话录制别默认全量开,它的存储成本很高。我的做法是:常备账号只做命令级审计,只有 break-glass 提权才强制开启完整会话录制。因为会话录制的核心场景是"复盘一次异常授权下的完整操作过程",而这恰好只在应急提权时发生。
合规口径上,等保 2.0 对审计记录有留存期限要求,落到工程上一般取不少于 6 个月。腾讯云开发者社区的堡垒机与数据审计的等保合规实践对这类"审计记录留存 + 集中管理 + 防篡改"的落地细节有比较完整的梳理,可以作为合规路径的参考。微软的 Microsoft Cloud Security Benchmark:Privileged Access 也把"特权访问必须可审计、可追溯"列为核心控制项,并明确建议采用 JIT 授权而非常备权限。
审计表设计:把"为什么"也存下来
留痕最容易被忽略的字段是"为什么"。一个只有 who/what/when 的审计表,在复盘时无法回答"这个操作在当时是否合理"。所以在设计审计表时,reason 和事件关联字段必须是一等公民。
-- file: audit_schema.sql
-- 设计要点:审计表只追加,不更新、不删除;所有写入必须和业务操作同事务。
-- 字段设计围绕复盘需要的五个问题:谁、对什么、做了什么、为什么、什么时候。
CREATE TABLE access_audit (
id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL DEFAULT now(), -- when
actor TEXT NOT NULL, -- who(必须是具体人,禁止 shared)
actor_source_ip INET NOT NULL, -- 从哪来(辅助归因)
channel TEXT NOT NULL, -- 通道:jit / breakglass / standby
grant_id TEXT, -- 关联授权记录
resource_type TEXT NOT NULL, -- 对象类型:host/namespace/device
resource_id TEXT NOT NULL, -- 对象标识(复盘定位的关键)
action TEXT NOT NULL, -- 做了什么:patch/rollback/restart
command TEXT, -- 原始命令(命令级审计)
session_id TEXT, -- 会话 ID(与会话录制关联)
incident_id TEXT, -- 关联事件号(串起时间线)
reason TEXT NOT NULL, -- 为什么(复盘必需)
outcome TEXT NOT NULL, -- success/failure
approval_ref TEXT -- 审批工单号(有审批时非空)
);
-- 查询模式:复盘时按事件号聚合全部操作,按时间排序还原时间线
CREATE INDEX idx_audit_incident ON access_audit (incident_id, ts);
-- 查询模式:审计时按人查一段时间内的全部生产操作
CREATE INDEX idx_audit_actor_time ON access_audit (actor, ts DESC);
-- 审计表必须防篡改。生产做法:撤销普通账号的 UPDATE/DELETE 权限,
-- 只保留 INSERT,并通过数据库审计或外部日志落一份只读副本。
REVOKE UPDATE, DELETE ON access_audit FROM app_rw;
复盘时用一条 SQL 就能把一次事件的完整操作时间线拉出来,和告警、事件记录对照:
-- 还原某个事件下所有生产操作,按时间排序,与告警时间线对照
SELECT
a.ts AS 操作时间,
a.actor AS 操作人,
a.channel AS 通道,
a.resource_id AS 对象,
a.action AS 动作,
a.outcome AS 结果,
a.reason AS 原因
FROM access_audit a
WHERE a.incident_id = 'INC-20261027-0317'
ORDER BY a.ts;
与事件时间线关联
审计留痕孤零零地存在数据库里,价值有限。它的价值在于和告警、变更、事件记录关联起来,形成一条可对照的时间线。我要求每次 break-glass 授权都带 incident_id,这样:
- 告警在 03:17 触发
- break-glass 在 03:19 签发(原因挂在同一个 incident)
- 实际变更在 03:20–03:24 执行(命令级审计)
- 授权在 03:34 自动回收
- 复盘在次日提交
这条时间线一旦对上,复盘时讨论的就不再是"我印象里当时好像是……",而是"审计记录显示 3 点 19 分签发的授权,实际动作发生在 3 点 20 分,中间那 1 分钟在等什么"。这个精度对改进应急流程至关重要。
六、事后强制复盘与超期权限巡检
应急访问体系的最后一环是收尾。没有收尾的应急体系会慢慢腐化——权限忘了收、复盘忘了写、演练忘了做,半年后它就和没有一样。
24 小时内强制复盘
break-glass 使用后 24 小时内必须提交复盘,包含四问:
- 为什么常规权限不够用?(这个问题最容易被跳过,但它指向权限模型的不足)
- 应急操作是否都在授权范围内?(有没有越权,有没有超出 TTL 后用其他方式继续操作)
- 有没有更省权限的替代做法?(下次是不是 JIT 就够了,不需要 break-glass)
- 这次使用暴露了哪些通道/演练上的问题?
我特别看重第 1 问。如果某类故障反复需要 break-glass,说明常规 JIT 的权限范围或者审批速度有问题,需要调整。break-glass 的使用频率本身是一个 KPI——它应该在低位稳定,既不能长期为零(说明通道没人验证过),也不能频繁使用(说明常态权限设计有问题)。
超期权限自动巡检
人一定会忘记回收权限,这是必然,不是例外。所以必须有自动巡检兜底。下面这个 Python 脚本扫几个常见的权限源,找出超期未回收的授权:
#!/usr/bin/env python3
# file: stale_grant_audit.py
"""
超期权限巡检 —— 扫描各权限源,找出“已过期但仍存在”的授权并告警。
【依赖】标准库 + requests(pip install requests);kubectl/aws 等 CLI 需在 PATH 中。
【输入】无(配置在脚本头部的 SOURCES 里)。
【输出】超期授权清单;有发现时退码 1。建议由定时任务每日运行,或接进值班日报。
【为什么用巡检兜底】自动回收服务负责“正常路径”上的回收,但如果 sweeper 崩了、
权限被直接建在 K8s 里绕过了 jit-grantd、或者授权是通过控制台手工加的,
这些都不会被自动回收覆盖。巡检是最后一道防线,作用类似数据库的定期一致性校验。
"""
import json
import subprocess
import sys
import time
from datetime import datetime, timezone
import requests
JIT_API = "http://127.0.0.1:8787"
GRACE_SECONDS = 300 # 允许 5 分钟宽限,避免与 sweeper 的扫描周期抢跑
def stale_from_jit():
"""检查 jit-grantd 里是否有过期但仍 active 的授权(回收失效信号)。"""
stale = []
try:
grants = requests.get(f"{JIT_API}/grants", timeout=5).json().get("grants", [])
except requests.RequestException as exc:
# API 不可达本身也是异常,但不应让整轮巡检失败——只是无法覆盖这一源
return [{"source": "jit", "error": f"api unreachable: {exc}"}]
now = time.time()
for g in grants:
# 字段顺序与 jit_grantd 的 SELECT 一致
gid, subject, resource, _verbs, expire_at, _sev = g
if expire_at + GRACE_SECONDS < now:
stale.append({
"source": "jit", "grant_id": gid, "subject": subject,
"resource": resource,
"expired_for_seconds": int(now - expire_at),
})
return stale
def stale_from_k8s():
"""扫描带 sre.wang/expire-at 注解但已过期的 RoleBinding。"""
stale = []
cmd = [
"kubectl", "get", "rolebindings", "--all-namespaces", "-o", "json",
]
try:
out = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=30).stdout
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
return [{"source": "k8s", "error": str(exc)}]
now = int(time.time())
for item in json.loads(out).get("items", []):
ann = (item.get("metadata", {}).get("annotations") or {})
expire_at = ann.get("sre.wang/expire-at")
if not expire_at:
continue
try:
if int(expire_at) + GRACE_SECONDS < now:
stale.append({
"source": "k8s",
"binding": f'{item["metadata"]["namespace"]}/{item["metadata"]["name"]}',
"issued_for": ann.get("sre.wang/issued-for", "unknown"),
"expired_for_seconds": now - int(expire_at),
})
except (TypeError, ValueError):
continue
return stale
def main():
findings = []
for fn in (stale_from_jit, stale_from_k8s):
findings.extend(fn())
ts = datetime.now(timezone.utc).isoformat()
print(f"[{ts}] 超期权限巡检,发现 {len(findings)} 项")
for f in findings:
print(" -", json.dumps(f, ensure_ascii=False))
if findings:
# 这里对接告警:可以推 webhook、发值班群、或写入日报
print("存在超期权限,请立即处理:先手工 revoke,再排查自动回收为何失效。")
return 1
print("无超期权限。")
return 0
if __name__ == "__main__":
sys.exit(main())
权限回收的验证回路
巡检发现了问题还不够,还要验证"回收确实生效了"。我在项目里要求回收后做一次实际验证:授权标记为 revoked 之后,用一个探针尝试执行那个被回收的动作,确认它真的被拒绝了。
这个验证很关键,因为它能发现"账本状态更新了但实际权限没撤掉"的情况——比如 K8s 的 RoleBinding 删了,但某处还缓存着一个长有效期的 token。这种情况在只做账本审计时完全看不出来,因为账本显示"已回收",一切正常。审计应该验证的是实际能力,而不是账本状态。 这一条经验来自前面提过的"僵尸应急账号"事故:当时审计表显示那些账号已回收,但实际权限还在,因为回收动作只改了账本、没改实际绑定。
总结
回到凌晨 3 点 17 分那个场景。如果当时有这套体系,流程会是:
- 告警触发,值班同学确认是 SEV1
- 通过 break-glass 独立通道获得一个 30 分钟的应急授权(免审批,但广播到值班群、安全负责人和事件看板)
- 在哨兵机强制会话录制下,用最窄的权限(只 patch 网络 ACL)把规则加回去,3 分钟恢复
- 授权 30 分钟后自动回收,sweeper 留痕
- 次日提交复盘,SQL 拉出完整时间线,确认每一步都在授权范围内
- 巡检脚本确认没有残留权限;如果发现连续几次网络类故障都需要 break-glass,就说明常规 JIT 里应该加上网络设备的预授权
这套体系的价值不在于它多复杂,而在于它把"安全"和"速度"这对本能冲突的目标,用工程手段拆解到了可以共存的程度。六个决策里,我认为最容易被低估的是第 2 个(预置通道)和第 6 个(收尾)——前者决定了体系在真实故障中有没有用,后者决定了体系能不能长期存活。
如果只让我给三条立刻能做的建议:
- 先把 TTL 收到 15–30 分钟。这一条几乎零成本,改个配置就能把僵尸权限降到接近零,效果立竿见影
- 做一次 break-glass 演练,哪怕只是走一遍解封流程。我敢说很多团队的封存凭证都有过期、分片缺失或者账号已停用的问题,不演练永远发现不了
- 给命令级审计加上"目标对象标识"。复盘能不能快速定位根因,八成取决于这一列数据
体系建起来之后,再和变更门禁、On-Call 机制、错误预算串起来,就是一套完整的生产稳定性治理体系。相关文章:审批 3 天还炸了:用错误预算门禁替代人工 CAB,变更类故障减少 60% 讲了变更侧的自动化门禁,和这篇的应急访问,正好覆盖了"计划内变更"和"计划外应急"两条路径。
参考资料与致谢
- Emergency Response — Google SRE Book, Chapter 13,参考了应急响应流程、事故指挥职责划分与"授权明确、边界清晰"的响应原则
- Managing Incidents — Google SRE Book, Chapter 14,参考了事故沟通中"让正确的人知情"的第一原则,用于设计 break-glass 广播的知情范围
- Microsoft Cloud Security Benchmark: Privileged Access — Microsoft,参考了特权访问控制基线中"JIT 授权优先于常备权限"“特权操作必须可审计"的核心控制要求
- RBAC best practices — Microsoft Entra ID 文档,参考了最小权限原则与角色分配的最佳实践,用于设计权限收敛模型
- Just-in-Time (JIT) VM access — Microsoft Defender for Cloud,参考了 JIT 临时放行的实现思路与限时授权的设计模式
- 堡垒机与数据审计的等保合规实践 — 腾讯云开发者社区,参考了审计记录集中留存、防篡改与等保 2.0 合规落地的工程细节
本文中的实战案例与数据均做了泛化处理,不涉及任何具体企业、客户或项目信息。感谢在多个生产环境中一起打磨这套应急访问机制的同事,是你们的复盘和较真让这些决策经得起推敲。