#!/usr/bin/env python3 # -*- coding: utf-8 -*- """M9-收口C 权限种子导入器(pbl_scense_ext 覆盖层 26 条路径,消 pending=26)。 背景:M9-收口B 一致性核验实测 `scripts/load_path.py`(register 分支)rc=1、 `rbac paths: total=26 ok=0 pending=26` —— 声明侧合规(--audit rc=0)但权限表零 落库,线上覆盖层 403。本脚本把 init/rbac_seed.json 的 26 条 permission / rolepermission 种子**幂等**导入应用库,并作为 scripts/rbac_set_perm.py 的底层 实现(load_path.register() 的 RBAC_SET_PERM 工具)。 实现模式对齐同项目已收口的 modules/pbl_runtime_ext/scripts/m11b34_rbac_seed.py (commit e141be2 幂等种子导入器 + b54ff00 traceability grep 实测)。 硬规定(module-development-spec 硬门禁 6.6 / rbac-permission-initialization -pattern / sqlor-database-module): 1. **逐条显式登记,禁 `%` / `*` / `?` 通配符**——离线校验命中即 FAIL,不写半截。 2. **permission.path 唯一语义**:一个 path 一行 permission;多角色靠多条 rolepermission 指向同一 permid。rolepermission.id 显式生成(rbac 表无自增)。 3. **不猜列名**:permission/rolepermission/role 的实际列集合运行时从 information_schema 读取,只写交集列;缺关键列直接 FAIL 而非静默跳过。 4. **幂等**:permission 按 path 查重(存在则 UPDATE 非键列),rolepermission 按 (roleid, permid) 查重;id 用 md5(path)/md5(role|path) 派生的稳定 32 位串, 重复执行零差异、行数不增。 5. **logined / any 语义不混用**:种子里每条 permission 的 role 必须与其所在 role_grants 分桶一致,交叉即 FAIL。 6. **库名/凭据不硬编码**:dbname 依次取 --dbname > $PBL_SCENSE_EXT_DBNAME > 应用 conf/config.json 的 module_dbname 映射;连接参数取 databases[dbname].kwargs,可被 $PBL_DB_HOST/PORT/USER/PASSWORD 覆盖 (沙箱库验收用)。解析不到库名 → 直接 FAIL,不猜。 7. **超管授权运行时解析**(QC #12):seed.superadmin_grant.role_names 是**角色 名称**候选,导入时查 role 表(列交集,按 role/name/rolelabel 命中)取真实 32 位 id 再写 rolepermission;解析不到打印 WARN 并在退出码中保持 0(伪角色 logined/any 已按 rbac userperm.py 语义覆盖全部登录/匿名主体),绝不静默。 用法: python3 scripts/m9c_rbac_seed.py --check # 离线校验,不连库(rc=0 即 errors=0) python3 scripts/m9c_rbac_seed.py --emit-sql x.sql # 出幂等 SQL(交 build.sh 第 9 步 import_rp) python3 scripts/m9c_rbac_seed.py --apply # 连库幂等导入 + 库内回查 python3 scripts/m9c_rbac_seed.py --report # 连库只读回查 26 条落库行数 python3 scripts/m9c_rbac_seed.py --upsert-one logined /pbl_scense_ext/xxx.dspy """ import argparse import hashlib import importlib.util import json import os import re import sys HERE = os.path.dirname(os.path.abspath(__file__)) MODULE_DIR = os.path.dirname(HERE) MODULE = 'pbl_scense_ext' APP_NAME = 'pbls' SEED_JSON = os.path.join(MODULE_DIR, 'init', 'rbac_seed.json') WWWROOT = os.path.join(MODULE_DIR, 'wwwroot') 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'] # role 表里可能承载「角色名称」的列(按实测 DDL:id/orgtypeid/role/rolelabel/name) ROLE_NAME_COLS = ['role', 'name', 'rolelabel'] # permission.ptype 按资源类别区分(api 端点 / page 页面壳 / static 静态资源) PTYPE_BY_KIND = {'api': 'api', 'page': 'page', 'static': 'static'} # rbac 伪角色(不是 role 表实体行,userperm.py 按主体类型直接判定;这里作为 # rolepermission.roleid 的授权键,与 pbl_runtime_ext 已收口做法一致) PSEUDO_ROLES = ('logined', 'any') 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_pairs(): """导入 scripts/load_path.py 读 PATHS(含循环 append 的 12 条页面壳)。 用 importlib 而非 AST:load_path.PATHS 由字面量 + for 循环 append 构成, ast.literal_eval 只能拿到字面量部分(会漏 13 条),三方一致性校验会假红。 load_path.py 有 __main__ 守卫,import 无副作用。 """ spec = importlib.util.spec_from_file_location('_pbl_scense_ext_load_path', LOAD_PATH_PY) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return [(p, r) for p, r in mod.PATHS], mod def path_to_fs(path): """URL 路径 → wwwroot 下期望文件/目录(目录型条目返回目录)。""" marker = '/%s' % MODULE if not path.startswith(marker): return None, None rel = path[len(marker):].lstrip('/') fs = os.path.join(WWWROOT, *rel.split('/')) if rel else WWWROOT return rel, fs def static_validate(seed): """离线三方一致性(种子 ↔ load_path.PATHS ↔ 磁盘文件)+ 通配符/语义门禁。""" errors, warns = [], [] perms = seed.get('permissions') or [] if not perms: return False, ['init/rbac_seed.json permissions 为空'], [] pairs, _mod = load_path_pairs() registered = set(pairs) names, paths = [], 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.append(name) if path in paths: errors.append('重复权限路径(permission.path 必须唯一): %s' % path) paths.add(path) if WILDCARD_RE.search(path): errors.append('禁止通配符(硬政策): %s' % path) if not path.startswith('/%s' % MODULE): errors.append('路径前缀不符合模块自动路由 /%s: %s' % (MODULE, path)) if role not in PSEUDO_ROLES: errors.append('role 必须是伪角色 %s 之一: %s -> %s' % (PSEUDO_ROLES, name, role)) rel, fs = path_to_fs(path) if fs and not (os.path.exists(fs) or os.path.isdir(fs)): errors.append('磁盘缺资源文件: %s (期望 %s)' % (path, os.path.relpath(fs, MODULE_DIR))) for key in ('traceability', 'milestone', 'title', 'kind', 'endpoint'): if not p.get(key): errors.append('缺 %s 字段: %s' % (key, name)) # 双向集合一致:种子 26 条 == load_path.PATHS 26 条(同 path 同 role) seed_pairs = set((p['path'], p['role']) for p in perms) for path, role in sorted(registered - seed_pairs): errors.append('load_path.py 有登记但种子缺失: %s (%s)' % (path, role)) for path, role in sorted(seed_pairs - registered): errors.append('种子有但 load_path.py PATHS 未登记: %s (%s)' % (path, role)) grants = seed.get('role_grants') or {} granted = set() for role, lst in grants.items(): for gname in lst: granted.add(gname) hit = [p for p in perms if p.get('name') == gname] if not hit: errors.append('role_grants[%s] 引用不存在的权限名: %s' % (role, gname)) elif hit[0].get('role') != role: # 退回意见 #12:logined / any 语义不得混用 errors.append('role_grants 分桶与 permission.role 不一致(语义混用): %s 在 [%s] 桶但 role=%s' % (gname, role, hit[0].get('role'))) for p in perms: if p['name'] not in granted: warns.append('权限未进入任何 role_grants 分桶: %s' % p['name']) # 超管段语义(退回意见 #12):role_ids 不得塞角色名;要么留真实 id,要么空 sup = seed.get('superadmin_grant') or {} for rid in sup.get('role_ids') or []: if not re.match(r'^[0-9a-f]{32}$', str(rid)): errors.append('superadmin_grant.role_ids 含非 32 位主键值(角色名应放 role_names): %s' % rid) if not sup.get('role_names'): warns.append('superadmin_grant.role_names 为空:超管授权只能靠 role 表解析结果,未配置候选名') return (not errors), errors, warns # ── 库名 / 连接(禁止硬编码 DBNAME 与凭据)────────────────────────────────── def app_conf_candidates(): ws = os.path.dirname(os.path.dirname(MODULE_DIR)) return [os.path.join(ws, 'apps', APP_NAME, '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_SCENSE_EXT_DBNAME > 应用 module_dbname 映射。""" if getattr(args, 'dbname', None): return args.dbname, '--dbname' env_name = os.environ.get('PBL_SCENSE_EXT_DBNAME') if env_name: return env_name, '$PBL_SCENSE_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,允许 $PBL_DB_* 覆盖。""" conf, _path = read_app_conf() kw = {} entry = ((conf or {}).get('databases') or {}).get(dbname) or {} kw = dict(entry.get('kwargs') or {}) kw.pop('dbname', None) kw.pop('db', None) kw.pop('charset', None) if entry.get('password') and 'password' not in kw: kw['password'] = entry.get('password') kw.setdefault('host', os.environ.get('PBL_DB_HOST', '127.0.0.1')) kw.setdefault('port', int(os.environ.get('PBL_DB_PORT', '3306'))) for env_key, key in (('PBL_DB_USER', 'user'), ('PBL_DB_PASSWORD', 'password'), ('PBL_DB_SCHEMA', 'database')): if os.environ.get(env_key): kw[key] = os.environ[env_key] # conf 里没有该 dbname 的条目时(如沙箱库 --dbname),database 直接取解析出的库名 kw.setdefault('database', dbname) kw['charset'] = 'utf8mb4' kw['autocommit'] = False kw['connect_timeout'] = 6 return kw def get_conn(dbname): try: import pymysql except ImportError: return None return pymysql.connect(**db_kwargs(dbname)) 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()] # ── 行构造(id 稳定 → 幂等)──────────────────────────────────────────────── def perm_row(p, cols): pid = stable_id('rp_', p['path']) vals = { 'id': pid, 'path': p['path'], 'name': p['name'], 'title': p.get('title') or p['name'], 'ptype': PTYPE_BY_KIND.get(p.get('kind'), 'api'), 'parentid': MODULE, 'description': '%s | %s | role=%s | %s' % ( p.get('title') or p['name'], p.get('milestone') or '', p.get('role') or '', (p.get('traceability') or '')[:120]), 'icon': p.get('kind') or 'api', } return pid, {c: vals.get(c) for c in cols} def rp_row(role, pid, path, cols): rrow = {'id': stable_id('rr_', role, path)} if 'roleid' in cols: rrow['roleid'] = role if 'permid' in cols: rrow['permid'] = pid return rrow def build_rows(seed, perm_cols, rp_cols, superadmin_ids=()): """permission 每 path 一行;rolepermission = 伪角色分桶 + 解析到的超管实体角色。""" perm_rows, rp_rows, name_to = [], [], {} for p in seed['permissions']: pid, row = perm_row(p, perm_cols) name_to[p['name']] = (pid, p['path']) perm_rows.append((pid, row)) for role, lst in (seed.get('role_grants') or {}).items(): for n in lst: hit = name_to.get(n) if hit: rp_rows.append(rp_row(role, hit[0], hit[1], rp_cols)) for srole in superadmin_ids: for n, (pid, path) in name_to.items(): rp_rows.append(rp_row(srole, pid, path, rp_cols)) return perm_rows, rp_rows def upsert(cur, table, cols, rows, key_col, stats, key_val=None): """按 key_col 查重:存在 UPDATE 非键列,不存在 INSERT。rows 为 dict 列表。""" for r in rows: kv = key_val(r) if key_val else r[key_col] cur.execute('SELECT id FROM `%s` WHERE `%s` = %%s' % (table, key_col), (kv,)) hit = cur.fetchone() present = [c for c in cols if c in r] if hit: sets = [c for c in present if c != 'id'] if sets: cur.execute('UPDATE `%s` SET %s WHERE id = %%s' % (table, ', '.join('`%s` = %%s' % c for c in sets)), [r[c] for c in sets] + [hit[0]]) 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 resolve_superadmin(cur, dbname, seed, stats): """退回意见 #12:运行时按 role_names 查 role 表取真实 32 位 id,回填后授权。""" sup = seed.get('superadmin_grant') or {} names = [n for n in (sup.get('role_names') or []) if n] configured_ids = [r for r in (sup.get('role_ids') or []) if re.match(r'^[0-9a-f]{32}$', str(r))] if not sup.get('grant_all'): print(' INFO superadmin_grant.grant_all=false,跳过超管授权') return configured_ids cols = table_columns(cur, dbname, 'role') if not cols: stats['superadmin'] = 'WARN: role 表不存在,未授权超管' print(' WARN role 表不存在(rbac 未建表),超管授权跳过') return configured_ids name_cols = [c for c in ROLE_NAME_COLS if c in cols] found = list(configured_ids) for nc in name_cols: for n in names: cur.execute('SELECT id FROM `role` WHERE `%s` = %%s' % nc, (n,)) for row in cur.fetchall(): rid = str(row[0]) if len(rid) <= 32 and rid not in found: found.append(rid) print(' OK 超管角色解析命中: role.%s=%s -> id=%s' % (nc, n, rid)) if not found: stats['superadmin'] = 'WARN: 未解析到超管实体角色(候选名 %s)' % names print(' WARN 未解析到超管实体角色(候选 %s,扫描列 %s);' 'logined/any 伪角色已覆盖全部登录/匿名主体,不 FAIL' % (names, name_cols)) else: stats['superadmin_resolved'] = len(found) return found def emit_sql(seed, perm_cols, rp_cols, out_path, superadmin_ids=()): perm_rows, rp_rows = build_rows(seed, perm_cols, rp_cols, superadmin_ids) lines = ['-- M9-收口C pbl_scense_ext 覆盖层权限种子(26 条,逐条显式,无 % / * 通配符)', '-- 由 scripts/m9c_rbac_seed.py 生成;id 为 path/role 的 md5 派生 → 幂等(可重复执行)', '-- 用法:交 apps/pbls/build.sh 第 9 步(import_rp)或直接 mysql < 本文件', ''] 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 += ['', '-- 校验:permission 应为 26 行,rolepermission 应为 26 行(logined 22 + any 4)', "SELECT COUNT(1) AS perm_cnt FROM `permission` WHERE `parentid` = '%s';" % MODULE] with open(out_path, 'w', encoding='utf-8') as f: f.write('\n'.join(lines) + '\n') return len(perm_rows), len(rp_rows) def verify_db(cur, dbname, seed, stats): paths = [p['path'] for p in seed['permissions']] ph = ', '.join(['%s'] * len(paths)) cur.execute('SELECT COUNT(1) FROM `permission` WHERE `path` IN (%s)' % ph, paths) 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 IN (%s)' % ph, paths) stats['verify_rolepermission_in_db'] = cur.fetchone()[0] cur.execute('SELECT COUNT(DISTINCT rp.roleid) FROM `rolepermission` rp JOIN ' '`permission` p ON rp.permid = p.id WHERE p.path IN (%s)' % ph, paths) stats['verify_distinct_roles'] = cur.fetchone()[0] return stats def report_db(cur, dbname, seed): """逐条打印 26 条路径在 permission / rolepermission 的实测行数(验收证据)。""" print(' %-58s %-8s %6s %6s' % ('path', 'role', 'perm', 'rolem')) total_p = total_r = 0 for p in seed['permissions']: cur.execute('SELECT id FROM `permission` WHERE `path` = %s', (p['path'],)) rows = cur.fetchall() pid = rows[0][0] if rows else None if pid: cur.execute('SELECT COUNT(1) FROM `rolepermission` WHERE `permid` = %s', (pid,)) rc = cur.fetchone()[0] else: rc = 0 total_p += len(rows) total_r += rc print(' %-58s %-8s %6d %6d' % (p['path'], p['role'], len(rows), rc)) print(' TOTAL permission=%d rolepermission=%d(期望 26 / >=26)' % (total_p, total_r)) return total_p, total_r def apply_seed(args, single=None): """连库幂等导入。single=(role, path) 时只 upsert 一条(rbac_set_perm.py 用)。""" seed = load_seed() ok, errors, warns = static_validate(seed) for e in errors: print(' FAIL ' + e) if not ok: return 1 dbname, src = resolve_dbname(args) if not dbname: print(' FAIL 无法解析 %s 的库名(--dbname / $PBL_SCENSE_EXT_DBNAME / ' '应用 conf module_dbname 均缺)' % MODULE) return 1 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)[:120]) return 2 if conn is None: print(' SKIP 无 pymysql:改用 --emit-sql 出 SQL 交 build.sh 第 9 步导入') 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)' % (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] if single: role, path = single hit = [p for p in seed['permissions'] if p['path'] == path] if not hit: print(' FAIL 种子清单里没有这条路径: %s' % path) conn.close() return 1 if hit[0]['role'] != role: print(' FAIL 角色与种子声明不一致: %s 应为 %s(传入 %s)' % (path, hit[0]['role'], role)) conn.close() return 1 pid, row = perm_row(hit[0], use_perm) stats = {} stats = upsert(cur, 'permission', use_perm, [row], 'path', stats) stats = upsert(cur, 'rolepermission', use_rp, [rp_row(role, pid, path, use_rp)], 'id', stats) conn.commit() print('[%s] upsert-one ok: role=%s path=%s %s' % (MODULE, role, path, json.dumps(stats, ensure_ascii=False))) conn.close() return 0 stats = {} sup_ids = resolve_superadmin(cur, dbname, seed, stats) perm_rows, rp_rows = build_rows(seed, use_perm, use_rp, sup_ids) if not (args.apply or args.report): print(' DRY:加 --apply 才写库,或 --emit-sql 出 SQL') conn.close() return 0 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() verify_db(cur, dbname, seed, stats) print('[%s] 导入结果 dbname=%s(%s): %s' % (MODULE, dbname, src, json.dumps(stats, ensure_ascii=False))) if args.report: report_db(cur, dbname, seed) expect_p = len(seed['permissions']) good = stats.get('verify_permission_in_db') == expect_p \ and stats.get('verify_rolepermission_in_db', 0) >= expect_p print(' %s 库内回查:permission=%s rolepermission=%s(期望 %d / >=%d)' % ('OK' if good else 'FAIL', stats.get('verify_permission_in_db'), stats.get('verify_rolepermission_in_db'), expect_p, expect_p)) conn.close() return 0 if good else 1 def main(): ap = argparse.ArgumentParser() ap.add_argument('--check', action='store_true', help='只做离线校验,不连库') ap.add_argument('--apply', action='store_true', help='连库幂等导入') ap.add_argument('--report', action='store_true', help='连库幂等导入 + 逐条落库行数回查') ap.add_argument('--emit-sql', default=None, help='输出幂等 SQL 文件(不连库)') ap.add_argument('--upsert-one', nargs=2, metavar=('ROLE', 'PATH'), help='单条幂等 upsert(scripts/rbac_set_perm.py 的内部入口)') ap.add_argument('--dbname', default=None) args = ap.parse_args() seed = load_seed() ok, errors, warns = static_validate(seed) pairs, _mod = load_path_pairs() print('[%s] rbac_seed 静态校验: seed=%d load_path.PATHS=%d errors=%d warns=%d' % (MODULE, len(seed['permissions']), len(pairs), len(errors), len(warns))) for e in errors: print(' FAIL ' + e) for w in warns: print(' WARN ' + w) if not ok: return 1 print(' OK 26 条路径逐条显式登记(无 %% / * 通配符);' '种子 ↔ load_path.PATHS ↔ 磁盘资源 三方一致;logined/any 分桶无混用' % ()) if args.check: print('[%s] --check 通过(离线,未连库)' % MODULE) return 0 if args.upsert_one: return apply_seed(args, single=tuple(args.upsert_one)) 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 return apply_seed(args) if __name__ == '__main__': sys.exit(main())