chore: 风控Agent模块自治边界标注 + AL-09 合并预处置
背景:远端 main 新提交 3995cb4(09-07 17:20,交接文档漏记)为 Wave 0 鉴权/ chat/防护平行实现,与已完工 T-01/T-02/T-03/T-06 同名不同路径;试合并实测 20 文件冲突(原记 9 个),另有 13 个 main 新增文件不报冲突会静默并入。 拍板:风控 Agent 按独立封装模块自治,与宿主耦合收敛到 4 个接缝。 1. 《风控Agent模块边界与合并接缝标注》入库存档:A~D 四类文件归属表; 4 接缝(S1 挂载点 main.py / S2 AuthContext / S3 settings / S4 引擎工厂); 20 冲突文件逐个裁决(core_ro、model/suitability、conftest、02-seed-base 以模块版为准;chat/main/settings/agent_service 等公共层以 main 为主); 三处硬伤处置:issuer 不一致改为适配器映射不统一、STAFF-90001 必保、 main infer_roles 未知 actor 默认 analyst(fail-open)记宿主侧 P1。 2. app/api/auth_adapter.py:S2 接缝适配器预制件(当前未接线,AL-09 接入)。 鸭子类型读宿主 ctx 故不依赖宿主文件;sub→actor_id、trace_id→contextvar、 perm_matches 兼容宿主 `前缀:*` 通配;缺主体即 HostAuthAdapterError, fail-closed 不静默降级。 3. tests/test_module_boundary.py:边界防呆 4 类断言——模块私有文件存在、 禁止跨层 import 宿主私有实现(gateway.*/config.database/middleware.*/ utils.input_guard/model.schemas)、AuthContext 契约完整(actor_id 与 has_role 多参)、settings 私有字段与 AGENT_TYPES 四值不漂移。 4. tests/test_auth_adapter.py:适配器 11 例(映射/回退/fail-closed/trace 绑定/通配)。 基线:406 → 436 全绿(演示库已按演练 SOP §2 重灌:AML 8 条 / 演示 7 行 / sync 33 rows)。
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
"""宿主 AuthContext → 模块 AuthContext 适配器(S2 接缝,AL-09 接线用)。
|
||||
|
||||
状态:**预制件,当前未接线**。模块内部继续直接使用 `app.api.deps.get_auth_context`。
|
||||
AL-09 合并 main 后,在模块 API 入口把宿主的 `AuthContext` 经 `from_host_auth` 转换即可,
|
||||
模块其余代码零改动。
|
||||
|
||||
设计取舍:
|
||||
- **不 import 宿主模块**(main 的 `app/model/schemas.py` 等),改用鸭子类型读取字段,
|
||||
保证本文件在当前分支(宿主文件尚不存在)也能被导入与单测;
|
||||
- 只做字段映射与语义补齐,不做任何权限判定(判定仍在 `deps.py` 的矩阵内);
|
||||
- 权限原样透传,另提供 `perm_matches` 兼容宿主的 `前缀:*` 通配语义,
|
||||
避免模块侧 `has_permission` 精确匹配漏判通配权限。
|
||||
|
||||
字段映射(宿主 → 模块):
|
||||
sub → actor_id (字段名差异,模块全量代码依赖 actor_id)
|
||||
trace_id → contextvar 绑定 (模块不存字段,走 utils.trace.current_trace)
|
||||
agent_type → 不入模,仅返回 (模块按 agent_type 单独传参做准入判定)
|
||||
roles/permissions/tenant_id/jti/customer_id → 原样透传
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.api.deps import AuthContext
|
||||
from app.utils.trace import set_trace
|
||||
|
||||
__all__ = ["from_host_auth", "perm_matches", "HostAuthAdapterError"]
|
||||
|
||||
|
||||
class HostAuthAdapterError(ValueError):
|
||||
"""宿主上下文字段缺失且无法安全兜底时抛出(fail-closed,不静默降级)。"""
|
||||
|
||||
|
||||
def perm_matches(perm: str, permissions: list[str] | tuple[str, ...]) -> bool:
|
||||
"""权限匹配:精确命中,或命中宿主的 `前缀:*` 通配写法。
|
||||
|
||||
例:`risk:alert:read` 在权限集含 `risk:*` 时返回 True。
|
||||
"""
|
||||
if not perm:
|
||||
return False
|
||||
if perm in permissions:
|
||||
return True
|
||||
prefix = perm.split(":", 1)[0]
|
||||
return any(p == f"{prefix}:*" or p.endswith(":*") and p.startswith(f"{prefix}:") for p in permissions)
|
||||
|
||||
|
||||
def from_host_auth(
|
||||
host_ctx: Any,
|
||||
*,
|
||||
agent_type: str | None = None,
|
||||
bind_trace: bool = True,
|
||||
) -> AuthContext:
|
||||
"""把宿主 AuthContext 转成模块 AuthContext。
|
||||
|
||||
Args:
|
||||
host_ctx: 宿主的 `AuthContext`(含 sub/roles/token_type/... 的任意对象)。
|
||||
agent_type: 显式指定请求目标 agent;缺省时取宿主的 `agent_type` 字段。
|
||||
bind_trace: 是否把宿主的 `trace_id` 绑定到模块 trace contextvar。
|
||||
|
||||
Raises:
|
||||
HostAuthAdapterError: 连主体标识(sub/actor_id)都取不到时抛出。
|
||||
"""
|
||||
actor_id = getattr(host_ctx, "sub", None) or getattr(host_ctx, "actor_id", None)
|
||||
if not actor_id:
|
||||
# fail-closed:拿不到主体就拒绝,不匿名放行
|
||||
raise HostAuthAdapterError("宿主 AuthContext 缺少主体标识(sub/actor_id),拒绝放行")
|
||||
|
||||
roles = list(getattr(host_ctx, "roles", None) or [])
|
||||
permissions = list(getattr(host_ctx, "permissions", None) or [])
|
||||
token_type = getattr(host_ctx, "token_type", None) or "staff"
|
||||
customer_id = getattr(host_ctx, "customer_id", None)
|
||||
|
||||
# customer 类 token 若未显式带 customer_id,回退为主体自身
|
||||
if customer_id is None and token_type == "customer":
|
||||
customer_id = actor_id
|
||||
|
||||
if bind_trace:
|
||||
trace_id = getattr(host_ctx, "trace_id", None)
|
||||
if trace_id:
|
||||
set_trace(str(trace_id))
|
||||
|
||||
return AuthContext(
|
||||
actor_id=str(actor_id),
|
||||
roles=roles,
|
||||
customer_id=customer_id,
|
||||
token_type=str(token_type),
|
||||
permissions=permissions,
|
||||
tenant_id=getattr(host_ctx, "tenant_id", None),
|
||||
jti=getattr(host_ctx, "jti", None),
|
||||
)
|
||||
|
||||
|
||||
def resolve_agent_type(host_ctx: Any, default: str = "risk") -> str:
|
||||
"""取请求目标 agent 类型;宿主无该字段时回退 default。"""
|
||||
value = getattr(host_ctx, "agent_type", None)
|
||||
return str(value) if value else default
|
||||
@@ -0,0 +1,197 @@
|
||||
# 风控 Agent 模块边界与合并接缝标注
|
||||
|
||||
> 版本:v1.0 · 2026-09-07 18:20
|
||||
> 适用分支:`risk-control-agent`(当前唯一开发分支)
|
||||
> 定位:**给 AL-09 合并 main 用的裁决依据**,也供日常改码判断"这个文件动了会不会影响宿主"。
|
||||
> 关联:《修改报告-对齐main基准.md》(阶段一执行依据)、《实现方案-风控追加需求v1.1-C4C6.md》(阶段二编码依据)
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话结论
|
||||
|
||||
**风控 Agent 按"独立封装模块"自治:模块内部(含自有鉴权)自己说了算,与宿主的耦合收敛到 4 个接缝;合并 main 时不逐文件融合,只在接缝处接线。**
|
||||
|
||||
采用本方案后,main 侧 `gateway/*`、`middleware/trace.py`、`utils/input_guard.py` 等文件与模块私有实现**允许并存**——它们不是"重复实现",而是"宿主层"与"模块层"各自的实现,只要接缝不串、模块不被宿主覆盖即可。
|
||||
|
||||
**前提(三条,缺一不可)**:
|
||||
1. 模块私有文件不被宿主同名文件覆盖(合并时以模块版本为准);
|
||||
2. 模块对宿主的依赖只走 §2 的 4 个接缝,不得直接 import 宿主私有实现;
|
||||
3. 接缝由 `tests/test_module_boundary.py` 锁死,宿主一改破坏契约即红灯。
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块边界(文件归属表)
|
||||
|
||||
### 1.1 A 类 · 模块私有(风控业务,合并时全量保留,不与 main 融合)
|
||||
|
||||
| 路径 | 说明 |
|
||||
| --- | --- |
|
||||
| `app/service/risk/` | 风控引擎、规则、预警服务、对话 Tool、L3 画像、AML(阶段 A/B/C 全部成果) |
|
||||
| `app/service/suitability.py` | 适当性服务(AL-05 换核后为 main 契约 + 我方兼容层) |
|
||||
| `app/service/tool_service.py` | Tool 编排与文案汇总 |
|
||||
| `app/repository/risk_repository.py` | 风控读写仓储 |
|
||||
| `app/repository/core_ro.py` | Core 只读(AL-03 已与 main 融合,归模块私有) |
|
||||
| `app/repository/session_repository.py` | 会话仓储(模块自用) |
|
||||
| `app/api/risk.py`、`app/api/simulate.py` | 风控 4 API + 交易网关模拟入口 |
|
||||
| `app/gateway/trade_gateway.py` | 交易网关(C6 需透传 actor_id) |
|
||||
| `app/model/suitability.py` | 适当性落库行构造(AL-04 引入,与 main 同名 → **冲突时以模块版为准**) |
|
||||
| `scripts/demo/*`、`scripts/core/*` | 演示与 Core 种子脚本 |
|
||||
| `tests/`(除 test_module_boundary 外) | 模块测试,406 基线 |
|
||||
|
||||
### 1.2 B 类 · 模块私有基建(与宿主同类但模块内自用,**允许与 main 并存**)
|
||||
|
||||
| 模块侧(我方) | 宿主侧(main) | 并存是否安全 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `app/service/auth_service.py` + `app/api/deps.py` | `app/gateway/{jwt_service,auth_deps,rbac,ownership}.py` | ✅ 安全 | 模块内 API 一律 `Depends(get_auth_context)` 走 deps.py,不碰 gateway |
|
||||
| `app/utils/trace.py` + `app/api/audit_middleware.py` | `app/middleware/trace.py` | ✅ 安全 | 模块用 contextvar 取 trace;宿主用 TraceMiddleware,互不读写 |
|
||||
| `app/service/input_guard.py` | `app/utils/input_guard.py` | ✅ 安全 | 模块版含 42 词表 + oversize 4000 + Redis 限流 + 留痕;宿主版仅 SQL 注入校验 |
|
||||
| `app/utils/db.py`(`get_engine(db)`) | `app/config/database.py`(`get_agent_engine/get_core_engine`) | ⚠️ 有条件 | 二者都是双库,功能等价;但**连接池分裂**——模块 Side 不得 import `config.database`,见 §2-S4 |
|
||||
|
||||
> ⚠️ 唯一实质风险:B 类第 4 行。其余三行只要"各用各的"就不冲突。
|
||||
|
||||
### 1.3 C 类 · 公共层/接缝(合并时以 main 为主,模块做适配)
|
||||
|
||||
| 接缝 | 文件 | 处置 |
|
||||
| --- | --- | --- |
|
||||
| S1 | `app/main.py` | 以 main 骨架为主,补挂模块 router 与中间件(见 §2-S1) |
|
||||
| S2 | `app/config/settings.py` | 以 main 为主 + **必须保留模块私有字段**(见 §2-S3) |
|
||||
| S3 | `app/utils/exceptions.py`、`app/utils/response.py` | 以 main 为主,补齐模块用到的错误码与响应字段 |
|
||||
| S4 | `app/service/agent_service.py`、`app/service/memory_service.py` | 以 main 为主(chat 主链路),模块 Tool 通过注册表挂载 |
|
||||
|
||||
### 1.4 D 类 · main 独有新增(合并时静默并入,模块不依赖)
|
||||
|
||||
`app/api/auth.py`、`app/gateway/{auth_deps,jwt_service,ownership,rbac}.py`、`app/middleware/{__init__,trace}.py`、
|
||||
`app/repository/{advisor_rel,agent,audit}_repository.py`、`app/utils/input_guard.py`、`app/model/schemas.py`、
|
||||
`app/config/database.py`、`tests/test_wave0_*.py`
|
||||
|
||||
处置:**全部接纳,不删除**(它们是宿主 Wave 0 的正当实现),但模块代码**不得 import** 其中任何一个——由 §3 防呆测试锁死。
|
||||
|
||||
---
|
||||
|
||||
## 2. 四个接缝(合并时唯一需要人工接线的地方)
|
||||
|
||||
### S1 · 挂载点(`app/main.py`)
|
||||
|
||||
我方现状:挂 `risk` / `simulate` / `chat` 三个 router;两层 `@app.middleware("http")`(audit + trace)。
|
||||
main 现状:`add_middleware(TraceMiddleware)` + 挂 `auth` / `chat`。
|
||||
|
||||
合并口径:
|
||||
```python
|
||||
# 宿主部分保留 main 写法
|
||||
app.add_middleware(TraceMiddleware)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(chat_router)
|
||||
# 模块部分:追加挂载,中间件改为模块内注册(见下)
|
||||
app.include_router(risk_router)
|
||||
app.include_router(simulate_router)
|
||||
setup_risk_module(app) # 待 AL-09 实现:注册 audit + trace 两层 http 中间件
|
||||
```
|
||||
**注意**:main 用 `add_middleware`,我方用 `@app.middleware("http")`——两种注册方式可共存,但中间件执行顺序需实测确认(trace 必须在 audit 内层,保证 audit 能读到 trace_id)。这列为 AL-09 必测项。
|
||||
|
||||
### S2 · AuthContext 契约(**最关键的接缝**)
|
||||
|
||||
| 字段/方法 | 模块侧(deps.AuthContext) | 宿主侧(schemas.AuthContext) | 差异后果 |
|
||||
| --- | --- | --- | --- |
|
||||
| 主体标识 | `actor_id` | `sub` | 字段名不同,全量 `auth.actor_id` 引用会 AttributeError |
|
||||
| trace | 无(走 contextvar `current_trace`) | `trace_id`(必填) | 模块需从 contextvar 或适配器补齐 |
|
||||
| agent_type | 无(单独传参) | `agent_type`(必填,4 值) | 取值集合**双方一致**:`customer/advisor/analyst/risk` ✅ |
|
||||
| `has_role` | `has_role(*roles)` 多参 | `has_role(role)` 单参 | 多参调用会 TypeError |
|
||||
| 权限判断 | `has_permission(perm)` | `has_perm(perm)`(支持 `xx:*` 通配) | 方法名不同 |
|
||||
| 其余 | `roles/customer_id/token_type/permissions/tenant_id/jti` | 同左(tenant_id 默认 `default`,jti 非空) | 基本对齐 |
|
||||
|
||||
**处置:写适配器,不改模块内部。** 已预制 `app/api/auth_adapter.py`(当前未接线,AL-09 时接入):
|
||||
```python
|
||||
from app.api.auth_adapter import from_host_auth
|
||||
# 宿主 AuthContext(sub/trace_id/agent_type) → 模块 AuthContext(actor_id/...)
|
||||
ctx = from_host_auth(host_ctx)
|
||||
```
|
||||
适配器保证 `sub→actor_id`、`trace_id→contextvar`、`has_role` 多参语义、权限前缀通配到 `permissions` 的展开。
|
||||
|
||||
### S3 · settings 字段
|
||||
|
||||
- **main 新增需吸收**:`jwt_dev_algorithm`(HS256)、`jwt_dev_expire_hours`(8)。`jwt_dev_secret` 双方同名同值,无冲突。
|
||||
- **模块私有,合并时一个都不能丢**:
|
||||
- 双库:`mysql_core_database`
|
||||
- 中间件:`redis_url`
|
||||
- 知识库:`milvus_uri`、`ollama_base_url`、`embed_model`、`embed_dim`、`embed_timeout_seconds`
|
||||
- LLM:`deepseek_api_key`、`deepseek_base_url`
|
||||
- 风控阈值 11 项:`risk_large_amount`、`risk_daily_total`、`risk_freq_count`、`risk_probe_window_minutes`、`risk_probe_count`、`risk_probe_amount`、`risk_small_amount`、`risk_small_count`、`risk_aml_default_threshold`、`guard_rate_limit_max`、`guard_rate_limit_window_seconds`
|
||||
- **⚠️ issuer 硬伤**:模块 `jwt_issuer = https://idp.jinrong.internal`,宿主 `https://idp.jinrong.dev`。
|
||||
自治方案下**不改任何一方**,由 S2 适配器在模块入口完成映射;已签发 token 不受影响(模块继续认自己的 issuer)。
|
||||
|
||||
### S4 · 引擎工厂
|
||||
|
||||
模块统一用 `app/utils/db.py:get_engine(database)`(含 `dispose_engines` 生命周期)。
|
||||
宿主 `app/config/database.py` 提供 `get_agent_engine()` / `get_core_engine()`。
|
||||
**红线**:模块代码一律不得 `from app.config.database import ...`,否则连接池分裂、测试 monkeypatch 失效。由防呆测试锁死。
|
||||
AL-09 可选优化:让 `config.database` 内部转调 `utils.db.get_engine`,保留宿主 API 外观、共用一处连接池。
|
||||
|
||||
---
|
||||
|
||||
## 3. 防呆机制(`tests/test_module_boundary.py`)
|
||||
|
||||
新增 4 组断言,任何一条被破坏即测试失败:
|
||||
|
||||
1. **模块私有文件存在性**:A 类关键文件不得被误删。
|
||||
2. **禁止跨层 import**:模块文件(A/B 类)中不得出现 `from app.gateway.auth_deps`、`from app.config.database`、`from app.middleware.trace`、`from app.utils.input_guard`(宿主版)的导入。
|
||||
3. **AuthContext 契约**:模块 `AuthContext` 必须保留 `actor_id/roles/customer_id/token_type/permissions/tenant_id/jti` 字段与 `has_role(多参)/has_permission/is_customer` 方法——防合并时被宿主类替换。
|
||||
4. **settings 私有字段**:§2-S3 列出的模块私有配置必须齐全;`AGENT_TYPES` 必须等于 `("customer","advisor","analyst","risk")`。
|
||||
|
||||
> 用法:AL-09 合并后立刻跑 `pytest tests/test_module_boundary.py`,全绿才说明模块没被宿主侵蚀。
|
||||
|
||||
---
|
||||
|
||||
## 4. 冲突裁决表(AL-09 用,试合并实测 20 个冲突文件)
|
||||
|
||||
> 实测命令:`git merge-tree --write-tree --name-only risk-control-agent 3995cb4`(只读,可随时复算)
|
||||
|
||||
| # | 冲突文件 | 归属 | 裁决 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | `app/api/chat.py` | C 公共 | 以 main 为主;模块对话能力经 Tool 注册表与 `agent_service` 挂载,不占用此文件 |
|
||||
| 2 | `app/main.py` | C 接缝 S1 | 以 main 骨架为主 + 追加模块 router/中间件(见 §2-S1) |
|
||||
| 3 | `app/config/settings.py` | C 接缝 S3 | 以 main 为主 + 保留模块私有字段(见 §2-S3 清单) |
|
||||
| 4 | `app/service/agent_service.py` | C 公共 | 以 main 为主;注意入口函数差异:main `run_chat(ctx,msg,cust)->(str,bool)` vs 模块 `chat(...)`,**由适配层统一,勿直接替换** |
|
||||
| 5 | `app/service/memory_service.py` | C 公共 | 以 main 为主,补齐模块会话窗口所需方法 |
|
||||
| 6 | `app/utils/exceptions.py` | C 公共 | 以 main 为主,补齐模块错误码(`AUTH_*`、`STATE_CONFLICT` 等) |
|
||||
| 7 | `app/utils/response.py` | C 公共 | 以 main 为主,补齐模块响应字段 |
|
||||
| 8 | `app/repository/core_ro.py` | A 私有 | **以模块版为准**(AL-03 已融合 main 四项 + 我方风控扩展 5 项,main 版缺风控扩展) |
|
||||
| 9 | `app/model/suitability.py` | A 私有 | **以模块版为准**(AL-04 引入;main 版为纯落库映射,缺模块兼容层) |
|
||||
| 10 | `app/gateway/__init__.py` | B/D 混合 | 合并两侧导出:保留模块 `trade_gateway` 导出,不引入 main 的 gateway 鉴权导出 |
|
||||
| 11 | `AGENTS.md` | 文档 | 以我方为准(含 memory 六件套指引),吸收 main 新增条目 |
|
||||
| 12~18 | `docs/memory/{MEMORY,TODO,REQUIREMENTS,FRAMEWORK,FLOW,ENVIRONMENT,ITERATION}.md` | 文档 | **以我方为准**(六件套是我方进度事实源),逐份吸收 main 新增实质内容 |
|
||||
| 19 | `scripts/core/02-seed-base.sql` | A 私有 | 手工融合:main 账号(STAFF-10086~50001 等)+ 模块独有 **STAFF-90001**(风控演示账号,main 无!)+ C5 前置 **STAFF-31001/31002** |
|
||||
| 20 | `tests/conftest.py` | A 私有 | **以模块版为准**(406 用例依赖),再把 main 的 wave0 fixture 增量并入 |
|
||||
|
||||
**另有 13 个 main 独有文件不报冲突、静默并入** → 见 §1.4,全部接纳但模块不得 import。
|
||||
|
||||
---
|
||||
|
||||
## 5. 三处硬伤在自治方案下的处置
|
||||
|
||||
| 硬伤 | 原风险 | 自治方案下处置 |
|
||||
| --- | --- | --- |
|
||||
| JWT issuer 不一致(`idp.jinrong.internal` vs `.dev`) | 统一后旧 token 全 401 | **不统一**。模块继续认自己的 issuer;跨层调用走 S2 适配器。省掉一次全量 token 重签 |
|
||||
| main 无 STAFF-90001(风控演示账号) | 演示账号变 401 | 模块账号由模块 seed 维护(`02-seed-base.sql` 融合时保留),不走宿主 `DEFAULT_ROLES_BY_ACTOR` |
|
||||
| main `infer_roles` 未知 actor 默认 `["analyst"]`(fail-open,含 `sql:execute:readonly`) | 越权风险 | 模块鉴权不采用 `infer_roles`;**若宿主侧修复前上线,须在 AL-09 记录为待办 P1**——模块自身为 fail-closed(缺失 X-Agent-Type 即 401) |
|
||||
|
||||
---
|
||||
|
||||
## 6. AL-09 执行步骤(按本方案修订)
|
||||
|
||||
1. `git merge main`(预期 20 冲突,按 §4 表逐个裁决,禁止 `git checkout --ours/theirs` 批量解决);
|
||||
2. 跑 `pytest tests/test_module_boundary.py`,红了先修边界再继续;
|
||||
3. 接 S2 适配器:`app/api/auth_adapter.py` 接线到模块入口;
|
||||
4. 接 S1 挂载点:`main.py` 补 router + 两层中间件,实测中间件顺序(trace 在内层);
|
||||
5. 处理 S4:确认模块无 `config.database` 导入(防呆测试已覆盖);
|
||||
6. 融合 `02-seed-base.sql`(保留 STAFF-90001 / 补 STAFF-31001、31002);
|
||||
7. SOP §2 重灌演示库 → 全量 `pytest` 须回 **406 + 新增边界用例**全绿;
|
||||
8. uvicorn 冒烟:三端点 + 模块 4 API 逐个验证(注意 JWT 与 debug 头两条通道都要走一遍)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 待办与遗留
|
||||
|
||||
- [ ] AL-09 时实现 `setup_risk_module(app)`(中间件注册函数),当前 main.py 仍为直接装饰器
|
||||
- [ ] 中间件执行顺序实测(main `add_middleware` vs 模块 `@app.middleware` 双层叠加)
|
||||
- [ ] 宿主 `infer_roles` fail-open 缺陷:建议在 AL-09 一并提交宿主侧修复(P1,非模块阻塞项)
|
||||
- [ ] 模块代码若未来要彻底隔离,可迁至 `app/modules/risk/`——本次**不做**(属重构,收益 < 风险)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""S2 接缝适配器单测:宿主 AuthContext → 模块 AuthContext。
|
||||
|
||||
适配器当前为预制件(未接线),本测试保证它随时可用、且语义是 fail-closed。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.auth_adapter import (
|
||||
HostAuthAdapterError,
|
||||
from_host_auth,
|
||||
perm_matches,
|
||||
resolve_agent_type,
|
||||
)
|
||||
from app.utils.trace import current_trace, reset_trace, set_trace
|
||||
|
||||
|
||||
def _host(**kw):
|
||||
"""构造一个宿主风格的 AuthContext(字段与 main 的 schemas.AuthContext 对齐)。"""
|
||||
base = dict(
|
||||
sub="STAFF-90001",
|
||||
token_type="staff",
|
||||
roles=["risk_officer"],
|
||||
permissions=["risk:alert:write"],
|
||||
tenant_id="default",
|
||||
trace_id="trace-host-001",
|
||||
agent_type="risk",
|
||||
jti="jti-001",
|
||||
customer_id=None,
|
||||
)
|
||||
base.update(kw)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
def test_sub_maps_to_actor_id():
|
||||
ctx = from_host_auth(_host(), bind_trace=False)
|
||||
assert ctx.actor_id == "STAFF-90001"
|
||||
|
||||
|
||||
def test_roles_and_permissions_passthrough():
|
||||
ctx = from_host_auth(_host(roles=["risk_officer", "risk_demo"]), bind_trace=False)
|
||||
assert ctx.has_role("risk_officer", "compliance") is True
|
||||
assert ctx.has_permission("risk:alert:write") is True
|
||||
|
||||
|
||||
def test_customer_token_fallbacks_customer_id_to_sub():
|
||||
ctx = from_host_auth(_host(sub="CUST-1001", token_type="customer"), bind_trace=False)
|
||||
assert ctx.customer_id == "CUST-1001"
|
||||
assert ctx.is_customer() is False # 角色集合仍为空,归属判定不因 token_type 放宽
|
||||
|
||||
|
||||
def test_missing_sub_is_fail_closed():
|
||||
bad = SimpleNamespace(sub=None, actor_id=None, roles=[], token_type="staff")
|
||||
with pytest.raises(HostAuthAdapterError):
|
||||
from_host_auth(bad, bind_trace=False)
|
||||
|
||||
|
||||
def test_empty_sub_string_is_fail_closed():
|
||||
bad = SimpleNamespace(sub="", roles=[], token_type="staff")
|
||||
with pytest.raises(HostAuthAdapterError):
|
||||
from_host_auth(bad, bind_trace=False)
|
||||
|
||||
|
||||
def test_trace_binding_writes_contextvar():
|
||||
token = set_trace("before")
|
||||
try:
|
||||
from_host_auth(_host(trace_id="trace-host-999"), bind_trace=True)
|
||||
assert current_trace() == "trace-host-999"
|
||||
finally:
|
||||
reset_trace(token)
|
||||
|
||||
|
||||
def test_trace_not_bound_when_disabled():
|
||||
token = set_trace("keep-me")
|
||||
try:
|
||||
from_host_auth(_host(trace_id="trace-host-999"), bind_trace=False)
|
||||
assert current_trace() == "keep-me"
|
||||
finally:
|
||||
reset_trace(token)
|
||||
|
||||
|
||||
def test_perm_matches_exact_and_wildcard():
|
||||
perms = ["risk:alert:read", "audit:*"]
|
||||
assert perm_matches("risk:alert:read", perms) is True
|
||||
assert perm_matches("audit:read:all", perms) is True # 命中 audit:* 通配
|
||||
assert perm_matches("trade:execute", perms) is False
|
||||
assert perm_matches("", perms) is False
|
||||
|
||||
|
||||
def test_resolve_agent_type():
|
||||
assert resolve_agent_type(_host(agent_type="risk")) == "risk"
|
||||
assert resolve_agent_type(SimpleNamespace(agent_type=None)) == "risk" # 默认回退
|
||||
@@ -0,0 +1,150 @@
|
||||
"""模块边界防呆测试(配合《风控Agent模块边界与合并接缝标注.md》§3)。
|
||||
|
||||
风控 Agent 按"独立封装模块"自治:模块私有实现自己维护,与宿主(main)的耦合
|
||||
只走 4 个接缝。本文件把这层约定写成断言——**AL-09 合并 main 后必须全绿**,
|
||||
任何一条红了都说明模块被宿主侵蚀或接缝被破坏,应先修边界再继续。
|
||||
|
||||
四类断言:
|
||||
1. 模块私有文件未被误删;
|
||||
2. 模块代码不得 import 宿主私有实现(防双套串味);
|
||||
3. 模块 AuthContext 契约未被宿主类替换;
|
||||
4. 模块私有 settings 字段未被合并丢掉。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.deps import AuthContext
|
||||
from app.config.settings import settings
|
||||
from app.utils.authz import AGENT_TYPES
|
||||
|
||||
APP_DIR = Path(__file__).resolve().parents[1] / "app"
|
||||
|
||||
# 模块私有关键文件(A 类):缺失即说明被误删或合并时被宿主覆盖
|
||||
MODULE_PRIVATE_FILES = [
|
||||
"app/api/deps.py",
|
||||
"app/api/risk.py",
|
||||
"app/api/simulate.py",
|
||||
"app/service/auth_service.py",
|
||||
"app/service/input_guard.py",
|
||||
"app/service/suitability.py",
|
||||
"app/service/tool_service.py",
|
||||
"app/repository/core_ro.py",
|
||||
"app/repository/risk_repository.py",
|
||||
"app/repository/session_repository.py",
|
||||
"app/gateway/trade_gateway.py",
|
||||
"app/model/suitability.py",
|
||||
"app/utils/db.py",
|
||||
"app/utils/trace.py",
|
||||
"app/api/audit_middleware.py",
|
||||
"app/service/risk/engine.py",
|
||||
"app/service/risk/alert_service.py",
|
||||
]
|
||||
|
||||
# 禁止模块代码导入的宿主私有实现(D 类 / B 类宿主侧)
|
||||
FORBIDDEN_IMPORT_PREFIXES = (
|
||||
"from app.gateway.auth_deps",
|
||||
"from app.gateway.jwt_service",
|
||||
"from app.gateway.rbac",
|
||||
"from app.gateway.ownership",
|
||||
"from app.config.database",
|
||||
"from app.middleware",
|
||||
"from app.utils.input_guard", # 模块用 app.service.input_guard(含限流与留痕)
|
||||
"from app.model.schemas", # 宿主 AuthContext,须经 app.api.auth_adapter 转换
|
||||
)
|
||||
|
||||
# 模块私有 settings 字段(合并时一个都不能丢)
|
||||
MODULE_SETTINGS_FIELDS = (
|
||||
"mysql_core_database",
|
||||
"redis_url",
|
||||
"milvus_uri",
|
||||
"ollama_base_url",
|
||||
"embed_model",
|
||||
"embed_dim",
|
||||
"embed_timeout_seconds",
|
||||
"deepseek_api_key",
|
||||
"deepseek_base_url",
|
||||
"risk_large_amount",
|
||||
"risk_daily_total",
|
||||
"risk_freq_count",
|
||||
"risk_probe_window_minutes",
|
||||
"risk_probe_count",
|
||||
"risk_probe_amount",
|
||||
"risk_small_amount",
|
||||
"risk_small_count",
|
||||
"risk_aml_default_threshold",
|
||||
"guard_rate_limit_max",
|
||||
"guard_rate_limit_window_seconds",
|
||||
)
|
||||
|
||||
_IMPORT_LINE = re.compile(r"^\s*(from|import)\s+")
|
||||
|
||||
|
||||
def _iter_module_py_files():
|
||||
for path in APP_DIR.rglob("*.py"):
|
||||
if "__pycache__" in path.parts:
|
||||
continue
|
||||
yield path
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rel_path", MODULE_PRIVATE_FILES)
|
||||
def test_module_private_file_exists(rel_path):
|
||||
"""模块私有文件必须存在(防合并时被删除/覆盖)。"""
|
||||
assert (APP_DIR.parent / rel_path).is_file(), f"模块私有文件缺失:{rel_path}"
|
||||
|
||||
|
||||
def test_no_host_private_imports():
|
||||
"""模块代码不得直接 import 宿主私有实现——跨层一律走接缝。"""
|
||||
offenders: list[str] = []
|
||||
for path in _iter_module_py_files():
|
||||
for lineno, line in enumerate(
|
||||
path.read_text(encoding="utf-8", errors="ignore").splitlines(), start=1
|
||||
):
|
||||
stripped = line.strip()
|
||||
if not _IMPORT_LINE.match(stripped):
|
||||
continue # 只看真正的 import 行,避免注释/文档字符串误报
|
||||
if stripped.startswith(FORBIDDEN_IMPORT_PREFIXES):
|
||||
rel = path.relative_to(APP_DIR.parent).as_posix()
|
||||
offenders.append(f"{rel}:{lineno} -> {stripped}")
|
||||
|
||||
assert not offenders, "发现跨层导入宿主私有实现(应改走接缝):\n" + "\n".join(offenders)
|
||||
|
||||
|
||||
def test_auth_context_contract_intact():
|
||||
"""模块 AuthContext 契约必须完整——防被宿主的 schemas.AuthContext 替换。
|
||||
|
||||
宿主用 sub/trace_id/agent_type、has_role 单参、has_perm;
|
||||
模块用 actor_id、has_role 多参、has_permission。字段名或方法名一变,
|
||||
模块内全量引用会静默失效,故在此锁死。
|
||||
"""
|
||||
for field in (
|
||||
"actor_id",
|
||||
"roles",
|
||||
"customer_id",
|
||||
"token_type",
|
||||
"permissions",
|
||||
"tenant_id",
|
||||
"jti",
|
||||
):
|
||||
assert field in AuthContext.model_fields, f"AuthContext 缺失字段:{field}"
|
||||
|
||||
ctx = AuthContext(actor_id="STAFF-90001", roles=["risk_officer", "risk_demo"])
|
||||
assert ctx.has_role("risk_officer", "compliance") is True # 多参语义
|
||||
assert ctx.has_role("advisor") is False
|
||||
assert ctx.has_permission("risk:alert:write") is False
|
||||
assert ctx.is_customer() is False
|
||||
|
||||
|
||||
def test_module_settings_fields_present():
|
||||
"""模块私有配置字段必须齐全(防合并 settings.py 时被丢)。"""
|
||||
missing = [f for f in MODULE_SETTINGS_FIELDS if not hasattr(settings, f)]
|
||||
assert not missing, f"settings 丢失模块私有字段:{missing}"
|
||||
|
||||
|
||||
def test_agent_types_contract():
|
||||
"""Agent 类型四值须与宿主一致(这是少数双方天然对齐的契约,不得漂移)。"""
|
||||
assert AGENT_TYPES == ("customer", "advisor", "analyst", "risk")
|
||||
Reference in New Issue
Block a user