pbl_compiler/scripts/audit_rbac_parity.py
agent.develop cb1132e6a9 fix(pbl_compiler): M3b RBAC 29条显式登记+CRUD只读拒绝端点+json根键统一+audit BinOp解析修复
- scripts/load_path.py: PATHS_CONTRACTS 20 条(含 M3b 5 个导出契约) + PATHS_READONLY_DENY 9 条
  = 29 条全显式登记,无通配符;与 wwwroot/api/*.dspy(29) 一一对应
- wwwroot/api/: 新增 9 个 CRUD 只读拒绝端点(create/update/delete × 3 表),
  封死 xls2ui 回退默认写入口,保 rules_hash 29.6 确定性指纹唯一写入路径
- json/*.json: 根级 browserfields 合并进 params,三文件统一 tblname/alias/title/params 根键集,
  editable 齐备 new_/update_/delete_data_url 且指向真实 .dspy
- scripts/audit_rbac_parity.py: 修复 PATHS = A + B (BinOp) 解析取空的自身缺陷(假 FAIL 根因),
  新增 json/ 根键白名单 + editable 三 URL + 幽灵/漏登记 检查
- pbl_compiler/init.py: 文件头注释登记条数与代码事实对齐(29=20+9)
- 实跑: audit_rbac_parity.py rc=0 PASS;test_m3b_mapping.py PASS=113/FAIL=0
2026-09-19 11:04:55 +08:00

386 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""pbl_compiler 接线 / RBAC / CRUD 定义 一致性机械审计QC 硬门禁自证工具)。
背景:连续多轮 QC 退回的根因都是「写了但没接线 / 接了线但没登记 RBAC /
CRUD 定义格式不合规」——人肉声明不可信,故本脚本用机械比对钉死所有登记面。
比对集合:
期望端点集合 EXPECTED = CONTRACTS READONLY_DENY
① pbl_compiler/init.py CONTRACTS 契约名集合20
② pbl_compiler/__init__.py __all__ 导出集合(须 ⊇ CONTRACTS
③ wwwroot/api/*.dspy 实际端点文件集合(须 == EXPECTED
④ scripts/load_path.py PATHS 登记集合(须 == EXPECTED
⑤ scripts/load_path.py PATHS_READONLY_DENYCRUD 只读拒绝端点,非契约)
另加 json/*.json 结构审计QC #3改任一 CRUD 文件必须扫 json/ 全部Pitfall 10
· 根键白名单 {tblname, alias, title, params, _comment}(禁根级 browserfields
· browserfields 只允许挂在 params 下
· alias 不得与模块名同名(会与模块 wwwroot 目录冲突)
· params.editable 必须齐备 new_data_url / update_data_url / delete_data_url
且三个 URL 指向的 .dspy 文件真实存在(防 xls2ui 回退默认写入口)
· browserfields/editexclouded 引用的字段必须存在于 models/{tblname}.json
退出码0 全通过1 存在 FAIL。交付前必须实跑并在交付摘要引用输出。
"""
import ast
import json
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE) # 模块仓库根
PKG = os.path.join(ROOT, 'pbl_compiler')
WWW = os.path.join(ROOT, 'wwwroot')
API = os.path.join(WWW, 'api')
JSONDIR = os.path.join(ROOT, 'json')
MODELSDIR = os.path.join(ROOT, 'models')
failures = []
notes = []
def fail(msg):
failures.append(msg)
def ok(msg):
notes.append('PASS ' + msg)
def parse(path):
"""ast 解析:返回 (tree, 字面量常量, 顶层 def 名集合, 顶层赋值目标名)。"""
with open(path, 'r', encoding='utf-8') as fh:
tree = ast.parse(fh.read(), filename=path)
consts, defs, assigned = {}, set(), []
for node in tree.body:
if isinstance(node, ast.Assign):
for tgt in node.targets:
if isinstance(tgt, ast.Name):
assigned.append(tgt.id)
try:
consts[tgt.id] = ast.literal_eval(node.value)
except Exception: # noqa: BLE001 - 含函数引用的 dict 非字面量
consts[tgt.id] = node.value # 保留 AST 供专用提取
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
defs.add(node.name)
return tree, consts, defs, assigned
def dict_keys(node):
"""从 dict AST或已 literal_eval 的 dict取字符串键集合。
init.py 的 CONTRACTS 形如 {'name': fn_obj},值是实现函数引用,
ast.literal_eval 整体求值会失败——必须只提取键,不能因此判为空。
"""
if isinstance(node, dict):
return {k for k in node if isinstance(k, str)}
if isinstance(node, ast.Dict):
return {k.value for k in node.keys if isinstance(k, ast.Constant)
and isinstance(k.value, str)}
return set()
def literal_or_none(node):
"""AST 节点 → python 值;失败返回 None。"""
try:
return ast.literal_eval(node) if isinstance(node, ast.AST) else node
except Exception: # noqa: BLE001
return None
# ── ① init.py CONTRACTS ────────────────────────────────────────────────────
init_py = os.path.join(PKG, 'init.py')
CONTRACTS = set()
impl_defs = set()
if not os.path.isfile(init_py):
fail('缺 pbl_compiler/init.py')
else:
_, consts, _, _ = parse(init_py)
if 'CONTRACTS' not in consts:
fail('init.py 无 CONTRACTS 常量(三处同步的事实源丢失)')
else:
CONTRACTS = dict_keys(consts['CONTRACTS'])
if not CONTRACTS:
fail('init.py CONTRACTS 为空 dict')
else:
ok('init.py CONTRACTS = %d 个契约' % len(CONTRACTS))
# 实现层:契约必须在 api.py 或 rules_export_api.py 中有同名 async def
for impl in ('api.py', 'rules_export_api.py'):
p = os.path.join(PKG, impl)
if os.path.isfile(p):
impl_defs |= parse(p)[2]
ghost = sorted(CONTRACTS - impl_defs)
if ghost:
fail('CONTRACTS 注册了实现层不存在的函数(幽灵注册 → AttributeError: %s' % ghost)
else:
ok('%d 个契约在 api.py/rules_export_api.py 均有同名实现' % len(CONTRACTS))
# ── ② __init__.py __all__ ─────────────────────────────────────────────────
pkg_init = os.path.join(PKG, '__init__.py')
if not os.path.isfile(pkg_init):
fail('缺 pbl_compiler/__init__.py')
else:
_, consts, _, _ = parse(pkg_init)
all_names = set(consts.get('__all__') or [])
missing = sorted(CONTRACTS - all_names)
if missing:
fail('__init__.py __all__ 未导出 CONTRACTS 契约(漏 ② → .dspy NameError: %s' % missing)
else:
ok('__init__.py __all__ 覆盖全部 %d 个契约' % len(CONTRACTS))
# ── ⑤ load_path.pyPATHS / PATHS_CONTRACTS / PATHS_READONLY_DENY ──────────
lp_py = os.path.join(HERE, 'load_path.py')
entries, contract_entries, deny_entries = [], [], []
READONLY_DENY = set()
if not os.path.isfile(lp_py):
fail('缺 scripts/load_path.py')
else:
_, consts, _, _ = parse(lp_py)
# 先把各列表常量解析成 python 值,再解析 PATHS = A + BName 需回查 consts
# 关键parse() 对「纯字面量列表」已 literal_eval 成 python list只有求值失败
# 的赋值才保留 AST。两种形态都必须收进 resolved —— 否则 PATHS = PATHS_CONTRACTS
# + PATHS_READONLY_DENY 的 BinOp 回查会拿到空列表,把已登记的 29 条误判成
# 「PATHS 为空RBAC 零登记)」,产生假 FAIL本轮排查出的审计工具自身缺陷
resolved = {}
for var, raw in consts.items():
if isinstance(raw, list): # parse() 已求值的纯字面量列表
resolved[var] = raw
elif isinstance(raw, ast.List): # 含函数引用等求值失败的列表 AST
try:
resolved[var] = [ast.literal_eval(e) for e in raw.elts]
except Exception: # noqa: BLE001
resolved[var] = []
for var, bucket in (('PATHS', entries),
('PATHS_CONTRACTS', contract_entries),
('PATHS_READONLY_DENY', deny_entries)):
raw = consts.get(var)
if raw is None:
fail('load_path.py 缺 %s 列表' % var)
continue
if isinstance(raw, ast.List):
raw = resolved.get(var, [])
elif isinstance(raw, ast.Name):
raw = resolved.get(raw.id, [])
elif isinstance(raw, ast.BinOp): # PATHS = PATHS_CONTRACTS + PATHS_READONLY_DENY
left = resolved.get(getattr(raw.left, 'id', None), []) if isinstance(raw.left, ast.Name) \
else (literal_or_none(raw.left) or [])
right = resolved.get(getattr(raw.right, 'id', None), []) if isinstance(raw.right, ast.Name) \
else (literal_or_none(raw.right) or [])
raw = list(left) + list(right)
for item in raw or []:
if not (isinstance(item, (list, tuple)) and len(item) == 2):
fail('load_path.py %s 元素必须是 (path, role) 二元组: %r' % (var, item))
continue
bucket.append((item[0], item[1]))
if not entries:
fail('load_path.py PATHS 为空RBAC 零登记 → 全部 403')
READONLY_DENY = {p.rsplit('/', 1)[-1][:-len('.dspy')]
for p, _ in deny_entries if '/api/' in p}
for p, _role in entries:
if '%' in p or '*' in p:
fail('load_path.py 禁用通配符: %s' % p)
if not any('%' in p or '*' in p for p, _ in entries):
ok('load_path.py 无通配符(%d 条全显式)' % len(entries))
dup = len(entries) - len({p for p, _ in entries})
if dup:
fail('load_path.py 存在重复登记 %d' % dup)
else:
ok('load_path.py 登记 %d 条无重复(契约 %d + 只读拒绝 %d'
% (len(entries), len(contract_entries), len(deny_entries)))
PATH_NAMES = {p.rsplit('/', 1)[-1][:-len('.dspy')]
for p, _ in entries if '/api/' in p}
CONTRACT_PATH_NAMES = {p.rsplit('/', 1)[-1][:-len('.dspy')]
for p, _ in contract_entries if '/api/' in p}
EXPECTED = CONTRACTS | READONLY_DENY
if CONTRACT_PATH_NAMES != CONTRACTS:
fail('PATHS_CONTRACTS 与 CONTRACTS 不一致:未登记=%s 幽灵登记=%s'
% (sorted(CONTRACTS - CONTRACT_PATH_NAMES),
sorted(CONTRACT_PATH_NAMES - CONTRACTS)))
else:
ok('PATHS_CONTRACTS 与 CONTRACTS 一一对应(%d 条)' % len(CONTRACTS))
if PATH_NAMES != EXPECTED:
fail('PATHS 与 CONTRACTSREADONLY_DENY 不一致:未登记=%s 幽灵登记=%s'
% (sorted(EXPECTED - PATH_NAMES), sorted(PATH_NAMES - EXPECTED)))
else:
ok('PATHS 登记 %d 条,与期望端点集合(契约 %d + 只读拒绝 %d)一一对应'
% (len(PATH_NAMES), len(CONTRACTS), len(READONLY_DENY)))
# ── ③ wwwroot/api/*.dspy ──────────────────────────────────────────────────
dspy_files = sorted(f for f in os.listdir(API) if f.endswith('.dspy')) \
if os.path.isdir(API) else []
DSPY_NAMES = {f[:-len('.dspy')] for f in dspy_files}
if DSPY_NAMES != EXPECTED:
fail('wwwroot/api/ 端点与期望集合不一致:缺端点=%s 多余端点=%s'
% (sorted(EXPECTED - DSPY_NAMES), sorted(DSPY_NAMES - EXPECTED)))
else:
ok('wwwroot/api/*.dspy = %d 个,与 CONTRACTSREADONLY_DENY 一一对应'
% len(DSPY_NAMES))
n_api = len([1 for p, _ in entries if '/api/' in p])
if n_api != len(dspy_files):
fail('端点数与 RBAC 登记数不等:%d != %d' % (n_api, len(dspy_files)))
else:
ok('grep 口径自证PATHS 中 /api/ 条目=%dwwwroot/api/*.dspy=%d'
% (n_api, len(dspy_files)))
# ── .dspy 规范审计dspy-file-implementation-spec─────────────────────────
bad_import, no_return, env_req = [], [], []
for fname in dspy_files:
text = open(os.path.join(API, fname), 'r', encoding='utf-8').read()
for line in text.splitlines():
s = line.strip()
if s.startswith('import ') or s.startswith('from '):
if 'sqlor.filter' not in s:
bad_import.append('%s: %s' % (fname, s))
if not re.search(r'(?m)^\s*return\b', text):
no_return.append(fname)
if 'ServerEnv()' in text:
env_req.append(fname)
if bad_import:
fail('.dspy 存在非法顶层 import应由 load_*() 导出): %s' % bad_import)
else:
ok('%d 个 .dspy 无非法 import仅允许 sqlor.filter' % len(dspy_files))
if no_return:
fail('.dspy 缺显式 return隐式返回 None → 前端拿到 null: %s' % no_return)
else:
ok('%d 个 .dspy 均有显式 return' % len(dspy_files))
if env_req:
fail('.dspy 不得用 ServerEnv() 取请求态: %s' % env_req)
else:
ok('.dspy 无 ServerEnv() 取请求态')
# ── 只读拒绝端点必须真返回 Error防「名义拒绝、实际可写」─────────────────
deny_hollow = []
for name in sorted(READONLY_DENY):
p = os.path.join(API, name + '.dspy')
if not os.path.isfile(p):
deny_hollow.append('%s: 文件缺失' % name)
continue
text = open(p, 'r', encoding='utf-8').read()
if 'widgettype' not in text or '"Error"' not in text and "'Error'" not in text:
deny_hollow.append('%s: 未返回 Error widget' % name)
if deny_hollow:
fail('只读拒绝端点空心化(未显式拒绝写入): %s' % deny_hollow)
else:
ok('%d 个只读拒绝端点均显式返回 Error widgetCRUD 写入口已封死)'
% len(READONLY_DENY))
# ── CRUD 生成目录登记面(防「生成目录未登记 → 403」───────────────────────
SKIP_DIRS = {'api', 'i18n', 'imgs', 'styles', 'scripts', 'bricks'}
crud_dirs = [d for d in sorted(os.listdir(WWW))
if os.path.isdir(os.path.join(WWW, d)) and d not in SKIP_DIRS] \
if os.path.isdir(WWW) else []
lp_paths = {p for p, _ in entries}
for alias in crud_dirs:
adir = os.path.join(WWW, alias)
for fn in sorted(os.listdir(adir)):
if fn == 'index.ui' or re.match(r'^(get_|add_|update_|delete_)', fn):
expect = '/pbl_compiler/%s/%s' % (alias, fn)
if expect not in lp_paths:
fail('CRUD 生成文件未登记 RBAC: %s' % expect)
if crud_dirs:
ok('发现 CRUD 生成目录 %d 个,已逐文件核对登记' % len(crud_dirs))
else:
ok('wwwroot/ 无 CRUD 生成子目录(只读列表走手写契约 api/pbl_script_rule_list.dspy')
# ── json/*.json 结构审计QC #3 + Pitfall 10改一个必须全扫──────────────
ROOT_ALLOWED = {'tblname', 'alias', 'title', 'params', '_comment'}
json_files = sorted(f for f in os.listdir(JSONDIR) if f.endswith('.json')) \
if os.path.isdir(JSONDIR) else []
if not json_files:
fail('json/ 目录为空CRUD 定义缺失)')
root_key_bad, alias_bad, editable_bad, url_missing, field_ghost = [], [], [], [], []
for fn in json_files:
path = os.path.join(JSONDIR, fn)
try:
d = json.load(open(path, 'r', encoding='utf-8'))
except Exception as exc: # noqa: BLE001
fail('json/%s 不是合法 JSON: %s' % (fn, exc))
continue
rk = set(d.keys())
if not rk <= ROOT_ALLOWED:
root_key_bad.append('%s: 非法根键 %s' % (fn, sorted(rk - ROOT_ALLOWED)))
continue
if 'browserfields' in rk:
root_key_bad.append('%s: 根级 browserfields规范只允许 params.browserfields' % fn)
continue
alias = d.get('alias')
if alias == 'pbl_compiler':
alias_bad.append('%s: alias 与模块同名,生成目录会与模块 wwwroot 冲突' % fn)
params = d.get('params') or {}
if 'browserfields' not in params:
root_key_bad.append('%s: 缺 params.browserfields' % fn)
editable = params.get('editable')
if not isinstance(editable, dict):
editable_bad.append('%s: params.editable 不是对象(%r' % (fn, type(editable).__name__))
continue
need = ('new_data_url', 'update_data_url', 'delete_data_url')
absent = [k for k in need if k not in editable]
if absent:
editable_bad.append('%s: editable 缺 %s' % (fn, absent))
for k in need:
v = editable.get(k)
if v is None:
continue
if not isinstance(v, dict) or 'url' not in v:
editable_bad.append('%s: editable.%s 必须是含 url 的对象' % (fn, k))
continue
m = re.search(r"entire_url\('([^']+)'\)", v['url'])
if not m:
editable_bad.append('%s: editable.%s.url 未用 entire_url()' % (fn, k))
continue
ref = m.group(1)
base = os.path.basename(ref)
if base.startswith('/'):
base = base.lstrip('/')
if not (os.path.isfile(os.path.join(API, base))
or os.path.isfile(os.path.join(ROOT, 'wwwroot', base))):
url_missing.append('%s: editable.%s%s 无匹配 .dspy' % (fn, k, ref))
gd = editable.get('get_data_url')
if isinstance(gd, dict) and gd.get('url'):
m = re.search(r"entire_url\('([^']+)'\)", gd['url'])
if m and not os.path.isfile(os.path.join(API, os.path.basename(m.group(1)))):
url_missing.append('%s: get_data_url → %s 无匹配 .dspy' % (fn, m.group(1)))
# 字段幽灵检查browserfields 引用的字段必须在 models/{tblname}.json 存在
mp = os.path.join(MODELSDIR, (d.get('tblname') or '') + '.json')
if os.path.isfile(mp):
cols = {f.get('name') for f in json.load(open(mp, 'r', encoding='utf-8')).get('fields', [])}
for scope in ('browserfields', 'editexclouded'):
bf = params.get(scope)
names = list(bf.keys()) if isinstance(bf, dict) and scope == 'browserfields' \
else (bf or [])
for name in names:
if name not in cols:
field_ghost.append('%s: %s 引用 models 中不存在的字段 %s'
% (fn, scope, name))
else:
field_ghost.append('%s: 缺 models/%s.json' % (fn, d.get('tblname')))
for lst, msg in ((root_key_bad, 'json/ 根键不合规'),
(alias_bad, 'json/ alias 不合规'),
(editable_bad, 'json/ editable 段不合规'),
(url_missing, 'json/ editable URL 无匹配 .dspy'),
(field_ghost, 'json/ 字段与 models 不一致')):
if lst:
fail('%s: %s' % (msg, lst))
if not (root_key_bad or alias_bad or editable_bad or url_missing or field_ghost):
ok('json/*.json 共 %d 个:根键白名单(%s) / alias / editable 三 URL 齐备且指向真实 .dspy '
'/ 字段与 models 全对齐' % (len(json_files), sorted(ROOT_ALLOWED)))
# ── 输出 ───────────────────────────────────────────────────────────────────
print('=== pbl_compiler 接线/RBAC/CRUD parity 审计 ===')
for line in notes:
print(' ' + line)
if failures:
print('--- FAIL (%d) ---' % len(failures))
for line in failures:
print(' ' + line)
print('RESULT: FAIL')
sys.exit(1)
print('RESULT: PASS (contracts=%d, readonly_deny=%d, dspy=%d, rbac_api_paths=%d, json=%d)'
% (len(CONTRACTS), len(READONLY_DENY), len(dspy_files), len(PATH_NAMES),
len(json_files)))
sys.exit(0)