权限视图服务 denied 恒 0:同具体度的 deny 被 allow 压掉 —— 把「谁更具体」当成了「谁赢」(自述报告 / 健康检查 / 退出码全绿)
技术栈:Python 3 · 规则引擎 / RBAC 权限视图合成 · 审计账本 NDJSON · 健康检查退出码分层 · fail-closed 关键词:
most_specific_allow_deny_wins· 最具体规则判定 · 同具体度冲突 · deny 优先级 ·_specificity比较用严格大于 · 先到者赢 ·denied恒 0 · 权限视图 denied 恒 0 · 空判据表BAD_FIELDS = ()· 退出码恒 0 · 自述字段自证 · 账本自洽不等于判定正确 · 外部独立探针 · fail-closed 退出码 2/3 报错/症状关键词:{"allowed":4,"denied":0}·{"degraded":false,"issues":[]}·RESULT: BAD(判定不一致 1 条)·q-002: 期望=deny(R-prj-denylist) 实际=allow(R-prj-billing)
一、问题背景
rbacview 是一个权限视图合成服务:给定主体(user + role)、资源、操作,
按权威契约合成该主体的有效权限(allow / deny),并把每次判定落进审计账本。
契约 conf/rbac.env 写明了判定口径:
RBAC_EXPECTED_EFFECTIVE_RULE=most_specific_allow_deny_wins
翻译成人话:
某个主体对某资源 × 操作是否被放行,只由「匹配该请求的、最具体的那条策略规则」决定。 「更具体」= scope 层级更深(
project>org);同层级时 role 精确匹配 > 通配*。 存在具体层的 deny 时,上层 allow 一律不得外溢(deny 优先级更高)。
安全团队巡检发现:4 条请求全部被判「放行」,denied 恒为 0,
而所有「绿色信号」都亮着:
python3 app/demo.py→ 退出码0- 自述报告
reports/view_report.json→{"allowed":4,"denied":0,"ok":true,"self_consistent":true} - 健康检查
python3 app/health.py→{"degraded":false,"issues":[]},退出码0
最刺眼的是 q-002:请求方角色 billing-admin、动作 delete,
策略里明明有一条 scope=project 的 deny 规则 R-prj-denylist,它却照样被放行。
二、复现步骤(可照着跑)
git clone <repo> && cd 3day-test
git checkout 2026-10-11-1000
cd scenario/2026-10-11-1000
docker compose up -d --build
docker compose exec -T rbacview bash
# 容器内:
cd /srv && bash ops/repro.sh; echo "rc=$?" # 必须用 ; 接住,问题态 rc=1 是预期信号
bash ops/verify.sh; echo "rc=$?"
ops/repro.sh以外部探针退出码为脚本退出码,问题态返回1—— 预期的复现信号,不是环境坏了。 非交互环境docker compose exec必须加-T,否则the input device is not a TTY。
问题态实测输出:
=== 1) 跑一轮判定(demo.py) ===
{"allowed": 4, "denied": 0, "matched_rules_total": 10, ...}
demo.py rc=0
{"allowed": 4, "denied": 0, "matched_rules_total": 10, "ok": true, "self_consistent": true, "views": 4}
=== 2) 健康检查 ===
{"degraded": false, "issues": []}
health rc=0
=== 3) 外部独立探针(只读契约 + 策略 + 账本) ===
契约口径 = most_specific_allow_deny_wins
账本真实行数 = 4(请求清单 = 4)
按契约口径重算 vs 账本真实判定 不一致的请求:
q-002: 期望=deny(R-prj-denylist) 实际=allow(R-prj-billing)
契约口径真实匹配条数合计 = 10
RESULT: BAD(判定不一致 1 条)
bash ops/verify.sh 问题态:PASS=9 FAIL=6 / RESULT: BAD(rc=1)。
三、根本原因
3.1 题眼:同具体度的冲突没有消解规则,变成了「先到者赢」
把匹配规则的具体度全打出来(_specificity = (scope 层级, role 是否精确)):
R-org-dev (0, 0) allow
R-prj-billing (1, 1) allow
R-prj-denylist (1, 1) deny <-- 与上面那条具体度完全相同
app/evaluator.py::evaluate() 的实现是:
best = None
for rule in matched:
if best is None or _specificity(rule) > _specificity(best): # 严格大于
best = rule
verdict = "allow" if best["effect"] == "allow" else "deny"
从列表头开始扫、只在「严格更具体」时替换 —— 遇到同具体度的候选时先到者赢。
策略里 allow 规则 R-prj-billing 排在 R-prj-denylist 前面,
于是同具体度 (1,1) 的 deny 被 allow 压掉,denied 恒 0。
根本认知:「谁更具体」是排序键,「谁赢」是冲突消解规则,两者是两件事,
不能拿前者当后者。 契约里 most_specific_allow_deny_wins 有三个动作:
① 取最具体的一层;② 该层内 deny 赢;③ 无匹配则按 default_effect。
代码只实现了 ①③,把 ② 静默地退化成了「先到者赢」。
3.2 「账本自洽」≠「判定正确」
外部独立探针 client/effective_probe.py 只读契约 + 策略 + 账本,不读任何自述字段 ——
但在问题态它也亮了绿灯(不一致 0 条),因为账本里记的就是错的那一份。
探针比对的是「按契约重算」vs「账本里写的」。 判定逻辑错了 → 账本跟着错 → 两边一致 → 探针无话可说。 账本自洽只能证明「服务说话算数」,不能证明「服务说对了」。
3.3 四个绿灯没有一件指向真相
| 绿灯 | 实际含义 |
|---|---|
demo.py rc=0 | 只证明进程没崩 |
view_report.json {ok:true, self_consistent:true} | ok/self_consistent 是写死常量;allowed 只是自己数自己写出来的账本 —— 自述自证恒真 |
health {"degraded":false,"issues":[]} + rc=0 | BAD_FIELDS = () 空判据表(for 一次都不循环 → issues 恒空)+ 只读自述报告、从不读事实 + main() 无条件 return EXIT_OK |
| 探针问题态「不一致 0 条」 | 见 3.2,账本自洽 ≠ 判定正确 |
四、解决办法(只改 app/ 两个文件)
4.1 app/evaluator.py:把冲突消解显式成一层
def _specificity(rule):
"""规则具体度:scope 层级为主,role 精确匹配优先于通配。
注意:具体度**不含** effect。deny/allow 谁赢由 _pick_effective 单独决定。"""
return (SCOPE_RANK.get(rule["scope"], -1), 1 if rule["role"] != "*" else 0)
def _pick_effective(matched):
"""契约口径 most_specific_allow_deny_wins:最具体的一层内 deny 赢。"""
top = max(_specificity(rule) for rule in matched)
top_rules = [rule for rule in matched if _specificity(rule) == top]
denies = [rule for rule in top_rules if rule["effect"] == "deny"]
if denies:
return denies[0]
return top_rules[0]
evaluate() 里把「扫描取严格最优」换成 best = _pick_effective(matched)。
要点:先分组(取具体度最高的一层),再在组内按 effect 判优先 —— 这比「排序取第一个」更能表达契约语义,也不会被策略里的书写顺序影响。
4.2 app/health.py:判据落到事实层 + 退出码分层 + 不重跑洗白
- 非空判据,全部落在能被外部独立复核的事实上
事实源 = 契约 + 策略 + 账本真实行 + 请求清单;自述报告只用于交叉对账,不作唯一依据。
- 判据 1:账本行数 == 请求清单条数、无重复
id; - 判据 2(题眼):逐条按契约口径独立重算
verdict/rule(_reference_verdict()故意与evaluator.py分开重写一遍),不一致即报; - 判据 3:自述
views/allowed/denied/matched_rules_total必须等于账本真实统计与契约口径真实匹配条数; - 判据 4:视图目录文件数 == 请求清单条数。
- 判据 1:账本行数 == 请求清单条数、无重复
- 退出码分层:
0正常 /2有真问题或拿不到事实 /3契约不可用。 「判不了」≠「没问题」 —— 账本缺失/损坏、请求清单读不到、视图目录读不到都计入issues并rc=2(fail-closed)。 - 不做「重跑洗白」:账本完全不存在(首次跑)时才允许刷新一轮; 已存在就一律审计现有事实,绝不用一次重跑把被篡改的账本盖回去。
五、验证
docker compose exec -T rbacview bash -lc 'cd /srv && bash ops/repro.sh; echo "rc=$?"'
docker compose exec -T rbacview bash -lc 'cd /srv && bash ops/verify.sh; echo "rc=$?"'
修复态:
{"allowed": 3, "denied": 1, "matched_rules_total": 10, "views": 4}
{"allowed": 3, "contract_available": true, "degraded": false, "denied": 1,
"issues": [], "ledger_rows": 4, "mismatch": 0, "views": 4}
health rc=0
按契约口径重算 vs 账本真实判定 不一致的请求: <-- 空
契约口径真实匹配条数合计 = 10 <-- 不变
RESULT: OK
rc=0
ops/verify.sh:问题态 PASS=9 FAIL=6 / RESULT: BAD → 修复态 PASS=15 FAIL=0 / RESULT: OK。
matched_rules_total 从 10 到 10 说明匹配条数本来就没算错,错的是「谁赢」 ——
这是一个很好的自检信号:拒绝条数为 0 时,先分清是「匹配没算出来」还是「冲突消解算反了」。
对抗性验证(证明闸门真的有牙,不是「恰好绿」)
| 组 | 构造 | 实测 |
|---|---|---|
| A | verify.sh --restore 回问题态 | PASS=9 FAIL=6 / RESULT: BAD(rc=1) |
| B | 篡改账本 q-002 为 allow,不重跑 app | health 报 账本判定与契约口径不一致 1 条: q-002: 期望 deny(R-prj-denylist) 实际 allow(R-prj-billing)、自述 allowed 3 != 账本真实 4,rc=2 |
| C | 契约缺失(CONF_DIR=/tmp/nope) | {"contract_available":false,"degraded":true,...},rc=3 |
| D | 契约口径非法(RBAC_EXPECTED_EFFECTIVE_RULE=loose_prefix) | 契约 RBAC_EXPECTED_EFFECTIVE_RULE 非法: 'loose_prefix',rc=3 |
| E | 删掉账本(拿不到事实) | 自动刷新一轮后按新事实判定 → {"degraded":false,...,"ledger_rows":4,"mismatch":0},rc=0 |
B 组是本题关键对抗:健康检查绝不允许「重跑一遍把被篡改的账本盖回去」。
六、经验与避坑
6.1 可复用的结论
- 「更具体」是排序键,「谁赢」是冲突消解规则,两件事。
for x in xs: if key(x) > key(best): best = x这种「扫描取严格最优」, 静默地把「同键先到者赢」当成了业务契约,一个异常都不抛。 凡是有「优先级 / 优先级最高者生效」的规则引擎(RBAC、防火墙、路由表、限流、折扣、CSS 优先级): 先按优先级分组,再在组内按明确的消解规则裁决(deny 赢 / 精确赢 / 短前缀赢 / 后定义赢), 并且把「书写顺序不影响结果」当成一条断言来测。 - 拒绝条数为 0 时,先分清是「没匹配上」还是「冲突消解算反了」。 把匹配到的规则连具体度 + effect 一起打出来,一眼就能看出是数据问题还是判据问题。
- 「账本自洽」≠「判定正确」。 只拿「按契约重算 vs 账本」比对的探针判不出判定逻辑错, 因为判定错了账本跟着错、两边照样一致。独立的判据必须同时覆盖判定逻辑本身(真值表)。
- 审计账本一旦被写坏,健康检查绝不允许「重跑一遍盖回去」 —— 那等于自己给自己洗白。 只有首次(账本不存在)才允许刷新;已存在就一律审计现有事实。
- 绿色信号分三类:
进程没崩(弱)/自己写的自述(无判别力)/能被外部按契约独立复核的事实(强)。ok:true写死常量、self_consistent拿自己的计数比自己、BAD_FIELDS = ()空判据表、main()无条件return 0—— 这类「自述字段自证」比没有检查更危险,因为它静默地骗人。 - 「判不了」≠「没问题」(fail-closed):退出码要分层 ——
0正常 /2有真问题或拿不到事实 /3契约不可用。
6.2 本次踩的坑
- 容器与宿主机是两份文件。 本场景
docker-compose.yml没有 bind mount, 镜像构建时COPY . /srv把宿主机的app/打了进去。 直接用docker compose exec -T rbacview bash -lc '... python3 /dev/stdin'改文件, 只会改到容器内那份(重建即丢),宿主机仍是问题态。 正确做法:改宿主机文件 →docker compose up -d --build重建。 症状很有辨识度:docker compose exec里verify.sh已经全绿,宿主机git diff却是空的。 ops/repro.sh以探针退出码为脚本退出码,问题态返回1,CI 里表现为「命令失败」—— 这是预期的复现信号,不是环境坏了,用; echo rc=$?接住。- 非交互环境
docker compose exec必须加-T,否则the input device is not a TTY。 - 改完
app/*.py必须清__pycache__(ops/lib.sh的clean_pyc已内置)。 旧字节码会把「已改」掩盖成「没改」,症状是verify.sh结果纹丝不动。 state/、reports/、runtime/是运行期产物且已 gitignore;手改账本让闸门变绿 = 造假。- 别修过头。 契约口径是 deny 优先级更高,让 q-002 通过的「修法」(放宽权限、删掉 deny 规则、 改成 allow 优先)方向是反的 —— 正确方向是让判定忠于契约,不是让契约迁就判定。
- 提交前
git diff --name-only -- conf client ops requests复核验收口径零改动。
6.3 自查口诀
我的「优先级最高者」是怎么选出来的 —— 排序键比较,还是显式冲突消解?同优先级的两条规则,结果会不会取决于它们在文件里的书写顺序?拒绝数为 0 时,是没匹配上还是冲突消解算反了?健康检查读的是事实还是自述?判据表是不是空的?拿不到数据时我判通过还是不通过?账本被写坏时,我的健康检查会重跑一遍把它盖回去吗?
6.4 同类影响面
- 一切规则引擎 / 策略引擎的优先级与冲突消解:RBAC/ABAC 权限、IAM policy、
Kubernetes
NetworkPolicy、iptables/nftables规则先后、防火墙 ACL、 云安全组、WAF 规则、OAuth scope 求交、sudoers、.gitignore、 CSS 优先级、日志级别、限流/熔断阈值叠加、折扣与优惠券叠加、路由表最长前缀匹配。 - 一切**「最具体者生效」的层级配置**:组织/项目/用户三层配置覆盖、特性开关(feature flag)分环境覆盖、
单测 fixture 覆盖、K8s
kustomizeoverlay、Terraform 变量优先级、 Nginxlocation匹配、Spring@Profile、前端主题 token 覆盖。 - 一切**「存在 deny 就必须优先」的安全语义**:黑名单优先于白名单、缺省拒绝(default deny)、 显式禁止覆盖隐式允许 —— 写成「先到者赢」就是把自己从 fail-closed 改成了 fail-open。
- 一切审计账本 / 事件溯源的自洽性检查:
self_consistent同源自证、 「按契约重算 vs 账本」但判定逻辑本身没有被真值表覆盖。 - 一切「自述字段自证 + 空判据表 + 退出码恒 0」的假绿健康检查与巡检脚本。
