pbl_runtime_ext/scripts/m11b34_rbac_seed.py
agent.develop e141be2b9d feat(M11b-3/4): pbl_runtime_ext 8 条广播/轮询端点权限种子导入器 + 幂等 SQL(响应 QC #17/#18)
- init/rbac_seed.json: 8 条权限逐条显式登记(path/method/role/endpoint/milestone/traceability),无 % / * 通配符
- scripts/m11b34_rbac_seed.py: --check 离线三方一致性 / --emit-sql 幂等 SQL / --apply 连库 upsert + 库内回查
- QC #18-1: docstring 删去不存在的 dbname 兜底 'pbl' 与 $PBL_DBNAME 一级,改述为三级来源均缺即 FAIL(与 resolve_dbname 一致)
- QC #18-2: --apply 库内回查改为直接参数化 LIKE %s,去掉 ${p}$ replace 造占位符技巧
- QC #17: 模块仓选择性 git add 本地 commit 收口(禁 git add -A,生成目录/.venv/.rtx5tmp/egg-info 已在 .gitignore)
- sql/m11b34_rbac_seed.sql: 8 permission + 8 rolepermission 幂等 SQL,交 apps/pbls build.sh 第 9 步 import_rp
- 验证: py_compile OK; --check rc=0 errors=0; check_paths ok=True; 沙箱 --apply rc=2(1044 无库权限,不冒充成功)
2026-09-22 19:17:48 +08:00

382 lines
16 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 -*-
"""M11b-3 / M11b-4 权限种子导入器(pbl_runtime_ext 广播/轮询端点,8 条)。
背景:anchor3 自检 `scripts/load_path.py` rc=1,8 条 PENDING 全部属 M11b-3
服务端广播与 M11b-4 3s 轮询端点。本脚本把这 8 条 permission / rolepermission
种子**幂等**导入应用库,使 load_path.py 的自检收敛到 rc=0。
硬规定(module-development-spec 6.6 / crud-definition-spec
references/endpoint-registration.md / sqlor-database-module):
1. **逐条显式登记,禁 `%` / `*` / `?` 通配符**——导入前静态校验,命中即 FAIL 不写半截。
2. **load_path.py 单独不够**:新 api/*.dspy 必须同时落 DB `permission` +
`rolepermission`,否则线上 403。角色用 `logined`(登录即可访问)。
3. **不猜列名**:permission/rolepermission 的实际列集合运行时从
information_schema 读取,只写交集列;缺关键列直接 FAIL 而非静默跳过。
4. **幂等**:permission 按 path 查重(存在则 UPDATE 非键列),rolepermission 按
(roleid, permid) 查重;id 用 `md5` 派生的稳定 32 位串,重复执行零差异。
5. **库名不硬编码**:dbname 依次取 --dbname > $PBL_RUNTIME_EXT_DBNAME >
应用 conf/config.json 的 module_dbname 映射(真实值由应用 get_module_dbname
决定)。**三级来源均缺 → 直接 FAIL 退出,不猜库名、无 'pbl' 兜底**
(与 resolve_dbname() 实现一致:解析不到即返回 (None, 'unresolved'))。
真实表结构(rbac,见 modules/scense/scripts/ddl.sql 实测 DDL):
permission(id, name, description, ptype, parentid, path, title, icon)
rolepermission(id, roleid, permid)
用法:
python3 scripts/m11b34_rbac_seed.py --check # 离线校验,不连库(rc=0 即 errors=0)
python3 scripts/m11b34_rbac_seed.py --emit-sql x.sql # 出 SQL 交 build.sh 第 9 步
python3 scripts/m11b34_rbac_seed.py --apply # 连库幂等导入
"""
import argparse
import hashlib
import json
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
MODULE_DIR = os.path.dirname(HERE)
MODULE = 'pbl_runtime_ext'
SEED_JSON = os.path.join(MODULE_DIR, 'init', 'rbac_seed.json')
WWWROOT_API = os.path.join(MODULE_DIR, 'wwwroot', 'api')
LOAD_PATH_PY = os.path.join(HERE, 'load_path.py')
WILDCARD_RE = re.compile(r'[%*?]')
# rbac 真实列(information_schema 取交集后才使用,缺列 = FAIL,不静默降级)
PERM_REQUIRED = ['id', 'path']
PERM_OPTIONAL = ['name', 'description', 'ptype', 'parentid', 'title', 'icon']
RP_REQUIRED = ['id', 'roleid', 'permid']
# permission.ptype:api 类权限(与 rbac 菜单类权限区分,见 appbase 菜单 ptype='menu')
PTYPE_API = 'api'
def load_seed():
with open(SEED_JSON, 'r', encoding='utf-8') as f:
return json.load(f)
def stable_id(prefix, *parts):
"""32 位稳定 id(重复执行幂等,不用 UUID——UUID 每次不同会插重复行)。"""
h = hashlib.md5(('|'.join([MODULE] + [str(p) for p in parts])).encode('utf-8'))
return ('%s%s' % (prefix, h.hexdigest()))[:32]
def load_path_registered():
"""从 scripts/load_path.py 以 AST 解析 PATHS 的 (path, role) 集合(不 exec)。"""
import ast
with open(LOAD_PATH_PY, 'r', encoding='utf-8') as f:
tree = ast.parse(f.read(), filename=LOAD_PATH_PY)
pairs = set()
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
for tgt in node.targets:
if isinstance(tgt, ast.Name) and tgt.id == 'PATHS':
for item in ast.literal_eval(node.value):
if isinstance(item, (list, tuple)) and len(item) == 2:
pairs.add((item[0], item[1]))
return pairs
def static_validate(seed):
"""离线三方一致性(种子 ↔ 磁盘 .dspy ↔ load_path.PATHS)+ 通配符门禁。"""
errors = []
perms = seed.get('permissions') or []
if not perms:
return False, ['init/rbac_seed.json permissions 为空']
disk = set(f for f in os.listdir(WWWROOT_API) if f.endswith('.dspy')) \
if os.path.isdir(WWWROOT_API) else set()
registered = load_path_registered()
names, paths = set(), set()
for p in perms:
name, path, role = p.get('name') or '', p.get('path') or '', p.get('role') or ''
if name in names:
errors.append('重复权限名: %s' % name)
names.add(name)
paths.add(path)
if WILDCARD_RE.search(path):
errors.append('禁止通配符(硬政策): %s' % path)
if not path.startswith('/%s/api/' % MODULE):
errors.append('路径前缀不符合模块自动路由 /%s/api/: %s' % (MODULE, path))
if os.path.basename(path) not in disk:
errors.append('磁盘缺 .dspy 端点文件: %s' % os.path.basename(path))
if (path, role) not in registered:
errors.append('scripts/load_path.py PATHS 未登记: %s (%s)' % (path, role))
if not p.get('traceability'):
errors.append('缺 traceability(无法追溯到端点与角色): %s' % name)
if not p.get('milestone'):
errors.append('缺 milestone 归属: %s' % name)
for path, role in sorted(registered):
if path not in paths:
errors.append('load_path.py 有登记但种子清单缺失: %s (%s)' % (path, role))
for role, lst in (seed.get('role_grants') or {}).items():
for gname in lst:
if gname not in names:
errors.append('role_grants[%s] 引用不存在的权限名: %s' % (role, gname))
return (not errors), errors
def app_conf_candidates():
"""应用 conf/config.json 候选路径(库名/连接从应用配置读,禁止在本模块硬编码 DBNAME)。"""
ws = os.path.dirname(os.path.dirname(MODULE_DIR)) # .../sdlc_general
return [os.path.join(ws, 'apps', 'pbls', 'conf', 'config.json'),
os.environ.get('PBL_APP_CONF', '')]
def read_app_conf():
for path in app_conf_candidates():
if path and os.path.isfile(path):
try:
with open(path, 'r', encoding='utf-8') as f:
return json.load(f), path
except Exception: # noqa: BLE001
continue
return None, None
def resolve_dbname(args):
"""dbname 优先级:--dbname > $PBL_RUNTIME_EXT_DBNAME > 应用 module_dbname 映射。"""
if args.dbname:
return args.dbname, '--dbname'
env_name = os.environ.get('PBL_RUNTIME_EXT_DBNAME')
if env_name:
return env_name, '$PBL_RUNTIME_EXT_DBNAME'
conf, path = read_app_conf()
if conf:
m = (conf.get('module_dbname') or {})
name = m.get(MODULE) or m.get('default')
if name:
return name, 'conf:%s' % path
return None, 'unresolved'
def db_kwargs(dbname):
"""从应用 conf 的 databases[dbname].kwargs 取连接参数(不硬编码凭据)。"""
conf, _path = read_app_conf()
kw = {}
if conf:
entry = (conf.get('databases') or {}).get(dbname) or {}
kw = dict(entry.get('kwargs') or {})
kw.setdefault('host', os.environ.get('PBL_DB_HOST', '127.0.0.1'))
kw.setdefault('port', int(os.environ.get('PBL_DB_PORT', '3306')))
return kw
def table_columns(cur, dbname, table):
cur.execute("SELECT COLUMN_NAME FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s", (dbname, table))
return [r[0] for r in cur.fetchall()]
def perm_values(p, cols, role_hint='logined'):
"""按真实列集合构造 permission 行(只写交集列)。"""
pid = stable_id('rp_', p['path'])
row = {}
vals = {
'id': pid,
'path': p['path'],
'name': p['name'],
'title': p.get('title') or p['name'],
'ptype': PTYPE_API,
'parentid': 'pbl_runtime_ext',
'description': '%s | %s | role=%s | %s' % (
p.get('title') or p['name'], p.get('milestone') or '',
p.get('role') or role_hint, p.get('traceability') or ''),
'icon': 'api',
}
for c in cols:
row[c] = vals.get(c)
return pid, row
def build_rows(seed, perm_cols, rp_cols):
"""生成 (permission 行, rolepermission 行) 列表,全部 id 稳定 → 幂等。"""
perm_rows, rp_rows, name_to_pid = [], [], {}
for p in seed['permissions']:
pid, row = perm_values(p, perm_cols)
name_to_pid[p['name']] = pid
perm_rows.append((pid, row))
for role, names in (seed.get('role_grants') or {}).items():
for n in names:
pid = name_to_pid.get(n)
if not pid:
continue
rrow = {'id': stable_id('rr_', role, pid)}
if 'roleid' in rp_cols:
rrow['roleid'] = role
if 'permid' in rp_cols:
rrow['permid'] = pid
rp_rows.append(rrow)
return perm_rows, rp_rows
def upsert(cur, table, cols, rows, key_col, stats):
"""按 key_col 查重:存在 UPDATE,不存在 INSERT。rows 为 dict 列表。"""
for r in rows:
cur.execute('SELECT COUNT(1) FROM `%s` WHERE `%s` = %%s' % (table, key_col),
(r[key_col],))
exists = bool(cur.fetchone()[0])
present = [c for c in cols if c in r]
if exists:
sets = ', '.join('`%s` = %%s' % c for c in present if c != key_col)
if sets:
cur.execute('UPDATE `%s` SET %s WHERE `%s` = %%s' % (table, sets, key_col),
[r[c] for c in present if c != key_col] + [r[key_col]])
stats['%s_updated' % table] = stats.get('%s_updated' % table, 0) + 1
else:
ph = ', '.join(['%s'] * len(present))
cur.execute('INSERT INTO `%s` (%s) VALUES (%s)'
% (table, ', '.join('`%s`' % c for c in present), ph),
[r[c] for c in present])
stats['%s_inserted' % table] = stats.get('%s_inserted' % table, 0) + 1
return stats
def emit_sql(seed, perm_cols, rp_cols, path):
perm_rows, rp_rows = build_rows(seed, perm_cols, rp_cols)
lines = ['-- M11b-3/M11b-4 pbl_runtime_ext 广播/轮询端点权限种子(8 条,逐条显式,无通配符)',
'-- 由 scripts/m11b34_rbac_seed.py 生成,幂等(id 为 path/role 的 md5 派生)',
'']
for pid, r in perm_rows:
present = [c for c in perm_cols if c in r]
vals = ', '.join("'%s'" % (str(r[c]).replace("'", "''")) for c in present)
lines.append('INSERT INTO `permission` (%s) VALUES (%s)'
% (', '.join('`%s`' % c for c in present), vals)
+ ' ON DUPLICATE KEY UPDATE '
+ ', '.join('`%s`=VALUES(`%s`)' % (c, c)
for c in present if c != 'id') + ';')
lines.append('')
for r in rp_rows:
present = [c for c in rp_cols if c in r]
vals = ', '.join("'%s'" % r[c] for c in present)
lines.append('INSERT INTO `rolepermission` (%s) VALUES (%s)'
% (', '.join('`%s`' % c for c in present), vals)
+ ' ON DUPLICATE KEY UPDATE `permid`=VALUES(`permid`);')
lines.append('')
lines.append('-- 校验:应为 8 条 permission + 8 条 rolepermission')
lines.append("SELECT COUNT(1) AS perm_cnt FROM `permission` "
"WHERE `path` LIKE '/pbl\\_runtime\\_ext/api/%';")
with open(path, 'w', encoding='utf-8') as f:
f.write('\n'.join(lines) + '\n')
return len(perm_rows), len(rp_rows)
def get_conn(dbname):
try:
import pymysql
except ImportError:
return None
kw = db_kwargs(dbname)
kw['charset'] = 'utf8mb4'
kw['autocommit'] = False
kw['connect_timeout'] = 6
return pymysql.connect(**kw)
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--check', action='store_true', help='只做离线校验,不连库')
ap.add_argument('--apply', action='store_true', help='连库幂等导入')
ap.add_argument('--emit-sql', default=None, help='输出幂等 SQL 文件(不连库)')
ap.add_argument('--dbname', default=None)
args = ap.parse_args()
seed = load_seed()
ok, errors = static_validate(seed)
print('[%s] rbac_seed 静态校验: permissions=%d errors=%d'
% (MODULE, len(seed['permissions']), len(errors)))
for e in errors:
print(' FAIL ' + e)
if not ok:
return 1
print(' OK 全部路径逐条显式登记(无 %% / * 通配符);'
'种子 ↔ 磁盘 .dspy ↔ load_path.PATHS 三方一致')
if args.check:
print('[%s] --check 通过(离线,未连库)' % MODULE)
return 0
dbname, src = resolve_dbname(args)
if not dbname:
print(' FAIL 无法解析 %s 的库名(--dbname / $PBL_RUNTIME_EXT_DBNAME / '
'应用 conf module_dbname 均缺)' % MODULE)
return 1
print(' dbname=%s (来源 %s)' % (dbname, src))
if args.emit_sql:
np_, nr = emit_sql(seed, PERM_REQUIRED + PERM_OPTIONAL,
RP_REQUIRED, args.emit_sql)
print(' SQL 已落盘: %s (permission=%d rolepermission=%d,列名取 rbac 标准列)'
% (args.emit_sql, np_, nr))
return 0
try:
import pymysql # noqa: F401
except ImportError:
print(' SKIP 无 pymysql:改用 --emit-sql 出 SQL 交 build.sh 第 9 步导入')
return 2
try:
conn = get_conn(dbname)
except Exception as e: # noqa: BLE001
print(' SKIP 连库失败(%s):DB 不可达时用 --emit-sql 出 SQL,'
'交 build.sh 第 9 步(import_rp)导入' % str(e)[:100])
return 2
cur = conn.cursor()
try:
perm_cols = table_columns(cur, dbname, 'permission')
rp_cols = table_columns(cur, dbname, 'rolepermission')
except Exception as e: # noqa: BLE001
print(' SKIP 无法读取 %s 库的 rbac 表结构(%s):'
'先执行 rbac 建表或用 --emit-sql 出 SQL' % (dbname, str(e)[:80]))
conn.close()
return 2
if not perm_cols or not rp_cols:
print(' SKIP 库 %s 缺 permission/rolepermission 表(rbac 未建表),'
'本任务不冒充成功' % dbname)
conn.close()
return 2
missing = [c for c in PERM_REQUIRED if c not in perm_cols] \
+ [c for c in RP_REQUIRED if c not in rp_cols]
if missing:
print(' FAIL rbac 表缺关键列: %s(实际列 %s / %s)'
% (missing, perm_cols, rp_cols))
conn.close()
return 1
use_perm = [c for c in PERM_REQUIRED + PERM_OPTIONAL if c in perm_cols]
use_rp = [c for c in RP_REQUIRED if c in rp_cols]
perm_rows, rp_rows = build_rows(seed, use_perm, use_rp)
if not args.apply:
print(' DRY:加 --apply 才写库,或 --emit-sql 出 SQL')
conn.close()
return 0
stats = {}
stats = upsert(cur, 'permission', use_perm, [r for _, r in perm_rows], 'path', stats)
stats = upsert(cur, 'rolepermission', use_rp, rp_rows, 'id', stats)
conn.commit()
cur.execute("SELECT COUNT(1) FROM `permission` WHERE `path` LIKE %s",
('/%s/api/%%' % MODULE,))
stats['verify_permission_in_db'] = cur.fetchone()[0]
cur.execute("SELECT COUNT(1) FROM `rolepermission` rp JOIN `permission` p "
"ON rp.permid = p.id WHERE p.path LIKE %s", ('/%s/api/%%' % MODULE,))
stats['verify_rolepermission_in_db'] = cur.fetchone()[0]
conn.close()
print('[%s] 导入结果: %s' % (MODULE, json.dumps(stats, ensure_ascii=False)))
good = (stats.get('verify_permission_in_db') == len(seed['permissions'])
and stats.get('verify_rolepermission_in_db') == len(seed['permissions']))
print(' %s 库内回查:permission=%s rolepermission=%s 期望各 %d'
% ('OK' if good else 'FAIL', stats.get('verify_permission_in_db'),
stats.get('verify_rolepermission_in_db'), len(seed['permissions'])))
return 0 if good else 1
if __name__ == '__main__':
sys.exit(main())