- 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 无库权限,不冒充成功)
382 lines
16 KiB
Python
382 lines
16 KiB
Python
#!/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())
|