概述

凌晨 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)的代价

常备权限指账号长期持有生产写权限,登录即有。它的隐性成本有四层:

  1. 不可归因:多人共用账号时,audit log 里的 admin 到底是张三还是李四,查不出来
  2. 凭证泄露面放大:账号 A 泄露会影响全量资产,而不是它本来该管的那一小块
  3. 权限腐化:一个人三个月前做过一次网络变更,留下的高权限没人收,半年后调岗了权限还在
  4. 合规不合规:等保 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-glass15–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 的核心实现叫"封存信封":把应急凭证离线封存,正常状态下无人知道,使用需要打破封条,而打破封条的动作本身就会触发告警。

我在项目里的做法是三段式:

  1. 凭证生成:一次性生成高熵凭证(如 32 字节随机密码或带 expirationSeconds 的短期证书),不入库、不展示给任何人
  2. 封存:用 GPG 对凭证加密,加密后的密文存入一个独立的、被严格监听的存储桶;解密私钥由 2–3 名核心成员分片持有(Shamir 秘密共享,例如 3 取 2)
  3. 监封:对密文对象的任何读取、下载、元数据访问都触发实时告警,直达值班群和安全负责人

这里的关键是第 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 管的是时间,范围管的是空间。范围要在三个维度同时收:

  1. 对象:单主机 / 单命名空间 / 单资源名(用 resourceNames 白名单,而不是整个 API 组)
  2. 动作:只给完成当前任务需要的 verb(如 patch + deployment,不给 delete)
  3. 身份:一次签发只对应一个人、一个事件,不允许"一个凭证多人共用"

这三点里,第三点最容易被忽视,也最致命——共享凭证等于放弃了可归因性,前面所有审计投入全部作废。

完整实现:一个限时提权与自动回收服务

下面这套代码是一个零外部依赖、可直接运行的 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 之上加一层控制器。两种做法:

  1. 短期 Token:用 TokenRequest API 签发带 expirationSeconds 的 ServiceAccount token,token 过期即失效。注意 K8s 允许的最小有效期是 600 秒(10 分钟),所以 TTL 别低于这个值。
  2. 控制器回收:一个 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 小时内必须提交复盘,包含四问:

  1. 为什么常规权限不够用?(这个问题最容易被跳过,但它指向权限模型的不足)
  2. 应急操作是否都在授权范围内?(有没有越权,有没有超出 TTL 后用其他方式继续操作)
  3. 有没有更省权限的替代做法?(下次是不是 JIT 就够了,不需要 break-glass)
  4. 这次使用暴露了哪些通道/演练上的问题?

我特别看重第 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 分那个场景。如果当时有这套体系,流程会是:

  1. 告警触发,值班同学确认是 SEV1
  2. 通过 break-glass 独立通道获得一个 30 分钟的应急授权(免审批,但广播到值班群、安全负责人和事件看板)
  3. 在哨兵机强制会话录制下,用最窄的权限(只 patch 网络 ACL)把规则加回去,3 分钟恢复
  4. 授权 30 分钟后自动回收,sweeper 留痕
  5. 次日提交复盘,SQL 拉出完整时间线,确认每一步都在授权范围内
  6. 巡检脚本确认没有残留权限;如果发现连续几次网络类故障都需要 break-glass,就说明常规 JIT 里应该加上网络设备的预授权

这套体系的价值不在于它多复杂,而在于它把"安全"和"速度"这对本能冲突的目标,用工程手段拆解到了可以共存的程度。六个决策里,我认为最容易被低估的是第 2 个(预置通道)和第 6 个(收尾)——前者决定了体系在真实故障中有没有用,后者决定了体系能不能长期存活。

如果只让我给三条立刻能做的建议:

  1. 先把 TTL 收到 15–30 分钟。这一条几乎零成本,改个配置就能把僵尸权限降到接近零,效果立竿见影
  2. 做一次 break-glass 演练,哪怕只是走一遍解封流程。我敢说很多团队的封存凭证都有过期、分片缺失或者账号已停用的问题,不演练永远发现不了
  3. 给命令级审计加上"目标对象标识"。复盘能不能快速定位根因,八成取决于这一列数据

体系建起来之后,再和变更门禁、On-Call 机制、错误预算串起来,就是一套完整的生产稳定性治理体系。相关文章:审批 3 天还炸了:用错误预算门禁替代人工 CAB,变更类故障减少 60% 讲了变更侧的自动化门禁,和这篇的应急访问,正好覆盖了"计划内变更"和"计划外应急"两条路径。

参考资料与致谢

  1. Emergency Response — Google SRE Book, Chapter 13,参考了应急响应流程、事故指挥职责划分与"授权明确、边界清晰"的响应原则
  2. Managing Incidents — Google SRE Book, Chapter 14,参考了事故沟通中"让正确的人知情"的第一原则,用于设计 break-glass 广播的知情范围
  3. Microsoft Cloud Security Benchmark: Privileged Access — Microsoft,参考了特权访问控制基线中"JIT 授权优先于常备权限"“特权操作必须可审计"的核心控制要求
  4. RBAC best practices — Microsoft Entra ID 文档,参考了最小权限原则与角色分配的最佳实践,用于设计权限收敛模型
  5. Just-in-Time (JIT) VM access — Microsoft Defender for Cloud,参考了 JIT 临时放行的实现思路与限时授权的设计模式
  6. 堡垒机与数据审计的等保合规实践 — 腾讯云开发者社区,参考了审计记录集中留存、防篡改与等保 2.0 合规落地的工程细节

本文中的实战案例与数据均做了泛化处理,不涉及任何具体企业、客户或项目信息。感谢在多个生产环境中一起打磨这套应急访问机制的同事,是你们的复盘和较真让这些决策经得起推敲。