#!/usr/bin/env python3 # -*- coding: utf-8 -*- """pbl_scense_ext RBAC 路径注册(硬门禁 6.6 / QC #11 / M9 重做 QC #1 / QC #6)。 约定: - 路径 = 模块自动路由 `/pbl_scense_ext/...`,不带端口、不带 /wss 前缀; - 角色 `any` = 免登录可取(覆盖层引导脚本 loader.js、覆盖层主题 CSS、以及 i18n 文案表 wwwroot/i18n//msg.txt——预鉴权页壳首屏就要拿到文案, 鉴权完成前拿不到 loader/CSS/msg.txt 会导致页壳空白); - 角色 `logined` = 登录后可访问(契约 .dspy 接口、覆盖层业务 JS、12 个页面壳 .ui); - 禁止 `%` / `*` 通配符(module-development-spec「load_path.py Wildcards are FORBIDDEN」), 每个文件一条显式条目;新增/删除静态资源必须同步本表; - 由 apps/pbls/build.sh 第 8 步调用 `register()`;rbac CLI 不在位时打印清单(不静默跳过)。 audit() 自检范围(QC #6 修正「假绿」):overlay/ 与 pbl_overlay/ 之外, **必须**递归扫描 i18n/(含 zh/en 等语言子目录)——此前只扫 overlay/pbl_overlay, 新增 i18n 资源漏注册不会报错,属假绿。现改为按扩展名递归收集磁盘清单, 漏注册 / 悬空条目 / 通配符三类问题一律非零返回。 """ import os import subprocess import sys MODULE = 'pbl_scense_ext' # 覆盖层 12 页面壳(wwwroot/pbl_overlay/*.ui)——与 wwwroot/overlay/pbl_overlay_core.js # PAGES 注册表 route 同源(route '/pbl_overlay/.ui' → 本模块目录 pbl_overlay/.ui) OVERLAY_PAGES = [ 'home', 'blueprint_list', 'blueprint_editor', 'validation_report', 'agent_console', 'approval_center', 'play_preview', 'mission_board', 'role_panel', 'artifact_studio', 'evidence_wall', 'assessment_report', ] # i18n 语言清单(wwwroot/i18n//msg.txt)——与 scripts/gen_overlay_pages.py # 的 load_msg('zh')/load_msg('en') 校验语言集合同源,新增语言必须同步本表 + PATHS。 I18N_LANGS = ['zh', 'en'] # audit() 递归扫描的目录(相对 wwwroot/)与其纳管扩展名。 # i18n 文案表是纯文本,必须与 .js/.css/.ui 一样显式注册(QC #6)。 AUDIT_DIRS = { 'overlay': ('.js', '.css'), 'pbl_overlay': ('.ui',), 'i18n': ('.txt',), } # (path, role) PATHS = [ # ── 契约接口(4 个 .dspy,均需登录) ── ('/pbl_scense_ext/api/pbl_session_context_get.dspy', 'logined'), ('/pbl_scense_ext/api/pbl_page_registry_list.dspy', 'logined'), ('/pbl_scense_ext/api/pbl_playtest_feedback_save.dspy', 'logined'), ('/pbl_scense_ext/api/pbl_overlay_bootstrap.dspy', 'logined'), # ── 模块入口页 ── ('/pbl_scense_ext', 'logined'), ('/pbl_scense_ext/index.ui', 'logined'), # ── 覆盖层静态资源(QC #1 硬门禁:ahserver 自动托管 .css/.js 但仍受 RBAC 约束, # 未注册即 403,M9 前端全废) ── # loader.js 是页壳引导注入器 + 运行时 i18n 链路,须在预鉴权页壳里就能加载 → any ('/pbl_scense_ext/overlay/pbl_overlay_loader.js', 'any'), # 主题 CSS 同理(页壳首屏样式,不含业务数据)→ any ('/pbl_scense_ext/overlay/pbl_overlay.css', 'any'), # 业务 JS 只在登录后由 loader 注入 → logined ('/pbl_scense_ext/overlay/pbl_overlay_core.js', 'logined'), ('/pbl_scense_ext/overlay/pbl_overlay_transport.js', 'logined'), ('/pbl_scense_ext/overlay/pbl_overlay_play.js', 'logined'), # ── i18n 文案表(QC #6 硬门禁)────────────────────────────────────────── # loader.js 的 loadLang() 运行时 fetch `/pbl_scense_ext/i18n//msg.txt` # 覆盖 [data-pbl-i18n] 节点。页面壳是预鉴权首屏即可渲染的壳,文案表若走 # logined 则未登录/鉴权竞态下拿不到文案 → 界面只剩生成期内嵌中文快照, # 多语言链路形同不存在。故与 loader.js / CSS 同级注册为 any(纯静态文案, # 不含任何业务数据/用户数据)。禁止通配符:zh、en 各一条显式条目。 ('/pbl_scense_ext/i18n/zh/msg.txt', 'any'), ('/pbl_scense_ext/i18n/en/msg.txt', 'any'), ] # 覆盖层页面壳 .ui:目录 + 每个文件显式注册(登录后可见,含世界内覆盖层页) for _key in OVERLAY_PAGES: PATHS.append(('/pbl_scense_ext/pbl_overlay/%s.ui' % _key, 'logined')) PATHS.append(('/pbl_scense_ext/pbl_overlay', 'logined')) def register(): tool = os.environ.get('RBAC_SET_PERM', 'set_role_perm.py') done, missing = 0, [] for path, role in PATHS: if subprocess.call([sys.executable if os.environ.get('PY') else 'python3', tool, role, path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0: done += 1 else: missing.append((path, role)) print('[%s] rbac paths: total=%d ok=%d pending=%d' % (MODULE, len(PATHS), done, len(missing))) for path, role in missing: print(' PENDING %-12s %s' % (role, path)) return len(missing) == 0 def _walk_disk(root, sub, exts): """递归收集 wwwroot/ 下扩展名在 exts 内的文件 → (url_path, fs_path)。""" out = [] base = os.path.join(root, 'wwwroot', sub) if not os.path.isdir(base): return out for dirpath, dirnames, filenames in os.walk(base): dirnames.sort() for fn in sorted(filenames): if not fn.endswith(exts): continue fs = os.path.join(dirpath, fn) rel = os.path.relpath(fs, base).replace(os.sep, '/') out.append(('/%s/%s/%s' % (MODULE, sub, rel), fs)) return out def audit(): """自检:PATHS 与磁盘静态资源(overlay / pbl_overlay / i18n 递归)一一对应。 三类问题(QC #6:i18n 未纳入扫描 = 假绿,现全部覆盖): UNREGISTERED 磁盘有文件但 PATHS 缺条目(漏注册 → 线上 403) MISSING FILE PATHS 有条目但磁盘无文件(悬空注册) BAD ENTRY PATHS 条目落在受管目录里却不符合命名/语言清单约定 WILDCARD 任何通配符(零容忍) """ root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) problems = [] registered = set(p for p, _r in PATHS) # 1) 磁盘上有但未注册(含 i18n 语言子目录递归) for sub, exts in sorted(AUDIT_DIRS.items()): for url_path, fs_path in _walk_disk(root, sub, exts): if url_path not in registered: problems.append('UNREGISTERED %s' % url_path) # 2) 注册了但磁盘缺文件(overlay / i18n / 页面壳三类都查) for path, _role in PATHS: marker = '/%s/' % MODULE if marker not in path: continue rel = path[len(marker):] head = rel.split('/', 1)[0] if head not in AUDIT_DIRS: continue # 目录型注册条目(如 '/pbl_scense_ext/pbl_overlay',basename 无扩展名) # 不做文件存在性检查——它注册的是目录路由,磁盘上是目录而非文件。 if '.' not in os.path.basename(rel): continue if not os.path.isfile(os.path.join(root, 'wwwroot', rel)): problems.append('MISSING FILE %s' % path) # 3) 页面壳条目必须与 OVERLAY_PAGES 注册表同源(防手滑写错页名) have = set('/%s/pbl_overlay/%s.ui' % (MODULE, k) for k in OVERLAY_PAGES) for path, _role in PATHS: if '/pbl_overlay/' in path and path not in have: problems.append('BAD ENTRY %s' % path) # 4) i18n 语言清单与磁盘语言目录一致(新增语言目录未登记 I18N_LANGS 即报错) i18n_base = os.path.join(root, 'wwwroot', 'i18n') if os.path.isdir(i18n_base): on_disk_langs = sorted(d for d in os.listdir(i18n_base) if os.path.isfile(os.path.join(i18n_base, d, 'msg.txt'))) if on_disk_langs != sorted(I18N_LANGS): problems.append('BAD ENTRY i18n 语言清单与磁盘不一致: 磁盘 %s / 注册 %s' % (on_disk_langs, sorted(I18N_LANGS))) # 5) 通配符零容忍 for path, _role in PATHS: if '%' in path or '*' in path: problems.append('WILDCARD FORBIDDEN %s' % path) return problems if __name__ == '__main__': bad = audit() for b in bad: print('AUDIT %s' % b) if '--audit' in sys.argv: sys.exit(1 if bad else 0) sys.exit(0 if register() else 1)