pbl_scense_ext/scripts/m9c_rbac_seed.py
2026-09-22 20:54:19 +08:00

537 lines
24 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 -*-
"""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())