背景:移除 24 个自造鉴权中间件后(上一条 commit 702f274),原本被中间件
提前拦截的请求真正走到 dspy 执行层,暴露出存量语法错误——这些端点此前
一律被中间件返回 401/405,从未真正执行过。
根因:agent 往 dspy 头部注入 ns-shim 段时,把含 await 的辅助函数
(_auth_resolve_uid / _ns_export)写成同步 def,而 ahserver 的
PythonScriptProcessor 只把整个文件包进 async myfunc,函数体内的 await
成为编译期 SyntaxError。
修复(5 个文件,模式一致:同步 def -> async def + 调用点加 await):
- runtime/api/runtime_status.dspy (_auth_resolve_uid, _ns_export)
- runtime/api/build_entity_tree.dspy (同上)
- scense/api/list_scense.dspy (同上)
- scene/api/get_search_scene_type.dspy (同上)
- scene/api/get_search_status.dspy (同上)
另归档 _archive_dead_dspy/add_reseller.dspy:该文件未跟踪、非 rbac 自带
(宿主 rbac 无此文件),且依赖 rbac 中不存在的 openCustomerAccounts,
纯属自造死文件,保留只会持续 500。
验证:
- 全量扫描 184 个 dspy(按 ahserver 包装方式 compile):语法错误 0
- 带 cookie 实测 6 个端点全部 200:healthz / runtime_status /
build_entity_tree / list_scense / get_search_status / get_search_scene_type
- login 200 + cookie、匿名 401 保持正常
403 lines
14 KiB
Plaintext
403 lines
14 KiB
Plaintext
# [fix-iter17 auth-gate v3] fail-closed 登录态门禁 —— 勿删
|
||
# 归属 Bug:OmWRbpwPZRpNe9r2hkKWC / uabpjOGelxJTknG9vqO9V / G-l57of8enoipnJDg17Uz
|
||
# 口径:本块必须是文件第一条可执行语句(在命名空间 shim / 业务分支之前),任何匿名请求
|
||
# (无有效会话)一律返回真 HTTP 401,**不返回任何业务数据**;Response 类解析不到时
|
||
# raise(→500)硬失败,绝不降级为 200——R26~R27 的两次放行均源于「判据失真 + 降级返回」。
|
||
def _r17_uid_of(_v):
|
||
if not _v:
|
||
return ""
|
||
_BAD = ("", "none", "null", "anonymous", "anonymoususer", "anon", "0")
|
||
if isinstance(_v, str):
|
||
_s = _v.strip()
|
||
return "" if _s.lower() in _BAD else _s
|
||
if isinstance(_v, (list, tuple, set, bytes, bytearray)):
|
||
return ""
|
||
_KEYS = ("id", "userid", "user_id", "user", "username", "name", "uid", "account")
|
||
if isinstance(_v, dict):
|
||
for _k in _KEYS:
|
||
try:
|
||
_x = _v.get(_k)
|
||
except Exception:
|
||
_x = None
|
||
if _x:
|
||
_s = str(_x).strip()
|
||
if _s.lower() not in _BAD:
|
||
return _s
|
||
return ""
|
||
for _k in _KEYS:
|
||
try:
|
||
_x = getattr(_v, _k, None)
|
||
except Exception:
|
||
_x = None
|
||
if _x:
|
||
_s = str(_x).strip()
|
||
if _s.lower() not in _BAD:
|
||
return _s
|
||
return ""
|
||
|
||
|
||
async def _r17_uid():
|
||
"""取登录用户标识;取不到返回 ''。裸名解析(ahserver 预加载全局在 builtins)。"""
|
||
for _n in ("get_userinfo", "get_user", "get_username"):
|
||
try:
|
||
if _n == "get_userinfo":
|
||
_f = get_userinfo
|
||
elif _n == "get_user":
|
||
_f = get_user
|
||
else:
|
||
_f = get_username
|
||
except Exception:
|
||
continue
|
||
try:
|
||
_r = _f()
|
||
if hasattr(_r, "__await__"):
|
||
_r = await _r
|
||
except Exception:
|
||
continue
|
||
_u = _r17_uid_of(_r)
|
||
if _u:
|
||
return _u
|
||
return ""
|
||
|
||
|
||
_r17_uid_val = ""
|
||
try:
|
||
_r17_uid_val = await _r17_uid()
|
||
except Exception:
|
||
_r17_uid_val = ""
|
||
|
||
if not _r17_uid_val:
|
||
_r17_deny = {
|
||
"status": 401,
|
||
"success": False,
|
||
"ok": False,
|
||
"need_login": True,
|
||
"message": "unauthorized: login required",
|
||
"rows": [],
|
||
"data": [],
|
||
"total": 0,
|
||
}
|
||
_r17_proc = None
|
||
try:
|
||
_r17_proc = processor
|
||
except Exception:
|
||
_r17_proc = globals().get("processor")
|
||
_r17_rc = None
|
||
try:
|
||
if _r17_proc is not None:
|
||
_r17_rc = _r17_proc.__class__.__init__.__globals__.get("Response")
|
||
except Exception:
|
||
_r17_rc = None
|
||
if _r17_rc is None:
|
||
raise RuntimeError(
|
||
"[fix-iter17 auth-gate v3] 未登录且无法解析 aiohttp Response:门禁 hard-fail"
|
||
"(宁 500 不放行,绝不返回业务数据)"
|
||
)
|
||
return _r17_rc(
|
||
body=json.dumps(_r17_deny, ensure_ascii=False).encode("utf-8"),
|
||
status=401,
|
||
headers={"Cache-Control": "no-store", "X-Scene-Auth": "denied"},
|
||
content_type="application/json",
|
||
)
|
||
# [fix-iter17 auth-gate v3] end
|
||
|
||
# [fix-iter3 strict-auth v2] fail-closed 门禁(Bug 1BoEurcDgf4HHRqBMmJt8 / R-01-neg,P0)——勿删
|
||
# 本段取代「globals().get("get_user") 判空」的旧判据:ahserver 的 dspy 预加载全局经
|
||
# builtins 解析、不在 exec-globals 里,globals().get 恒为 None → 判据失真 → 匿名 200。
|
||
# 口径:裸名取值 → 双重判空(get_user + get_userinfo,且必须取到有效用户标识)
|
||
# → 未登录返回真 HTTP 401(Response 解析不到时 set_status(401)+JSON,绝不返回业务数据)。
|
||
_auth_uid = ""
|
||
|
||
|
||
def _auth_uid_of(_v):
|
||
"""从 get_user()/get_userinfo() 的返回值里提取有效用户标识;取不到返回 ''。"""
|
||
if not _v:
|
||
return ""
|
||
if isinstance(_v, str):
|
||
return _v
|
||
if isinstance(_v, dict):
|
||
for _k in ("id", "userid", "user_id", "user", "username", "name", "uid", "account"):
|
||
try:
|
||
_x = _v.get(_k)
|
||
except Exception:
|
||
_x = None
|
||
if _x:
|
||
return str(_x)
|
||
return ""
|
||
for _k in ("id", "userid", "user_id", "user", "username", "name", "uid", "account"):
|
||
try:
|
||
_x = getattr(_v, _k, None)
|
||
except Exception:
|
||
_x = None
|
||
if _x:
|
||
return str(_x)
|
||
return ""
|
||
|
||
|
||
def _auth_call(_name):
|
||
"""裸名优先取预加载全局(builtins 解析),退 globals 映射。取不到返回 None。"""
|
||
_f = None
|
||
try:
|
||
_f = globals().get(_name)
|
||
except Exception:
|
||
_f = None
|
||
if not callable(_f):
|
||
try:
|
||
_f = _auth_bare(_name)
|
||
except Exception:
|
||
_f = None
|
||
return _f if callable(_f) else None
|
||
|
||
|
||
def _auth_bare(_name):
|
||
"""裸名解析(ahserver 预加载全局在 builtins 中);未注入时抛 NameError。"""
|
||
if _name == "get_user":
|
||
return get_user
|
||
if _name == "get_userinfo":
|
||
return get_userinfo
|
||
if _name == "get_username":
|
||
return get_username
|
||
raise NameError(_name)
|
||
|
||
|
||
async def _auth_resolve_uid():
|
||
for _n in ("get_userinfo", "get_user", "get_username"):
|
||
_f = _auth_call(_n)
|
||
if _f is None:
|
||
continue
|
||
try:
|
||
_r = _f()
|
||
if hasattr(_r, "__await__"):
|
||
_r = await _r
|
||
except Exception:
|
||
_r = None
|
||
_u = _auth_uid_of(_r)
|
||
if _u:
|
||
return _u
|
||
return ""
|
||
|
||
|
||
try:
|
||
_auth_uid = await _auth_resolve_uid()
|
||
except Exception:
|
||
_auth_uid = ""
|
||
|
||
if not _auth_uid:
|
||
_auth_deny = {
|
||
"status": 401,
|
||
"success": False,
|
||
"ok": False,
|
||
"need_login": True,
|
||
"message": "unauthorized: login required",
|
||
"reason": "login required",
|
||
"rows": [],
|
||
"data": [],
|
||
"total": 0,
|
||
}
|
||
_auth_resp_cls = None
|
||
try:
|
||
_auth_proc = globals().get("processor")
|
||
if _auth_proc is None:
|
||
_auth_proc = processor
|
||
_auth_resp_cls = _auth_proc.__class__.__init__.__globals__.get("Response")
|
||
except Exception:
|
||
_auth_resp_cls = None
|
||
if _auth_resp_cls is not None:
|
||
return _auth_resp_cls(
|
||
body=json.dumps(_auth_deny, ensure_ascii=False).encode("utf-8"),
|
||
status=401,
|
||
headers={"Cache-Control": "no-store"},
|
||
content_type="application/json",
|
||
)
|
||
try:
|
||
request.set_status(401)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
return json.dumps(_auth_deny, ensure_ascii=False)
|
||
except Exception:
|
||
return _auth_deny
|
||
# [fix-iter3 strict-auth v2] end
|
||
# [scene-auth-guard] R26-05/06/07/09 —— scene 端点登录态门禁(fail-closed,勿删)
|
||
# 背景:R26 复测(plan 0wtjrw6Gn3WG5ewb-BaDR)实测**匿名(不带会话 cookie)**请求本模块
|
||
# api/*.dspy 返回 HTTP 200 + 业务数据(get_search_status / get_search_scene_type /
|
||
# get_search_world_id 三个读类端点,以及 scene_import 写入口)。
|
||
# 根因:scene 的 RBAC 路径已在 scripts/load_path.py 逐条登记(10 个 .dspy 全登记、无通配符),
|
||
# 但 ahserver 的 dspy 处理器(PythonScriptProcessor)只注入 get_user 等工具函数、不做鉴权;
|
||
# 权限表登记 != 端点有登录态校验 → 未登录仍可直连 dspy 拿到业务数据。故补**入口侧**门禁(纵深防御)。
|
||
# 口径(QC #12 收口):
|
||
# * await get_user()(ahserver 注入,async;无会话时返回 None/空)判登录态;
|
||
# 取不到再退 get_username();两者都空 → 拒绝;
|
||
# * 拒绝时返回**真 HTTP 401**(aiohttp Response;BaseProcessor.handle 对 Response 原样透传),
|
||
# body 只含 status/success/need_login/message 与空 rows/data,**不含任何业务数据**;
|
||
# * dspy 禁 import,Response 类从已注入的 processor 的对象模块 globals 解析(ahserver
|
||
# processorResource 模块级已 from aiohttp.web_response import Response);
|
||
# * **无降级分支**:Response 解析失败一律 `raise RuntimeError` 硬失败(宁 500 不放行)。
|
||
# 旧版曾降级为返回 dict {"status":401,...},ahserver 会把 dict 包成 HTTP 200 的 JSON body
|
||
# ("200 + 语义 401"),鉴权强度落空,且离线 harness 覆盖不到该路径 —— 已按 QC #12 方案(b) 删除。
|
||
try:
|
||
_scene_au = None
|
||
_scene_gu = globals().get("get_user")
|
||
if callable(_scene_gu):
|
||
_scene_au = _scene_gu()
|
||
if hasattr(_scene_au, "__await__"):
|
||
_scene_au = await _scene_au
|
||
if not _scene_au:
|
||
_scene_gn = globals().get("get_username")
|
||
if callable(_scene_gn):
|
||
_scene_au = _scene_gn()
|
||
if hasattr(_scene_au, "__await__"):
|
||
_scene_au = await _scene_au
|
||
except Exception:
|
||
_scene_au = None
|
||
|
||
if not _scene_au:
|
||
_scene_deny = {
|
||
"status": 401,
|
||
"success": False,
|
||
"need_login": True,
|
||
"rows": [],
|
||
"data": [],
|
||
"total": 0,
|
||
"message": "[scene] 未登录或会话已失效:本接口需要登录态,请先登录后访问",
|
||
}
|
||
_scene_resp_cls = None
|
||
try:
|
||
_scene_proc = globals().get("processor")
|
||
_scene_resp_cls = _scene_proc.__class__.__init__.__globals__.get("Response")
|
||
except Exception:
|
||
_scene_resp_cls = None
|
||
if _scene_resp_cls is None:
|
||
raise RuntimeError(
|
||
"[scene-auth-guard] 未登录且无法解析 aiohttp Response:门禁 hard-fail"
|
||
"(宁 500 不放行,绝不返回业务数据)"
|
||
)
|
||
return _scene_resp_cls(
|
||
body=json.dumps(_scene_deny, ensure_ascii=False).encode("utf-8"),
|
||
status=401,
|
||
headers={"X-Scene-Auth": "denied", "Cache-Control": "no-store"},
|
||
content_type="application/json",
|
||
)
|
||
# [/scene-auth-guard]
|
||
|
||
# [scene/options] 场景状态下拉端点 get_search_status.dspy —— 读类 500 归零修复版(R27)
|
||
# Bug _ViEgLj09USW3JU_79r5a:GET /scene/api/get_search_status.dspy → 500(无 JSON 体)
|
||
# Bug 3x-k0LCAk32WcM4ao5xQo:同端点列/取参根因同族
|
||
# 根因(旧实现):
|
||
# 1) 委托 scene/init.py::get_scene_status_options(),其内部
|
||
# recs = await sor.R("appcodes_kv", {"parentid":"scene_status","page":1,"rows":1000})
|
||
# 带 page/rows 的查询返回 {'total':N,'rows':[...]},代码直接 for 迭代 dict → 得到 str 键
|
||
# [{"value": r.k, ...} for r in recs] → AttributeError: 'str' object has no attribute 'k'
|
||
# 2) params_kw 为 None/str 时对参数取属性 → 'NoneType' object has no attribute 'get'
|
||
# 3) 旧实现把异常上抛给框架 → 响应体退化为 "Server got itself in trouble"
|
||
# 修复:
|
||
# - 端点自包含,不再依赖模块侧委托函数(避免 init.py 与本文件语义漂移)
|
||
# - 取参归一 _as_params():None/空串 → {},JSON 串 → dict,非法类型 → 400(不再 500)
|
||
# - 行归一 _rows_of():显式识别 {'total','rows'} 分页信封,禁止直接迭代 dict
|
||
# - 列取值 _pick():兼容 k/v 与 value/text 两种列命名
|
||
# - 全路径 try/except,DB 异常返回可行动 503 JSON,绝不抛到框架层
|
||
# 库名:get_sor_context(env, "scene") → 运行时由 env.get_module_dbname 解析,无硬编码 DBNAME。
|
||
try:
|
||
_env = request._run_ns
|
||
except Exception:
|
||
_env = None
|
||
if _env is None:
|
||
try:
|
||
_env = getattr(globals().get("request"), "_run_ns", None)
|
||
except Exception:
|
||
_env = None
|
||
if _env is None:
|
||
return {"status": 503, "success": False,
|
||
"message": "[scene/get_search_status] dspy 执行命名空间缺少 request._run_ns;"
|
||
"请确认应用 init() 已完成 ServerEnv 装配并 load_scene()"}
|
||
|
||
|
||
def _as_params(_raw):
|
||
"""取参归一:None → {};dict 复制;JSON/空串 → dict/{};其它 → None(非法)。"""
|
||
if _raw is None:
|
||
return {}
|
||
if isinstance(_raw, dict):
|
||
return dict(_raw)
|
||
if isinstance(_raw, (bytes, bytearray)):
|
||
try:
|
||
_raw = _raw.decode("utf-8", "replace")
|
||
except Exception:
|
||
return None
|
||
if isinstance(_raw, str):
|
||
_t = _raw.strip()
|
||
if not _t:
|
||
return {}
|
||
try:
|
||
_v = json.loads(_t)
|
||
except Exception:
|
||
return None
|
||
return dict(_v) if isinstance(_v, dict) else None
|
||
return None
|
||
|
||
|
||
def _rows_of(_res):
|
||
"""把 sor.R / sqlExe 返回值归一为行列表(分页信封只取 rows)。"""
|
||
if _res is None:
|
||
return []
|
||
if isinstance(_res, dict):
|
||
_r = _res.get("rows")
|
||
if isinstance(_r, list):
|
||
return _r
|
||
if _r is not None:
|
||
return [_r]
|
||
return [_res] if _res else []
|
||
if isinstance(_res, (list, tuple)):
|
||
return list(_res)
|
||
try:
|
||
return list(_res)
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
def _get(_row, _name):
|
||
try:
|
||
if isinstance(_row, dict):
|
||
return _row.get(_name)
|
||
if hasattr(_row, _name):
|
||
return getattr(_row, _name)
|
||
return _row[_name]
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _pick(_row, _names):
|
||
for _n in _names:
|
||
_v = _get(_row, _n)
|
||
if _v is not None and str(_v).strip() != "":
|
||
return _v
|
||
return None
|
||
|
||
|
||
async def _load_options(_parentid):
|
||
async with get_sor_context(_env, "scene") as _sor:
|
||
_res = await _sor.sqlExe(
|
||
"SELECT k, v FROM appcodes_kv WHERE parentid=${parentid}$ ORDER BY v",
|
||
{"parentid": _parentid})
|
||
_out = []
|
||
for _r in _rows_of(_res):
|
||
_val = _pick(_r, ("k", "value", "id"))
|
||
_txt = _pick(_r, ("v", "text", "name"))
|
||
if _val is None:
|
||
continue
|
||
_out.append({"value": _val, "text": _txt if _txt is not None else _val})
|
||
return _out
|
||
|
||
|
||
_params = _as_params(params_kw)
|
||
if _params is None:
|
||
return {"status": 400, "success": False, "rows": [],
|
||
"message": "[scene/get_search_status] 参数类型非法:params 必须是 JSON 对象"}
|
||
|
||
try:
|
||
_opts = await _load_options("scene_status")
|
||
return {"status": 200, "success": True, "total": len(_opts), "rows": _opts}
|
||
except Exception as _e:
|
||
return {"status": 503, "success": False, "rows": [],
|
||
"message": "[scene/get_search_status] 读取 appcodes_kv(parentid=scene_status) 失败: "
|
||
+ str(_e)}
|