相对第一版 46fc976 的完整变更。组员迁移对照表见 docs/20。
一、对外契约对齐 docs/05(破坏性,共 4 处,组员需按 docs/20 调整)
1) 配置发布端点改为文档规定的复数资源名:submit→validations、
approve→reviews(需 body decision)、activate→activations、
rollback→rollbacks;第一版这 4 个动词式路径 docs/05 从未定义过。
2) 错误码由 8 个笼统码改为 15 个具体语义码(FORBIDDEN→AGENT_PERMISSION_DENIED、
UNAUTHORIZED→AUTHENTICATION_REQUIRED、CONFLICT→RESOURCE_VERSION_CONFLICT、
RESOURCE_NOT_FOUND→RUN_NOT_FOUND/SESSION_NOT_FOUND 等),
输入类错误状态码 400→422。
3) POST /api/v1/agent-runs 与 GET /api/v1/agent-runs/{run_id} 统一为
{data, meta} 信封(data 内字段名与语义未变)。
4) 错误响应体统一为 {error:{code,message,retryable,field_errors}, meta:{trace_id}},
不再返回 FastAPI 默认的 {"detail": ...}。
二、数据库基线与约束
新增 39 张表的基线迁移(链根)与联合唯一键纠偏(4 张表、删 8 增 4,幂等收敛);
撤下 config_release 的双人复核 CHECK(应用层已允许自审,审核节点保留,
自审如实写入 reviewer_id);记忆 active key 生成列与唯一键;
activate 开始记录 supersedes_release_id 使版本链可追溯。
docs/00 基线未修改,未重命名或删除任何表与字段。
三、修复会静默出错或无报错的缺陷
- 跑完集成测试后平台会静默失去生效配置:清理只删自己创建的版本,却没有恢复被它
顶成 superseded 的原生效版本,且审计一并删除因而完全无痕,表现为所有工具被拒
但没有任何报错。已修清理逻辑并加恢复。
- Worker 单轮异常导致进程退出;记忆抽取调用方的“事务已开始”异常;
召回缓存丢失 degraded 标记;连接时区未生效导致 created_at/updated_at 差 8 小时;
.env 与 os.getenv 密钥来源分裂导致“没有可用的已批准模型端点”。
- 记忆信号识别漏判与跨键误命中;SSE 未带 Accept 的协商行为。
四、功能补齐
记忆链路 P1/P2/P3(抽取、受控词表、召回与缓存、生命周期级联及投影事件)、
fin_* 场内交易只读 ORM 层、agent_intent_config 状态流转并在运行期真正生效、
限流(Redis 固定窗口、故障一律放行)、游标校验、trace_id 中间件、
示例业务 Agent fund_query_demo 与一键端到端验证脚本,以及审计/指纹/迁移状态工具。
五、文档与验证
新增 docs/19(业务 Agent 接入实操)、docs/20(第一版迁移指南)与 docs/evidence 证据;
docs/01/02/06/08/09/17 同步实现现状。
验证结果:ruff 通过、mypy 103 文件无错、unit+contract 447 passed、
integration 29 passed、acceptance_check --production 7 PASS、
demo_agent_e2e 9/9 PASS(含失败关闭反证)。
166 lines
7.4 KiB
Python
166 lines
7.4 KiB
Python
"""造 RBAC 测试数据,验证真实链路身份加载与越权拦截。
|
||
|
||
数据使用 9001/9002/9003 号段(角色、权限分别用 9001 起的独立号段),便于清理,
|
||
不影响业务数据。
|
||
|
||
为什么需要这份种子:权限码必须与**代码实际声明**一致——工具声明写在
|
||
`app/service/agent/bootstrap.py`(`check_suitability` 需 `suitability:read`、
|
||
`query_fund_quote` 需 `fund:quote:read`),接口校验写在各 Service 的
|
||
`AuthorizationService.require(...)`。种子缺权限的后果不是"少测一项",而是
|
||
"功能明明实现了却一直 403",让验收与集成测试得出错误结论。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from datetime import UTC, datetime, timedelta
|
||
|
||
from sqlalchemy import text
|
||
|
||
from app.core.contracts import RequestContext
|
||
from app.infrastructure.db import SessionFactory
|
||
from app.service.identity_service import IdentityService
|
||
|
||
CUSTOMER_USER = "9001"
|
||
RISK_USER = "9002"
|
||
ADMIN_USER = "9003"
|
||
|
||
# (权限 id, 权限码, resource, action, data_scope)
|
||
PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = (
|
||
(9001, "agent:run", "agent", "run", "self"),
|
||
(9002, "suitability:read", "suitability", "read", "self"),
|
||
(9003, "fund:quote:read", "fund", "quote", "self"),
|
||
(9004, "conversation:create", "conversation", "create", "self"),
|
||
(9005, "conversation:close", "conversation", "close", "self"),
|
||
(9006, "conversation:feedback", "conversation", "feedback", "self"),
|
||
(9007, "agent:cancel", "agent", "cancel", "self"),
|
||
(9008, "handover:create", "handover", "create", "self"),
|
||
(9009, "memory:read:self", "memory", "read", "self"),
|
||
(9010, "memory:read:customer", "memory", "read", "own_customers"),
|
||
(9011, "knowledge:reference:read", "knowledge", "reference", "self"),
|
||
(9012, "audit:read", "audit", "read", "all"),
|
||
(9013, "config:read", "config", "read", "all"),
|
||
(9014, "config:write", "config", "write", "all"),
|
||
(9015, "config:review", "config", "review", "all"),
|
||
(9016, "config:activate", "config", "activate", "all"),
|
||
(9017, "model-endpoint:manage", "model-endpoint", "manage", "all"),
|
||
)
|
||
|
||
# 客户:业务侧自助能力(自己的会话、反馈、转人工、自己的记忆画像)。
|
||
CUSTOMER_PERMISSIONS = (9001, 9002, 9003, 9004, 9005, 9006, 9007, 9008, 9009, 9011)
|
||
# 风控专员:业务侧只读 + 跨客户记忆 + 审计只读,不含配置写权限。
|
||
RISK_PERMISSIONS = (9001, 9002, 9003, 9010, 9011, 9012)
|
||
# 平台管理员:管理面全套(配置发布四态 + 模型端点 + 审计)。
|
||
ADMIN_PERMISSIONS = tuple(row[0] for row in PERMISSIONS)
|
||
|
||
ROLES: tuple[tuple[int, str, str], ...] = (
|
||
(9001, "customer", "客户"),
|
||
(9002, "risk_operator", "风控专员"),
|
||
(9003, "admin", "平台管理员"),
|
||
)
|
||
|
||
USERS: tuple[tuple[int, str, str, str], ...] = (
|
||
(9001, "T-CUST", "cust_t", "customer"),
|
||
(9002, "T-RISK", "risk_t", "employee"),
|
||
(9003, "T-ADMIN", "admin_t", "employee"),
|
||
)
|
||
|
||
GRANTS: tuple[tuple[int, tuple[int, ...]], ...] = (
|
||
(9001, CUSTOMER_PERMISSIONS),
|
||
(9002, RISK_PERMISSIONS),
|
||
(9003, ADMIN_PERMISSIONS),
|
||
)
|
||
|
||
|
||
async def seed() -> None:
|
||
now = datetime.now(UTC).replace(tzinfo=None)
|
||
# MySQL 的 DATETIME(0) 会把微秒四舍五入到秒:若用"当前时间"写 assigned_at,
|
||
# 进位后可能落在未来,使紧随其后的 `assigned_at <= now` 授权校验判定
|
||
# "尚未生效",于是刚种好的账号一个权限都拿不到(表现为 roles=() 而非报错)。
|
||
# 往前留 5 秒,彻底避开这个舍入窗口。
|
||
effective_at = now - timedelta(seconds=5)
|
||
user_ids = tuple(user[0] for user in USERS)
|
||
role_ids = tuple(role[0] for role in ROLES)
|
||
async with SessionFactory() as session:
|
||
await session.execute(
|
||
text("DELETE FROM sys_role_permission WHERE role_id IN (9001,9002,9003)")
|
||
)
|
||
await session.execute(text("DELETE FROM sys_user_role WHERE user_id IN (9001,9002,9003)"))
|
||
await session.execute(text("DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099"))
|
||
await session.execute(text("DELETE FROM sys_role WHERE id IN (9001,9002,9003)"))
|
||
await session.execute(text("DELETE FROM sys_user WHERE id IN (9001,9002,9003)"))
|
||
|
||
for user_id, user_no, user_name, user_type in USERS:
|
||
await session.execute(
|
||
text(
|
||
"INSERT INTO sys_user (id, user_no, username, password_hash, user_type,"
|
||
" professional_investor_status, fund_account_status, status,"
|
||
" created_at, updated_at)"
|
||
" VALUES (:id,:no,:name,'x',:type,'none','closed','正常',:now,:now)"
|
||
),
|
||
{"id": user_id, "no": user_no, "name": user_name, "type": user_type, "now": now},
|
||
)
|
||
for role_id, role_code, role_name in ROLES:
|
||
await session.execute(
|
||
text(
|
||
"INSERT INTO sys_role (id, role_code, role_name, status, created_at, updated_at)"
|
||
" VALUES (:id,:code,:name,'active',:now,:now)"
|
||
),
|
||
{"id": role_id, "code": role_code, "name": role_name, "now": now},
|
||
)
|
||
for permission_id, code, resource, action, scope in PERMISSIONS:
|
||
await session.execute(
|
||
text(
|
||
"INSERT INTO sys_permission"
|
||
" (id, permission_code, resource, action, data_scope, created_at, updated_at)"
|
||
" VALUES (:id,:code,:resource,:action,:scope,:now,:now)"
|
||
),
|
||
{"id": permission_id, "code": code, "resource": resource, "action": action,
|
||
"scope": scope, "now": now},
|
||
)
|
||
for user_id, role_id in zip(user_ids, role_ids, strict=True):
|
||
await session.execute(
|
||
text(
|
||
"INSERT INTO sys_user_role (user_id, role_id, assigned_at)"
|
||
" VALUES (:user,:role,:now)"
|
||
),
|
||
{"user": user_id, "role": role_id, "now": effective_at},
|
||
)
|
||
for role_id, permission_ids in GRANTS:
|
||
for permission_id in permission_ids:
|
||
await session.execute(
|
||
text(
|
||
"INSERT INTO sys_role_permission (role_id, permission_id, created_at)"
|
||
" VALUES (:role,:perm,:now)"
|
||
),
|
||
{"role": role_id, "perm": permission_id, "now": now},
|
||
)
|
||
await session.commit()
|
||
|
||
|
||
async def verify() -> None:
|
||
service = IdentityService()
|
||
for label, user_id in (("customer", CUSTOMER_USER), ("risk_operator", RISK_USER),
|
||
("admin", ADMIN_USER)):
|
||
context = await service.resolve(RequestContext(user_id=user_id, trace_id="verify-trace"))
|
||
print(
|
||
f"{label:14s} roles={context.roles} permissions={sorted(context.permissions)} "
|
||
f"portal={context.portal} data_scope={context.data_scope}"
|
||
)
|
||
customer = await service.resolve(
|
||
RequestContext(user_id=CUSTOMER_USER, trace_id="verify-trace")
|
||
)
|
||
missing = {"agent:run", "suitability:read", "fund:quote:read"} - set(customer.permissions)
|
||
if missing:
|
||
raise SystemExit(f"客户角色缺少公共工具所需权限:{sorted(missing)}")
|
||
print("verified: customer can pass public tool authorization checks")
|
||
|
||
|
||
async def main() -> None:
|
||
await seed()
|
||
await verify()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
asyncio.run(main())
|