diff --git a/accounting/init.py b/accounting/init.py index 21be33b..f16d185 100644 --- a/accounting/init.py +++ b/accounting/init.py @@ -37,17 +37,18 @@ async def get_accdetail(request, accountid, page=1): env = request._run_ns userorgid = await env.get_userorgid() async with get_sor_context(env, 'accounting') as sor: + # 数据隔离:只允许查询本机构名下账户的明细,防止按 accountid 越权枚举他人账务 sql = """select a.*, c.name from acc_detail a, account b, subject c where b.subjectid = c.id and a.accountid = b.id and b.id = ${accountid}$ + and b.orgid = ${orgid}$ """ ns = { 'accountid': accountid, - 'page': page, - 'sort': 'acc_date desc' + 'orgid': userorgid } ret = await sor.sqlExe(sql, ns) return ret diff --git a/scripts/load_path.py b/scripts/load_path.py index a886e3a..1c45d4e 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -1,22 +1,44 @@ #!/usr/bin/env python3 """ -accounting 模块 RBAC 权限管理脚本 +accounting 模块 RBAC 权限管理脚本(角色分层版,2026-08-27 重构) + +重构原因(安全修复): + 旧版把整个财务后台(余额台账/账户/科目/分录/日志/信用/币种/汇率的增删改查) + 全部注册给 logined 角色,任何注册用户可见/可改/可删全平台余额与账务数据。 + 现按角色分层: + - any 免登录静态资源(菜单片段/图标)+ 带 localhost 校验的定时任务入口 + - logined 客户自服务页面(后端均已按本人/本机构过滤:我的账户、账务明细、账单、我的信用额度) + - 财务角色 财务后台全部 CRUD(余额台账、账户、科目、分录配置、日志、信用管理、币种汇率) + - 分销商角色 代客充值(含按用户名查询客户余额,属分销商业务需要) + +行为: + 默认先清理 /accounting/ 下全部旧授权(rolepermission),再按新矩阵重新注册。 + (旧授权里 logined、customer.customer 等角色持有财务后台增删改权限,不清理则泄漏依旧) + --add-only 跳过清理,仅增量注册(新路径/新宿主接入时用) + +角色解析: + 权限缓存按 role.id 关联(rbac/userperm.py load_roleperms),同名角色在不同宿主 + 的 role.id 可能不同(如 'owner.superuser' 字面量 vs 随机 id),因此本脚本 + 用 orgtypeid+name 查 role 表解析出真实 role.id 后注册,同名多条全部注册。 使用方法: - cd ~/repos/sage - ./py3/bin/python ~/repos/accounting/scripts/load_path.py + cd (含 py3/ 与 set_role_perm.py 的目录,如 /d/pipeline/pipeline-app) + ./py3/bin/python pkgs/accounting/scripts/load_path.py [--add-only] """ import subprocess import os import sys +import asyncio -def find_sage_root(): +def find_app_root(): candidates = [ os.path.expanduser("~/repos/sage"), os.path.expanduser("~/sage"), + # pkgs/accounting/scripts/load_path.py 的上三级 = 应用根目录 os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + os.getcwd(), ] for c in candidates: if os.path.isdir(os.path.join(c, "py3")) and os.path.isdir(os.path.join(c, "wwwroot")): @@ -24,43 +46,84 @@ def find_sage_root(): return None -SAGE_ROOT = find_sage_root() -if not SAGE_ROOT: - print("ERROR: Cannot find Sage root directory") +APP_ROOT = find_app_root() +if not APP_ROOT: + print("ERROR: Cannot find app root directory") sys.exit(1) -PYTHON = os.path.join(SAGE_ROOT, "py3", "bin", "python") -SET_PERM_SCRIPT = os.path.join(SAGE_ROOT, "set_role_perm.py") +PYTHON = os.path.join(APP_ROOT, "py3", "bin", "python") +SET_PERM_SCRIPT = os.path.join(APP_ROOT, "set_role_perm.py") MOD = "accounting" # ============================================================ -# 权限路径定义 +# 角色定义(标签格式 .,运行时解析为真实 role.id) # ============================================================ -# any — 无需登录 +# 财务后台管理角色:平台方超管/管理员/会计 + 分销商管理员/会计/运营 +FIN_ROLES = [ + "owner.superuser", + "owner.admin", + "owner.account", + "reseller.admin", + "reseller.accountant", + "reseller.operator", +] + +# 代客充值角色:分销商侧可代客户充值(需按用户名查询客户账户,属业务需要) +PROXY_ROLES = [ + "reseller.admin", + "reseller.accountant", + "reseller.operator", + "reseller.sale", +] + +# ============================================================ +# 权限路径矩阵 +# ============================================================ + +# any — 无需登录(静态资源 + 自带 localhost 校验的定时任务入口) PATHS_ANY = [ f"/{MOD}/usermenu.ui", + f"/{MOD}/imgs/accbalance.svg", + f"/{MOD}/imgs/accdetail.svg", + f"/{MOD}/imgs/account.svg", + f"/{MOD}/imgs/bill.svg", + # 定时任务入口(build.sh 注入的 cron 裸 curl 无会话,必须 any; + # dspy 内部已做 client_ip=localhost 校验,nginx XFF 追加模式伪造不成立) f"/{MOD}/api/fetch_forex_rates.dspy", ] -# logined — 所有已登录用户 +# logined — 客户自服务(后端查询均已按当前用户/本机构过滤) PATHS_LOGINED = [ + f"/{MOD}/myaccounts.ui", + f"/{MOD}/myaccounts.dspy", + f"/{MOD}/mybalance.dspy", + f"/{MOD}/accdetail.ui", + f"/{MOD}/accdetail.dspy", + f"/{MOD}/billing.ui", + f"/{MOD}/billing.dspy", + f"/{MOD}/billing_download.dspy", + f"/{MOD}/get_user_balance.dspy", + f"/{MOD}/open_customer_accounts_with_orgid.dspy", + f"/{MOD}/error_accounting.ui", + # 我的信用额度(查询按本机构过滤) + f"/{MOD}/credit_limit/credit_overview.ui", +] + +# 财务后台 — 仅财务角色 +PATHS_FIN = [ # 模块入口 f"/{MOD}", f"/{MOD}/index.ui", - # 顶层 .ui 页面 - f"/{MOD}/myaccounts.ui", - f"/{MOD}/accdetail.ui", + # 统计卡片(按机构汇总的财务看板) + f"/{MOD}/stat_total_balance.ui", + f"/{MOD}/stat_today_consumption.ui", + f"/{MOD}/stat_month_consumption.ui", + f"/{MOD}/stat_account_count.ui", - # 顶层 .dspy - f"/{MOD}/accdetail.dspy", - f"/{MOD}/billing.dspy", - f"/{MOD}/billing_download.dspy", - f"/{MOD}/get_user_balance.dspy", - f"/{MOD}/myaccounts.dspy", - f"/{MOD}/mybalance.dspy", + # 开户管理(平台/分销商侧操作,可指定任意 orgid) f"/{MOD}/oca.dspy", f"/{MOD}/open_customer_accounts.dspy", f"/{MOD}/open_owner_accounts.dspy", @@ -68,128 +131,223 @@ PATHS_LOGINED = [ f"/{MOD}/open_reseller_accounts.dspy", f"/{MOD}/open_reseller_provider_accounts.dspy", - # 统计卡片 .ui - f"/{MOD}/stat_total_balance.ui", - f"/{MOD}/stat_today_consumption.ui", - f"/{MOD}/stat_month_consumption.ui", - f"/{MOD}/stat_account_count.ui", - - # acc_balance/ + # acc_balance/(日终余额台账,全平台数据,无 orgid 列) f"/{MOD}/acc_balance/index.ui", f"/{MOD}/acc_balance/get_acc_balance.dspy", f"/{MOD}/acc_balance/add_acc_balance.dspy", f"/{MOD}/acc_balance/update_acc_balance.dspy", f"/{MOD}/acc_balance/delete_acc_balance.dspy", - # acc_detail/ + # acc_detail/(记账明细台账) f"/{MOD}/acc_detail/index.ui", f"/{MOD}/acc_detail/get_acc_detail.dspy", f"/{MOD}/acc_detail/add_acc_detail.dspy", f"/{MOD}/acc_detail/update_acc_detail.dspy", f"/{MOD}/acc_detail/delete_acc_detail.dspy", - # account/ + # account/(账户管理) f"/{MOD}/account/index.ui", f"/{MOD}/account/get_account.dspy", f"/{MOD}/account/add_account.dspy", f"/{MOD}/account/update_account.dspy", f"/{MOD}/account/delete_account.dspy", - # account_config/ + # account_config/(参与方科目配置) f"/{MOD}/account_config/index.ui", f"/{MOD}/account_config/get_account_config.dspy", f"/{MOD}/account_config/add_account_config.dspy", f"/{MOD}/account_config/update_account_config.dspy", f"/{MOD}/account_config/delete_account_config.dspy", - # accounting_config/ + # accounting_config/(会计分录配置) f"/{MOD}/accounting_config/index.ui", f"/{MOD}/accounting_config/get_accounting_config.dspy", f"/{MOD}/accounting_config/add_accounting_config.dspy", f"/{MOD}/accounting_config/update_accounting_config.dspy", f"/{MOD}/accounting_config/delete_accounting_config.dspy", - # accounting_log/ + # accounting_log/(记账日志) f"/{MOD}/accounting_log/index.ui", f"/{MOD}/accounting_log/get_accounting_log.dspy", f"/{MOD}/accounting_log/add_accounting_log.dspy", f"/{MOD}/accounting_log/update_accounting_log.dspy", f"/{MOD}/accounting_log/delete_accounting_log.dspy", - # subject/ + # subject/(科目) f"/{MOD}/subject/index.ui", f"/{MOD}/subject/get_subject.dspy", f"/{MOD}/subject/add_subject.dspy", f"/{MOD}/subject/update_subject.dspy", f"/{MOD}/subject/delete_subject.dspy", - # credit_limit/ + # credit_limit/(信用额度管理侧;客户自服务的 hub/overview 在 logined 组) f"/{MOD}/credit_limit/index.ui", f"/{MOD}/credit_limit/get_credit_limit.dspy", f"/{MOD}/credit_limit/add_credit_limit.dspy", f"/{MOD}/credit_limit/update_credit_limit.dspy", f"/{MOD}/credit_limit/delete_credit_limit.dspy", - f"/{MOD}/credit_limit/hub.ui", f"/{MOD}/credit_limit/credit_manage.ui", - f"/{MOD}/credit_limit/credit_overview.ui", f"/{MOD}/credit_limit/api/credit_summary.dspy", f"/{MOD}/credit_limit/api/set_credit_form.ui", f"/{MOD}/credit_limit/api/set_customer_credit.dspy", - # currency_list/ - f"/{MOD}/currency_list/index.ui", - f"/{MOD}/currency_list/get_currency_list.dspy", - f"/{MOD}/currency_list/add_currency_list.dspy", - f"/{MOD}/currency_list/update_currency_list.dspy", - f"/{MOD}/currency_list/delete_currency_list.dspy", + # currency/ + exchange_rate/(币种与汇率管理,目录名以 build 生成的 tblname 为准) + f"/{MOD}/currency/index.ui", + f"/{MOD}/currency/get_currency.dspy", + f"/{MOD}/currency/add_currency.dspy", + f"/{MOD}/currency/update_currency.dspy", + f"/{MOD}/currency/delete_currency.dspy", + f"/{MOD}/exchange_rate/index.ui", + f"/{MOD}/exchange_rate/get_exchange_rate.dspy", + f"/{MOD}/exchange_rate/add_exchange_rate.dspy", + f"/{MOD}/exchange_rate/update_exchange_rate.dspy", + f"/{MOD}/exchange_rate/delete_exchange_rate.dspy", - # exchange_rate_list/ - f"/{MOD}/exchange_rate_list/index.ui", - f"/{MOD}/exchange_rate_list/get_exchange_rate_list.dspy", - f"/{MOD}/exchange_rate_list/add_exchange_rate_list.dspy", - f"/{MOD}/exchange_rate_list/update_exchange_rate_list.dspy", - f"/{MOD}/exchange_rate_list/delete_exchange_rate_list.dspy", - - # api (manual DSPY) + # api(币种/汇率手工维护入口) f"/{MOD}/api/currency_create.dspy", f"/{MOD}/api/currency_update.dspy", f"/{MOD}/api/currency_delete.dspy", f"/{MOD}/api/exchange_rate_create.dspy", f"/{MOD}/api/exchange_rate_update.dspy", f"/{MOD}/api/exchange_rate_delete.dspy", +] - # proxy_recharge/ +# 代客充值 — 仅分销商角色 +PATHS_PROXY = [ f"/{MOD}/proxy_recharge.ui", f"/{MOD}/proxy_recharge_submit.dspy", ] + # ============================================================ -# 执行注册 +# 数据库辅助(角色解析 + 旧授权清理) # ============================================================ - -def run_set_perm(role, path): - cmd = [PYTHON, SET_PERM_SCRIPT, role, path] - result = subprocess.run(cmd, capture_output=True, text=True) - return result.returncode == 0 +def _get_db(): + sys.path.insert(0, APP_ROOT) + from sqlor.dbpools import DBPools + from appPublic.jsonConfig import getConfig + config = getConfig(APP_ROOT, NS={'workdir': APP_ROOT}) + return DBPools(config.databases) -def register_role_paths(role, paths): +def _row_get(row, key): + try: + return row[key] + except Exception: + return getattr(row, key, None) + + +async def _find_rbac_db(db): + """遍历 config.databases,找到含 role 表的库(pipeline 应用是 pipeline 库,sage 是 sage 库)。""" + names = list(db.databases.keys()) if hasattr(db, 'databases') else [] + for dbname in names: + try: + async with db.sqlorContext(dbname) as sor: + await sor.sqlExe('select count(*) as c from role', {}) + await sor.sqlExe('COMMIT', {}) + return dbname + except Exception: + continue + return None + + +async def resolve_role_ids(labels): + """标签 'orgtypeid.name' → 真实 role.id 列表(同名多条全返回)。""" + db = _get_db() + dbname = await _find_rbac_db(db) + mapping = {} + if not dbname: + print('WARN: role 表不可达,按字面量注册(可能无效)') + return {lb: [lb] for lb in labels} + async with db.sqlorContext(dbname) as sor: + for label in labels: + if label in ('any', 'logined', 'anonymous'): + mapping[label] = [label] + continue + orgtypeid, _, name = label.partition('.') + recs = await sor.sqlExe( + 'select id from role where orgtypeid=${o}$ and name=${n}$', + {'o': orgtypeid, 'n': name}) + ids = [_row_get(r, 'id') for r in recs] + ids = [i for i in ids if i] + if not ids: + print(f'WARN: 角色不存在 {label},跳过') + mapping[label] = ids + await sor.sqlExe('COMMIT', {}) + return mapping + + +async def clean_accounting_roleperms(): + """删除 /accounting/ 下全部旧授权(保留 permission 行本身)。""" + db = _get_db() + dbname = await _find_rbac_db(db) + if not dbname: + print('ERROR: 无法定位 RBAC 库,清理中止') + return -1 + total = 0 + async with db.sqlorContext(dbname) as sor: + perms = await sor.sqlExe( + 'select id from permission where path like ${p}$ or path=${p2}$', + {'p': f'/{MOD}/%', 'p2': f'/{MOD}'}) + for p in perms: + pid = _row_get(p, 'id') + r = await sor.sqlExe('delete from rolepermission where permid=${pid}$', {'pid': pid}) + total += 1 + await sor.sqlExe('COMMIT', {}) + print(f'清理完成:/accounting/ 下 {len(perms)} 个 permission 的旧授权已删除') + return total + + +# ============================================================ +# 注册 +# ============================================================ + +def run_set_perm(role_id, path): + cmd = [PYTHON, SET_PERM_SCRIPT, role_id, path] + result = subprocess.run(cmd, capture_output=True, text=True, cwd=APP_ROOT) + if result.returncode != 0: + print(f" FAIL {role_id} {path}: {(result.stderr or result.stdout).strip()[:200]}") + return False + return True + + +def register_role_paths(role_label, role_ids, paths): + if not role_ids: + return 0 count = 0 - for p in paths: - if run_set_perm(role, p): - count += 1 - print(f" {role}: {count}/{len(paths)} paths registered") + for rid in role_ids: + for p in paths: + if run_set_perm(rid, p): + count += 1 + print(f" {role_label} ({','.join(role_ids)}): {count}/{len(paths) * len(role_ids)} entries registered") return count def main(): - print(f"Sage root: {SAGE_ROOT}") + add_only = '--add-only' in sys.argv + print(f"App root: {APP_ROOT}") + + if not add_only: + print('=== 清理旧授权 ===') + asyncio.run(clean_accounting_roleperms()) + + print('=== 解析角色 ===') + all_labels = sorted(set(FIN_ROLES + PROXY_ROLES)) + role_map = asyncio.run(resolve_role_ids(all_labels)) + for lb in all_labels: + print(f' {lb} -> {role_map.get(lb) or "(不存在)"}') + + print('=== 注册新矩阵 ===') total = 0 - total += register_role_paths("any", PATHS_ANY) - total += register_role_paths("logined", PATHS_LOGINED) + total += register_role_paths('any', ['any'], PATHS_ANY) + total += register_role_paths('logined', ['logined'], PATHS_LOGINED) + for label in FIN_ROLES: + total += register_role_paths(label, role_map.get(label, []), PATHS_FIN) + for label in PROXY_ROLES: + total += register_role_paths(label, role_map.get(label, []), PATHS_PROXY) print(f"\nDone. Total {total} permission entries registered.") - print("NOTE: Restart Sage after permission changes to reload RBAC cache.") + print("NOTE: 重启应用(或调用 /rbac/refresh_userperm.dspy)后权限生效。") if __name__ == "__main__": diff --git a/wwwroot/get_user_balance.dspy b/wwwroot/get_user_balance.dspy index 2523d80..d9186b2 100644 --- a/wwwroot/get_user_balance.dspy +++ b/wwwroot/get_user_balance.dspy @@ -1,4 +1,6 @@ -username= params_kw.username +# 安全:只允许查询登录用户本人的余额。 +# 历史问题:外部传任意 username 即可查询他人余额(且原文件有 swait 拼写错误,一调用就 500)。 +userid = await get_user() env = request._run_ns async with get_sor_context(env, 'accounting') as sor: sql = """select @@ -8,12 +10,9 @@ a.balance from account a, subject c, users d where a.orgid = d.orgid and a.subjectid = c.id - and d.username = ${username}$ + and d.id = ${userid}$ """ - recs = swait or.sqlExe(sql, { - 'username': username, - 'sort': 'username' - }) + recs = await sor.sqlExe(sql, {'userid': userid}) return { 'status': 'ok', 'data': recs