pbls/scripts/load_path.py

385 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 -*-
"""pbls 应用(中央宿主)RBAC 注册 —— 双层回退的第二层。
背景(module-development-spec「Dual-Layer RBAC」/ QC 退回意见 #1)
----------------------------------------------------------------
模块自带的 ``modules/{mod}/scripts/load_path.py`` 直接调 set_role_perm.py,
实测在部分部署机上会**静默失败**(ModuleNotFoundError 之类),后果是「菜单里看得到、
点进去一律 403」。因此宿主应用必须有自己的一份注册入口(不同代码路径 = 可靠回退)。
本脚本的做法:**聚合**各 pbl_* / world_sync 模块 load_path.py 里的路径常量,
而不是抄一份清单。理由:清单抄两遍必然漂移(模块加了新接口,中央层忘了同步 = 又是 403)。
聚合后中央层与模块层永远一致;同时对 pbl_evidence 保留一份**硬编码兜底清单**,
即使模块脚本 import 失败也一定登记进去。
聚合形态兼容(M11b-2c-T3 QC 退回意见 #2 修复:消除「整模块静默 skip」)
--------------------------------------------------------------------
模块层允许下列任一写法,中央层逐条**归一**为 ``(path, role)``:
① 扁平二元组 ``PATHS = [('/x/a.dspy', 'logined'), ...]``
② 扁平纯字符串 ``PATHS = ['/x/a.dspy', ...]`` → role 取模块 ``DEFAULT_ROLE``(缺省 logined)
③ 三元组及以上 ``PATHS = [('/x/a.dspy','logined','备注'), ...]`` → 取前二
④ 分层常量 ``PATHS_ANY`` / ``PATHS_LOGINED`` / ``PATHS_OWNER_SUPERUSER``
/ ``PATHS_OWNER_OPERATOR``(world_sync 采用此写法)
⑤ 汇总别名 ``ALL_PATHS``,或 ``API_PATHS`` + ``UI_PATHS``
(pbl_validation / pbl_domain_ext 采用此写法,此前被静默跳过 = 同类缺口)
⑥ 显式零路径 ``NO_WEB_PATHS = True``(纯库函数模块,如 pbl_common / pbl_appcodes)
—— 必须**显式声明**,不允许以「常量留空」表达「无路径」。
**单条形态非法**(既不是 str 也不是 (list,tuple))→ 只跳过该条并记 WARN,不再丢弃整模块。
一致性门禁(QC 退回意见 #2 第 ② 点)
---------------------------------
``--check`` 除通配符审计外,新增**聚合完整性门禁**:MODULES 中凡存在
``scripts/load_path.py`` 的模块,若 ①import 失败 ②一条路径都没聚合到且没有
``NO_WEB_PATHS`` 显式声明 ③存在形态非法的路径条目 → 计入 FAIL 清单并 **rc=1**,
不再出现「聚合失败但 check 仍 PASS」。
用法
----
python3 scripts/load_path.py # 聚合全部模块路径并注册
python3 scripts/load_path.py --check # 一致性核对 + 通配符审计(不连库)
python3 scripts/load_path.py --list # 打印最终在册清单
WS_DIR=/path/to/workspace python3 scripts/load_path.py # 指定机构工作空间
铁律:路径逐条显式,禁止通配符(``%`` / ``*``)。注意区分「RBAC 通配符」与
「Python 格式化占位符」:模块源码里 ``"/api/x/%s.dspy" % a`` 的 ``%s`` 是**构造期**
占位符,展开后的路径字符串不含 ``%``;本脚本审计的是**展开后**的运行时字符串。
═══════════════════════════════════════════════════════════════════════════════
里程碑 RBAC 声明台账(中央层,与模块层双写等价)
═══════════════════════════════════════════════════════════════════════════════
M11b-2(T3,2026-09-22):
结论 = 无新路径登记(中央层同样零新增条目)。
理由 = M11b-2 里程碑(world_sync 列级差异比对 + pbl_runtime_ext 单事务事件/状态写入)
只交付 **Python 库函数** 与 models/*.json 表定义,**不新增任何
.dspy / .ui / .js / .css 可访问路径**,不经 HTTP 路由,故 RBAC 零登记项。
双写 = 等价声明同时写在 modules/world_sync/scripts/load_path.py 文件头台账、
modules/pbl_runtime_ext/scripts/load_path.py(如存在)、以及说明文件
modules/world_sync/wwwroot/api/README-N-A-M11b2.md。
附带修复(QC #2)= 见上「聚合形态兼容」+「一致性门禁」两节:pbl_blueprint(扁平
纯字符串 PATHS,139 条)与 pbl_validation / pbl_domain_ext(API_PATHS /
ALL_PATHS)此前在中央层被静默 skip,本次全部聚合成功。
═══════════════════════════════════════════════════════════════════════════════
"""
import argparse
import importlib.util
import os
import subprocess
import sys
APP_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 机构工作空间根:环境变量 > 应用仓库上溯(apps/pbls -> apps -> workspace)
WS_DIR = os.environ.get('WS_DIR') or os.path.normpath(os.path.join(APP_DIR, '..', '..'))
# 需要登记 RBAC 的模块(pbl_* 全量 + 宿主挂载的 world_sync)
MODULES = [
'pbl_common', 'pbl_appcodes', 'pbl_governance', 'pbl_blueprint', 'pbl_template',
'pbl_validation', 'pbl_compiler', 'pbl_agent_runtime', 'pbl_evidence',
'pbl_assessment', 'pbl_kdb_ext', 'pbl_domain_ext', 'pbl_scense_ext',
'pbl_analytics', 'pbl_runtime_ext',
'world_sync',
]
# 模块未显式给出 role 时的缺省授权层(可用模块常量 DEFAULT_ROLE 覆盖)
DEFAULT_ROLE = 'logined'
# 分层常量 → role 映射
TIER_ATTRS = (
('PATHS_ANY', 'any'),
('PATHS_LOGINED', 'logined'),
('PATHS_OWNER_SUPERUSER', 'owner.superuser'),
('PATHS_OWNER_OPERATOR', 'owner.operator'),
)
FORBIDDEN_WILDCARDS = ('%', '*')
# ── 里程碑声明台账(中央层,与模块层双写;--check / --list 会原样回显)────────
MILESTONE_DECLARATIONS = [
{
'milestone': 'M11b-2',
'new_paths': 0,
'declaration': (
'M11b-2 本里程碑不新增 .dspy/.ui/.js/.css 路径,仅提供库函数'
'(world_sync / pbl_runtime_ext 的 *.py 与 models/*.json),'
'故无 RBAC 登记项;禁通配符(%/*),'
'如有路径须按 any/logined/owner.* 分层逐条登记(中央层与模块层双写)。'
),
},
]
# ── pbl_evidence 硬编码兜底清单(与模块层一致;模块脚本 import 失败时由此登记)────
PBL_EVIDENCE_FALLBACK = [
('/pbl_evidence/index.ui', 'logined'),
('/pbl_evidence/api/pbl_artifact_create.dspy', 'logined'),
('/pbl_evidence/api/pbl_artifact_read.dspy', 'logined'),
('/pbl_evidence/api/pbl_artifact_update.dspy', 'logined'),
('/pbl_evidence/api/pbl_artifact_delete.dspy', 'logined'),
('/pbl_evidence/api/pbl_artifact_list.dspy', 'logined'),
('/pbl_evidence/api/pbl_evidence_collect.dspy', 'logined'),
('/pbl_evidence/api/pbl_evidence_collect_from_events.dspy', 'logined'),
('/pbl_evidence/api/pbl_evidence_list.dspy', 'logined'),
('/pbl_evidence/api/pbl_evidence_stats.dspy', 'logined'),
]
# 授权严格度排序:数值越大越严格。冲突时保留更严格者,避免合并顺序放宽权限。
ROLE_STRICTNESS = {'any': 0, 'logined': 1, 'owner.operator': 2, 'owner.superuser': 3}
def _stricter(a, b):
return a if ROLE_STRICTNESS.get(a, 1) >= ROLE_STRICTNESS.get(b, 1) else b
def _normalize(entry, mod, origin, bad):
"""把一条路径声明归一为 (path, role);形态非法则记入 bad 并返回 None。
只跳过**这一条**,绝不因单条异形而丢弃整个模块(QC 退回意见 #2 第 ① 点)。
"""
role_default = DEFAULT_ROLE
if isinstance(entry, (tuple, list)):
if len(entry) >= 2: # 二元组直取;三元组取前二
path, role = str(entry[0]), str(entry[1])
elif len(entry) == 1: # 单元素容器按纯字符串处理
path, role = str(entry[0]), role_default
else:
bad.append('%s: %s 含空条目 %r(已跳过该条)' % (mod, origin, entry))
return None
elif isinstance(entry, str): # 纯字符串 → 默认 role
path, role = entry, role_default
else:
bad.append('%s: %s 含非法条目类型 %s(已跳过该条)'
% (mod, origin, type(entry).__name__))
return None
path = path.strip()
if not path.startswith('/'):
bad.append('%s: %s 条目 %r 不是绝对路径(已跳过该条)' % (mod, origin, path))
return None
return (path, role)
def _load_module_paths(mod):
"""从 modules/{mod}/scripts/load_path.py 读路径常量(不执行其注册逻辑)。
返回 ``(paths, note, failed)``:
* ``failed=True`` 表示该模块**聚合失败**(import 异常 / 零路径且无显式
``NO_WEB_PATHS`` 声明 / 存在被跳过的非法条目),``--check`` 据此判 FAIL。
* 脚本不存在时返回 ``(None, 'no scripts/load_path.py', False)``——不算失败
(该模块尚无声明入口,由 MODULES 维护者决定何时补)。
"""
script = os.path.join(WS_DIR, 'modules', mod, 'scripts', 'load_path.py')
if not os.path.exists(script):
return None, 'no scripts/load_path.py', False
try:
spec = importlib.util.spec_from_file_location('_lp_' + mod, script)
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
except Exception as exc: # noqa: BLE001
return [], 'import failed: %s: %s' % (type(exc).__name__, exc), True
default_role = getattr(m, 'DEFAULT_ROLE', DEFAULT_ROLE)
if isinstance(default_role, str) and default_role:
globals()['DEFAULT_ROLE'] = default_role # 本模块归一期间生效
else:
default_role = DEFAULT_ROLE
out, bad = [], []
try:
paths = getattr(m, 'PATHS', None)
if paths:
for e in paths:
got = _normalize(e, mod, 'PATHS', bad)
if got:
out.append(got)
for attr, role in TIER_ATTRS:
tier = getattr(m, attr, None)
if not tier:
continue
for e in tier:
got = _normalize(e, mod, attr, bad)
if got:
out.append((got[0], role))
if not out: # 写法 ⑤ 汇总别名
# ALL_PATHS 优先(它通常就是 API_PATHS+UI_PATHS 的并集,避免重复计数)
alias_attrs = (('ALL_PATHS',) if getattr(m, 'ALL_PATHS', None)
else ('API_PATHS', 'UI_PATHS'))
for attr in alias_attrs:
alias = getattr(m, attr, None)
if not alias:
continue
for e in alias:
got = _normalize(e, mod, attr, bad)
if got:
out.append(got)
finally:
globals()['DEFAULT_ROLE'] = DEFAULT_ROLE
# 去重:模块同时提供扁平 PATHS 与分层常量时(如 pbl_common)会重复采集,
# 同一 path 保留更严格的 role,避免计数虚高。
seen, deduped = {}, []
for p, r in out:
if p in seen:
deduped[seen[p]] = (p, _stricter(deduped[seen[p]][1], r))
continue
seen[p] = len(deduped)
deduped.append((p, r))
out = deduped
if not out:
if bad:
return [], 'all entries malformed -> ' + '; '.join(bad), True
if getattr(m, 'NO_WEB_PATHS', False): # 写法 ⑥ 显式零路径
return [], 'declares NO_WEB_PATHS=True (zero web paths, explicit)', False
if getattr(m, 'MILESTONE_DECLARATIONS', None):
return [], 'declares milestone台账 only (zero web paths)', False
return [], ('PATHS / PATHS_* / ALL_PATHS empty and NO_WEB_PATHS not declared'
' -> aggregation FAILED'), True
note = 'aggregated %d paths' % len(out)
if bad:
note += ' (WARN %d malformed entries skipped: %s)' % (len(bad), '; '.join(bad))
return out, note, bool(bad)
def collect():
"""聚合全部模块路径 + 兜底清单。
返回 ``(paths, notes, failures)``:
* ``paths`` 去重排序后的 ``(path, role)`` 清单;
* ``notes`` 逐模块回显行;
* ``failures``聚合失败模块清单(``--check`` 非空即 rc=1)。
"""
merged, notes, failures, conflicts = {}, [], [], []
for mod in MODULES:
# 脚本存在性判断已下沉到 _load_module_paths()(got is None 即代表脚本缺失),
# 此处不再重复 stat(M11b-2c-T3 QC 退回意见 #14:清理不可达死代码)。
got, err, failed = _load_module_paths(mod)
if mod == 'pbl_evidence':
# 兜底清单始终并入(union),保证模块脚本坏掉也不漏登记
for p, r in PBL_EVIDENCE_FALLBACK:
merged[p] = r
if got is None:
notes.append('%s: no scripts/load_path.py (fallback list applied)' % mod)
continue
if failed:
failures.append('%s: %s (fallback list applied)' % (mod, err))
notes.append('%s: %s (fallback list applied)' % (mod, err))
elif not got:
notes.append('%s: %s (fallback list applied)' % (mod, err))
else:
notes.append('%s: aggregated %d paths from module script + fallback'
% (mod, len(got)))
else:
if got is None:
notes.append('%s: %s (skipped)' % (mod, err))
continue
if not got and not failed:
# 合法的空:模块显式声明 NO_WEB_PATHS / 仅里程碑台账
notes.append('%s: %s' % (mod, err))
continue
if failed and not got:
failures.append('%s: %s' % (mod, err))
notes.append('%s: %s (FAILED)' % (mod, err))
continue
for p, r in got:
if p in merged and merged[p] != r:
keep = _stricter(merged[p], r)
conflicts.append('%s: %s role %s vs %s -> keep %s (stricter)'
% (mod, p, merged[p], r, keep))
merged[p] = keep
else:
merged[p] = r
tag = ' (WARN partial)' if failed else ''
notes.append('%s: aggregated %d paths%s' % (mod, len(got), tag))
if failed:
failures.append('%s: %s' % (mod, err))
# 注:此处无需再判 got is None —— 上方各分支在 got is None 时均已 continue
# (M11b-2c-T3 QC 退回意见 #14 删除的不可达分支)。
for c in conflicts:
notes.append('[conflict] ' + c)
return sorted((p, merged[p]) for p in merged), notes, failures
def find_set_perm():
env_tool = os.environ.get('RBAC_SET_PERM')
if env_tool and os.path.exists(env_tool):
return env_tool
for root in (os.path.expanduser('~/repos/sage'), os.path.expanduser('~/test/sage'),
'/d/ymq/repos/sage', '/opt/sage'):
cand = os.path.join(root, 'py3', 'bin', 'set_role_perm.py')
if os.path.exists(cand):
return cand
from shutil import which
return which('set_role_perm.py')
def register(paths, verbose=True):
tool = find_set_perm()
if not tool:
if verbose:
print('[apps/pbls] set_role_perm.py not found -> %d paths PENDING '
'(rerun inside the app venv on the deploy host)' % len(paths))
return [p for p, _r in paths]
py = sys.executable or 'python3'
pending = []
for path, role in paths:
rc = subprocess.call([py, tool, role, path],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if rc != 0:
pending.append((path, role))
if verbose:
print('[apps/pbls] rbac paths: total=%d ok=%d pending=%d'
% (len(paths), len(paths) - len(pending), len(pending)))
for path, role in pending:
print(' PENDING %-8s %s' % (role, path))
return [p for p, _r in pending]
def print_milestone_declarations():
print('[apps/pbls] 里程碑 RBAC 声明台账(中央层,与模块层双写等价)')
for d in MILESTONE_DECLARATIONS:
print(' - %s: 新增路径 %d 条 -> %s'
% (d['milestone'], d['new_paths'], d['declaration']))
def main():
ap = argparse.ArgumentParser(description='pbls central RBAC registration (fallback layer)')
ap.add_argument('--check', action='store_true',
help='aggregate + wildcard audit + consistency gate (no DB)')
ap.add_argument('--list', action='store_true', help='print aggregated paths and exit')
args = ap.parse_args()
print_milestone_declarations()
paths, notes, failures = collect()
for note in notes:
print('[collect] ' + note)
bad = [p for p, _r in paths if any(w in p for w in FORBIDDEN_WILDCARDS)]
for p in bad:
print('WILDCARD FORBIDDEN: ' + p)
if args.list:
for path, role in paths:
print('%s %s' % (role, path))
return 0 if (not bad and not failures) else 1
if args.check:
# 门禁 1:通配符(零容忍);门禁 2:聚合完整性(有脚本却聚合失败 = FAIL)
print('[apps/pbls] consistency: module_scripts=%d aggregated=%d '
'zero_path_declarations=%d failed_modules=%d'
% (len([n for n in notes if 'no scripts/load_path.py' not in n]),
len([n for n in notes if 'aggregated' in n]),
len([n for n in notes if 'NO_WEB_PATHS' in n or '台账 only' in n]),
len(failures)))
for f in failures:
print('AGGREGATION FAILED: ' + f)
ok = (not bad) and (not failures)
print('[apps/pbls] check: total=%d wildcard=%d aggregation_failures=%d -> %s'
% (len(paths), len(bad), len(failures), 'PASS' if ok else 'FAIL'))
return 0 if ok else 1
pending = register(paths)
return 0 if (not pending and not bad and not failures) else 1
if __name__ == '__main__':
sys.exit(main())