添加权限

This commit is contained in:
zlt 2026-08-27 09:43:47 +08:00
parent 1be155c478
commit fcc39984a9
4 changed files with 82 additions and 67 deletions

View File

@ -33,6 +33,16 @@ PMS_WEB_PORT=38100
# openssl rand -base64 48
PMS_SESSION_SECRET=
# ---- 登录角色标记 (2026-08 加权限) ----
# 角色从 bshop 登录返回的权限清单里认。两项都可逗号分隔多条; 联测时直接指到测试账号真有的权限即可。
# 系统管理员 = 权限里含 PMS_ADMIN_PERMS 任一条 (system_key:menu_key)。默认 pms:admin;
# 想先拿现成权限联测, 就临时指到管理员账号有的某条, 比如 band:yhyqx (用户与权限)。
PMS_ADMIN_PERMS=band:yhyqx # 含这条权限的账号 = 系统管理员(示例用“用户与权限”)
# 交易员准入 = 进入 PMS 至少要占 PMS_TRADER_PERMS 任一条; 留空 = 任何能登录的人都是交易员。
# 想把交易员也限成某条权限 (只给持仓管理权限的人才是交易员), 就指到那条, 比如 band:ccgl。
# 机器:部署服务器 tlai4090.env 里)
PMS_TRADER_PERMS=band:ccgl # 进 PMS 至少要占这条 = 交易员;留空则任何能登录的人都是交易员
# ---- QMT WebSocket 直连通道的密钥 (协议 QMT_WS_PROTOCOL.md §10.1.1) ----
# 这两项是**密钥**, 只在这里维护: 不入库、不进 ParamStore、不上管理页面、不进代码。
# 生成一对新密钥 (公钥带外交给 QMT 侧, 私钥不出机器):

View File

@ -2,14 +2,14 @@
"""
PMS 登录与权限 认证解析 / 角色判定 / 会话票签验 / 接口鉴权判定纯函数
=======================================================================
方案见PMS 加登录与两个角色交易系统管理员 / 交易员本模块刻意只放纯函数
方案见PMS 加登录与两个角色系统管理员 / 交易员本模块刻意只放纯函数
不连库几乎不联网喂进去字典或字符串就能算便于单测
scripts/test_batch18_units.py唯一碰网络的是 call_bshop_login 一个函数
它把真正的 HTTP 调用隔离出来其余全部可离线测
三块职责
认证解析第三方登录系统 bshop 的登录返回parse_bshop_login
角色 bshop 返回的权限清单里认出交易系统管理员 / 交易员role_from_permissions
角色 bshop 返回的权限清单里认出系统管理员 / 交易员role_from_permissions
会话与鉴权自签会话票make_session / verify_session以及按方法 + 路径
判这个请求要什么角色authz_decision
@ -26,7 +26,7 @@ import time
from typing import Optional
# 角色常量:既是写进会话票的字面值,也是回给前端、供前端判断的字面值。
ROLE_ADMIN = "交易系统管理员"
ROLE_ADMIN = "系统管理员"
ROLE_TRADER = "交易员"
@ -92,7 +92,7 @@ def _markers(csv: str) -> set:
def role_from_permissions(permissions, admin_perms_csv: str,
trader_perms_csv: str = "") -> Optional[str]:
"""按权限清单判角色。
- 命中任一管理员标记权限 -> 交易系统管理员
- 命中任一管理员标记权限 -> 系统管理员
- 否则若配置了交易员准入且一条都不占 -> None无权进入
- 否则 -> 交易员
permissions "system_key:menu_key" 字符串列表parse_bshop_login 已归一
@ -159,20 +159,19 @@ def verify_session(cookie: str, secret: str, now: Optional[float] = None) -> Opt
# ============================================================ 四、接口鉴权判定
# 交易员放行的写接口(白名单)。其余任何写接口一律只给管理员 —— 「写操作默认拒绝」。
# 路径按段匹配,"*" 吃掉一段路径参数(如 {command_id})。
_TRADER_WRITE = [
("POST", ("api", "commands")), # 下命令
("POST", ("api", "commands", "*", "cancel")), # 撤命令
("POST", ("api", "commands", "*", "replan")), # 改期重排
("POST", ("api", "proposals", "*", "decide")), # 拍板提议
# 分工按前端两块视图划分(用户 2026-08 拍板):交易员工作台(前端 mode=trader上能点到
# 的都归交易员只有运维视图mode=ops里的系统维护操作归系统管理员。所以这里反过来只
# 登记「只给管理员」的那一小撮运维接口,其余已登录的人(交易员)默认放行 —— 交易员的能力
# 跟着他那一页走:日常下单、撤单、拍板、策略、参数微调、大盘冷热采纳,都算交易员的活。
# 只给管理员的接口族(前缀匹配,任何方法都算):
_ADMIN_PREFIX = [
("api", "ops"), # 回放 / 对账 / 日终 / 日报 / 择时扫描 / plan-refresh 等全部运维操作
("api", "ws-channel"), # ws 直连通道管理
("api", "industry"), # 行业划分数据导入
]
# 只给管理员的读接口(其余 GET 所有登录人可看)。
_ADMIN_GET = [
("GET", ("api", "ops", "rebuild-preflight")),
("GET", ("api", "ops", "rebuild-accept")),
("GET", ("api", "ops", "downstream-schema")),
# 只给管理员的单个接口(精确匹配方法与整条路径):
_ADMIN_EXACT = [
("POST", ("api", "proposals")), # 人工补录提议:影子期造数调试口,没有交易员入口
]
@ -187,22 +186,19 @@ def _match(pattern: tuple, segs: tuple) -> bool:
def authz_decision(method: str, path: str) -> str:
"""判这个请求所需的最低角色,返回 "admin"(要交易系统管理员)或 "any"(任何登录人可)。
规则写方法POST/PUT/PATCH/DELETE不在交易员白名单里就要管理员
GET/HEAD 默认放行仅少数运维读接口要管理员
只对已登录且不在公开白名单的请求调用公开白名单在 is_public_path
"""判这个请求所需的最低角色,返回 "admin"(要系统管理员)或 "any"(任何登录人可)。
规则命中只给管理员的运维接口族或那条人工补录提议的调试口就要管理员其余
已登录的人都放行只对已登录且不在公开白名单的请求调用白名单在 is_public_path
"""
method = (method or "").upper()
segs = _seg(path)
if method in ("GET", "HEAD"):
for m, pat in _ADMIN_GET:
if m == method and _match(pat, segs):
return "admin"
return "any"
for m, pat in _TRADER_WRITE:
for m, pat in _ADMIN_EXACT:
if m == method and _match(pat, segs):
return "any"
return "admin"
return "admin"
for pref in _ADMIN_PREFIX:
if len(segs) >= len(pref) and tuple(segs[:len(pref)]) == pref:
return "admin"
return "any"
# 公开路径:不需要登录也放行。首页 / 必须放行,否则登录框自己都加载不出来。

View File

@ -405,7 +405,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<div v-if="macroGateOn" style="color:var(--warn)">大盘偏热,系统已经停掉它自己发起的买入,以及策略里的买入动作。卖出不受影响,你自己下的命令也不受影响。</div>
<div v-if="mzSig.advice && !mzSig.advice.adopted" style="margin-top:4px">
<b>今日建议:{{ mzSig.advice.direction==='REDUCE' ? '降仓' : '升仓' }} {{ (mzSig.advice.pct*100).toFixed(1) }}%</b>
<button v-if="isAdmin" class="tbtn pri" style="margin-left:8px" @click="macroAdopt(mzSig.key)">采纳并下命令</button>
<button class="tbtn pri" style="margin-left:8px" @click="macroAdopt(mzSig.key)">采纳并下命令</button>
<div class="muted" style="font-size:11px">{{ mzSig.advice.why }} · 当日有效,隔日作废</div>
</div>
<div class="muted" v-if="mzSig.last_command" style="font-size:11px">最近一条大盘冷热命令 {{ mzSig.last_command.command_id }} · {{ mzSig.last_command.cmd_type==='REDUCE_EXPOSURE' ? '降仓' : '升仓' }} · {{ mzSig.last_command.status }}</div>
@ -872,7 +872,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<el-table-column label="下单量 / 已成交" width="130" align="right"><template #default="s"><span class="mono">{{ s.row.qty }} / {{ s.row.exec_qty||0 }}</span></template></el-table-column>
<el-table-column label="限价" width="82" align="right"><template #default="s"><span class="mono">{{ s.row.limit_price||'—' }}</span></template></el-table-column>
<el-table-column label="进展" min-width="130"><template #default="s"><span class="chip idle"><span class="dot"></span>{{ tx('insStatus', s.row.status) }}</span></template></el-table-column>
<el-table-column label="操作" width="76"><template #default="s"><button v-if="isAdmin" class="tbtn sm" @click="cancelIns(s.row.instruction_id)">撤单</button></template></el-table-column>
<el-table-column label="操作" width="76"><template #default="s"><button class="tbtn sm" @click="cancelIns(s.row.instruction_id)">撤单</button></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane name="done">
@ -1043,7 +1043,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<div class="st" style="color:var(--warn)" v-if="s.buy_paused">
这只票现在只卖不买。决策系统提示{{ s.buy_paused.reason || '资金异动' }},已经停掉买入;
卖出、以及把当天做T买的那部分卖回去都不受影响。</div>
<div style="margin-top:6px" v-if="isAdmin">
<div style="margin-top:6px">
<el-button v-if="s.status==='ACTIVE'" size="small" @click="setStrategyStatus(s.strategy_id,'PAUSED')">暂停</el-button>
<el-button v-else-if="s.status==='PAUSED'" size="small" type="success" @click="setStrategyStatus(s.strategy_id,'ACTIVE')">恢复</el-button>
<el-button v-if="s.buy_paused" size="small" type="warning" @click="resumeBuy(s)">恢复买入</el-button>
@ -1259,8 +1259,8 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
</el-alert>
<template #footer>
<el-button v-if="isAdmin" size="small" @click="validateStrategy" :loading="stratDlg.busy">先校验</el-button>
<el-button v-if="isAdmin" size="small" type="primary" @click="attachStrategy" :loading="stratDlg.busy">挂上并生效</el-button>
<el-button size="small" @click="validateStrategy" :loading="stratDlg.busy">先校验</el-button>
<el-button size="small" type="primary" @click="attachStrategy" :loading="stratDlg.busy">挂上并生效</el-button>
</template>
</el-dialog>
@ -1286,7 +1286,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<el-tabs v-model="tab" type="border-card">
<!-- ==================== ① 参数设置 ==================== -->
<el-tab-pane label="参数设置" name="params" v-if="isAdmin">
<el-tab-pane label="参数设置" name="params">
<div class="row" style="margin-bottom:10px">
<span class="muted">页面改动持久化到 pms_runtime_param, 优先于 settings.py 初值; 基础设施连接串只在 .env 维护。</span>
<div class="spacer"></div>
@ -1421,9 +1421,9 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
@click="cancelCmd(s.row.command_id)">撤销</el-button>
<el-button size="small" :disabled="!canCancel(s.row) || s.row.cmd_class!=='task'"
@click="replan(s.row.command_id)">重规划</el-button>
<el-button v-if="isAdmin && cmdArchivable(s.row) && !s.row.archived_at" size="small"
<el-button v-if="cmdArchivable(s.row) && !s.row.archived_at" size="small"
@click="archiveRow('commands', s.row.command_id, true)">移除</el-button>
<el-button v-if="isAdmin && s.row.archived_at" size="small" type="info"
<el-button v-if="s.row.archived_at" size="small" type="info"
@click="archiveRow('commands', s.row.command_id, false)">恢复显示</el-button>
</template>
</el-table-column>
@ -1547,7 +1547,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<el-table-column prop="origin_type" label="来源" width="80"></el-table-column>
<el-table-column label="操作" width="90">
<template #default="s">
<el-button v-if="isAdmin" size="small" @click="cancelIns(s.row.instruction_id)">撤销</el-button>
<el-button size="small" @click="cancelIns(s.row.instruction_id)">撤销</el-button>
</template>
</el-table-column>
</el-table>
@ -1617,7 +1617,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
scripts/ws_smoke.py place --yes —— 那条路径有显式确认、有参数校验、
有"这张单会真成交"的警告, 且不受 PMS_DISPATCH_MODE 影响。页面上加个
发单按钮等于在生产界面开了第二条绕过开关的下单入口, 不值当。 -->
<el-tab-pane label="ws 通道" name="ws" v-if="isAdmin">
<el-tab-pane label="ws 通道" name="ws">
<div class="row" style="margin-bottom:10px">
<el-tag :type="wsc.process_alive ? 'success':'danger'" effect="dark" size="small">
进程 {{ wsc.process_alive ? '在线':'不在线' }}</el-tag>

View File

@ -1,15 +1,15 @@
# -*- coding: utf-8 -*-
"""
第十八批: 登录与权限 (交易系统管理员 / 交易员) 的纯逻辑 不联网不连库
第十八批: 登录与权限 (系统管理员 / 交易员) 的纯逻辑 不联网不连库
=====================================================================
本批只测 app/web/auth.py 里的纯函数, 不导入 app.web.main (那会拉起 fastapi 与一堆
基础设施依赖)覆盖四件事:
1. 角色判定 role_from_permissions: 管理员标记命中/未中交易员准入闸放行与拦截;
2. 会话票 make_session / verify_session: 签发-校验往返, 篡改/过期/换密钥/空密钥一律拒;
3. bshop 返回解析 parse_bshop_login: 喂真实样例抽出手机号/用户名/权限/组织, code!=200 判失败;
4. 接口鉴权 authz_decision + is_public_path: 写操作默认拒绝交易员白名单外的写接口
一律要管理员 (专门钉死 /api/macro/adoptPOST /api/proposals.../archive .../cancel
结尾段区分), GET 默认放行仅少数运维读接口要管理员; 公开白名单放行首页与登录接口
4. 接口鉴权 authz_decision + is_public_path: 分工按前端两块视图划分 交易员工作台上能点到
的都归交易员 (下单/撤单/拍板/策略/参数微调/大盘冷热采纳), 只有运维视图里的系统维护
操作 (ops/* ws-channelindustry 导入人工补录提议) 管理员; 公开白名单放行首页与登录接口
运行: python scripts/test_batch18_units.py
"""
import os
@ -50,25 +50,25 @@ SAMPLE = {
# ---------------------------------------------------------------- 一、角色判定
@case("角色: 命中管理员标记 -> 交易系统管理员")
@case("角色: 命中管理员标记 => 管理员")
def _role_admin():
r = A.role_from_permissions(["band:bkld", "pms:admin"], "pms:admin", "")
assert r == A.ROLE_ADMIN, r
@case("角色: 只有普通权限 -> 交易员")
@case("角色: 只有普通权限 => 交易员")
def _role_trader():
r = A.role_from_permissions(["band:bkld", "band:yhyqx"], "pms:admin", "")
assert r == A.ROLE_TRADER, r
@case("角色: 配了交易员准入且占其一 -> 交易员")
@case("角色: 配了交易员准入且占其一 => 交易员")
def _role_trader_gate_pass():
r = A.role_from_permissions(["band:ccgl"], "pms:admin", "band:ccgl,pms:-")
assert r == A.ROLE_TRADER, r
@case("角色: 配了交易员准入却一条不占 -> None (无权进入)")
@case("角色: 配了交易员准入却一条不占 => None (无权进入)")
def _role_denied():
r = A.role_from_permissions(["band:bkld"], "pms:admin", "band:ccgl,pms:-")
assert r is None, r
@ -89,7 +89,7 @@ def _sess_roundtrip():
assert p["role"] == A.ROLE_ADMIN and p["exp"] == 1000 + 12 * 3600, p
@case("会话: 篡改正文或签名 -> None")
@case("会话: 篡改正文或签名 => None")
def _sess_tampered():
tok = A.make_session("186", "u", A.ROLE_TRADER, "s3cr3t", 12, now=1000.0)
b, sig = tok.split(".", 1)
@ -107,7 +107,7 @@ def _sess_reject():
# ---------------------------------------------------------------- 三、bshop 解析
@case("解析: 真实样例 -> 手机号/用户名/权限/组织")
@case("解析: 真实样例 => 手机号/用户名/权限/组织")
def _parse_ok():
r = A.parse_bshop_login(SAMPLE)
assert r["ok"] is True, r
@ -124,7 +124,7 @@ def _parse_then_role():
assert A.role_from_permissions(r["permissions"], "band:yhyqx", "") == A.ROLE_ADMIN
@case("解析: code!=200 / 缺手机号 / 非字典 -> 失败")
@case("解析: code!=200 / 缺手机号 / 非字典 => 失败")
def _parse_fail():
assert A.parse_bshop_login({"code": 401, "msg": "密码错误"})["ok"] is False
assert A.parse_bshop_login({"code": 200, "data": {"user": {}}})["ok"] is False
@ -132,35 +132,44 @@ def _parse_fail():
# ---------------------------------------------------------------- 四、接口鉴权
@case("鉴权: 交易员白名单 4 条写接口放行 (any)")
@case("鉴权: 交易员工作台上的写操作都放行 (any)")
def _authz_trader_ok():
for m, path in [("POST", "/api/commands"),
("POST", "/api/commands/CMD-1/cancel"),
("POST", "/api/commands/CMD-1/replan"),
("POST", "/api/proposals/PRP-1/decide")]:
trader_paths = [
("POST", "/api/commands"),
("POST", "/api/commands/CMD-1/cancel"),
("POST", "/api/commands/CMD-1/replan"),
("POST", "/api/commands/CMD-1/archive"),
("POST", "/api/proposals/PRP-1/decide"),
("POST", "/api/proposals/PRP-1/archive"),
("POST", "/api/instructions/INS-1/cancel"), # 用户点名: 撤单归交易员
("POST", "/api/macro/adopt"), # 用户点名: 大盘冷热采纳归交易员
("POST", "/api/params"), # 工作台上有参数微调面板
("POST", "/api/strategies"),
("POST", "/api/strategies/validate"),
("POST", "/api/strategies/S1/status"),
("POST", "/api/strategies/S1/resume-buy"),
("POST", "/api/strategies/S1/archive"),
]
for m, path in trader_paths:
assert A.authz_decision(m, path) == "any", (m, path, A.authz_decision(m, path))
@case("鉴权: 写操作默认拒绝 —— 白名单外一律要管理员")
def _authz_admin_default():
@case("鉴权: 只有系统维护类接口归管理员")
def _authz_admin_only():
admin_paths = [
("POST", "/api/params"),
("POST", "/api/ops/reconcile"),
("POST", "/api/strategies"),
("POST", "/api/strategies/S1/status"),
("POST", "/api/industry/import"),
("POST", "/api/ops/daily-settle"),
("POST", "/api/ops/macro-scan"),
("POST", "/api/ops/plan-refresh"),
("POST", "/api/ws-channel/clear-resync"),
("POST", "/api/instructions/INS-1/cancel"),
("POST", "/api/macro/adopt"), # 评审点出的漏网写接口, 兜住
("POST", "/api/proposals"), # 人工补录=造数调试口, 归管理员
("POST", "/api/commands/CMD-1/archive"), # 结尾段 archive != cancel
("POST", "/api/proposals/PRP-1/archive"),
("POST", "/api/industry/import"),
("POST", "/api/proposals"), # 人工补录 (无 id 段) = 造数调试口
]
for m, path in admin_paths:
assert A.authz_decision(m, path) == "admin", (m, path, A.authz_decision(m, path))
@case("鉴权: GET 默认放行, 仅三条运维读接口要管理员")
@case("鉴权: 运维读接口要管理员, 普通读接口谁都能看")
def _authz_gets():
for path in ["/api/ops/rebuild-preflight", "/api/ops/rebuild-accept",
"/api/ops/downstream-schema"]: