朕喜技术(ZhenxiJS)
2026.10.11
1 分钟阅读

权限视图服务 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=0BAD_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. 非空判据,全部落在能被外部独立复核的事实上 事实源 = 契约 + 策略 + 账本真实行 + 请求清单;自述报告只用于交叉对账,不作唯一依据。
    • 判据 1:账本行数 == 请求清单条数、无重复 id;
    • 判据 2(题眼):逐条按契约口径独立重算 verdict/rule(_reference_verdict() 故意与 evaluator.py 分开重写一遍),不一致即报;
    • 判据 3:自述 views/allowed/denied/matched_rules_total 必须等于账本真实统计与契约口径真实匹配条数;
    • 判据 4:视图目录文件数 == 请求清单条数。
  2. 退出码分层:0 正常 / 2 有真问题或拿不到事实 / 3 契约不可用。 「判不了」≠「没问题」 —— 账本缺失/损坏、请求清单读不到、视图目录读不到都计入 issues 并 rc=2(fail-closed)。
  3. 不做「重跑洗白」:账本完全不存在(首次跑)时才允许刷新一轮; 已存在就一律审计现有事实,绝不用一次重跑把被篡改的账本盖回去。

五、验证

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 时,先分清是「匹配没算出来」还是「冲突消解算反了」。

对抗性验证(证明闸门真的有牙,不是「恰好绿」)

组构造实测
Averify.sh --restore 回问题态PASS=9 FAIL=6 / RESULT: BAD(rc=1)
B篡改账本 q-002 为 allow,不重跑 apphealth 报 账本判定与契约口径不一致 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 可复用的结论

  1. 「更具体」是排序键,「谁赢」是冲突消解规则,两件事。for x in xs: if key(x) > key(best): best = x 这种「扫描取严格最优」, 静默地把「同键先到者赢」当成了业务契约,一个异常都不抛。 凡是有「优先级 / 优先级最高者生效」的规则引擎(RBAC、防火墙、路由表、限流、折扣、CSS 优先级): 先按优先级分组,再在组内按明确的消解规则裁决(deny 赢 / 精确赢 / 短前缀赢 / 后定义赢), 并且把「书写顺序不影响结果」当成一条断言来测。
  2. 拒绝条数为 0 时,先分清是「没匹配上」还是「冲突消解算反了」。 把匹配到的规则连具体度 + effect 一起打出来,一眼就能看出是数据问题还是判据问题。
  3. 「账本自洽」≠「判定正确」。 只拿「按契约重算 vs 账本」比对的探针判不出判定逻辑错, 因为判定错了账本跟着错、两边照样一致。独立的判据必须同时覆盖判定逻辑本身(真值表)。
  4. 审计账本一旦被写坏,健康检查绝不允许「重跑一遍盖回去」 —— 那等于自己给自己洗白。 只有首次(账本不存在)才允许刷新;已存在就一律审计现有事实。
  5. 绿色信号分三类:进程没崩(弱)/ 自己写的自述(无判别力)/ 能被外部按契约独立复核的事实(强)。 ok:true 写死常量、self_consistent 拿自己的计数比自己、BAD_FIELDS = () 空判据表、 main() 无条件 return 0 —— 这类「自述字段自证」比没有检查更危险,因为它静默地骗人。
  6. 「判不了」≠「没问题」(fail-closed):退出码要分层 —— 0 正常 / 2 有真问题或拿不到事实 / 3 契约不可用。

6.2 本次踩的坑

  1. 容器与宿主机是两份文件。 本场景 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 却是空的。
  2. ops/repro.sh 以探针退出码为脚本退出码,问题态返回 1,CI 里表现为「命令失败」—— 这是预期的复现信号,不是环境坏了,用 ; echo rc=$? 接住。
  3. 非交互环境 docker compose exec 必须加 -T,否则 the input device is not a TTY。
  4. 改完 app/*.py 必须清 __pycache__(ops/lib.sh 的 clean_pyc 已内置)。 旧字节码会把「已改」掩盖成「没改」,症状是 verify.sh 结果纹丝不动。
  5. state/、reports/、runtime/ 是运行期产物且已 gitignore;手改账本让闸门变绿 = 造假。
  6. 别修过头。 契约口径是 deny 优先级更高,让 q-002 通过的「修法」(放宽权限、删掉 deny 规则、 改成 allow 优先)方向是反的 —— 正确方向是让判定忠于契约,不是让契约迁就判定。
  7. 提交前 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 kustomize overlay、Terraform 变量优先级、 Nginx location 匹配、Spring @Profile、前端主题 token 覆盖。
  • 一切**「存在 deny 就必须优先」的安全语义**:黑名单优先于白名单、缺省拒绝(default deny)、 显式禁止覆盖隐式允许 —— 写成「先到者赢」就是把自己从 fail-closed 改成了 fail-open。
  • 一切审计账本 / 事件溯源的自洽性检查:self_consistent 同源自证、 「按契约重算 vs 账本」但判定逻辑本身没有被真值表覆盖。
  • 一切「自述字段自证 + 空判据表 + 退出码恒 0」的假绿健康检查与巡检脚本。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-10-11-1005-denyallow-denied0-0

分享这篇文章