diff --git a/.gitignore b/.gitignore index 82a50ab9..ffa2b5fa 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,7 @@ temp-*/ *.log .playwright-cli/ .tmp-pytest/ +**/.test-runtime/ +**/contract-regression-*.sqlite3 +**/browser-regression.sqlite3 tmp_domain_block*.txt diff --git a/project-docs/V2-data-anti-scraping-security-plan-2026-07-21.md b/project-docs/V2-data-anti-scraping-security-plan-2026-07-21.md new file mode 100644 index 00000000..b85d56eb --- /dev/null +++ b/project-docs/V2-data-anti-scraping-security-plan-2026-07-21.md @@ -0,0 +1,583 @@ +# AI 合同平台 V2 数据安全与反爬虫方案 + +日期:2026-07-21 + +适用范围:AI 合同平台 V2 的 Contract Web、Contract API、AI Platform API、worker、Nginx/WAF、PostgreSQL、文件仓库、审计与发布质量门。 + +## 1. 背景和目标 + +V2 已经具备合同台账、模板起草、审查结果、条款推荐、法律全文库、知识库、AI Trace、文件仓库和报告下载等能力。上线后真正的数据安全风险不只是匿名爬虫访问公开页面,而是合法账号、被盗 Cookie、内部人员、多账号轮换或代理池持续抓取数据库中的高价值数据。 + +本方案目标: + +- 降低合同、附件、审查报告、模板、条款规则、Prompt、知识库、AI Trace 被批量抓取和下载的概率。 +- 让列表接口默认只暴露摘要,正文和敏感内容走详情接口并计量。 +- 对搜索、详情、预览、下载、批量导出建立多维限流和每日出站预算。 +- 对异常行为及时告警、冻结、审计,并提供管理员解封和临时放宽配额流程。 +- 把反爬和数据出站控制纳入上线前 schema-check、smoke、安全扫描和接口压测。 + +非目标: + +- 不承诺阻止用户截图、拍照或人工逐条复制。 +- 不把 `robots.txt`、UA 黑名单或验证码作为核心防线。 +- 不在本方案阶段直接删除既有业务字段或大改数据库;数据库变更需单独迁移评审。 + +## 2. 当前能力 + +已经具备: + +- Contract Web 到 Contract API 有 proxy shared secret。 +- AI Platform 有 service shared secret。 +- 已有租户字段,合同、文件、知识库、AI Trace、质量动作等多数持久化查询具备租户过滤基础。 +- Contract Web 使用 HMAC 签名 Cookie,HttpOnly,SameSite=Lax。 +- 身份接口已有邮箱、IP、租户维度限流,主要覆盖登录、验证码、邀请。 +- 合同、附件、批量附件、审查报告下载已有审计事件基础。 +- 合同、文件、搜索等分页接口通常限制 `page_size <= 100`。 +- 生产 Nginx 已有 IP 级限流、连接限制、HTTPS 和安全响应头。 +- AI Trace 已有脱敏、保留、归档治理字段和 worker 基础。 + +## 3. 关键缺口 + +P0 缺口: + +- 业务接口缺统一后端 RBAC 和对象级授权门,不能仅依赖前端菜单隐藏。 +- 部分接口仍存在从请求参数传 `role` 的兼容习惯,生产不能信任客户端角色。 +- 后端需要使用可信身份上下文,不能裸信任外部传入的 `X-Tenant-Id`、`X-User-Id`。 +- 缺统一业务 API 限流,当前 Nginx IP 限流只能挡粗暴洪泛,不能挡已登录慢速搬运。 +- 缺按记录数、文件数、字节数计量的数据出站预算。 +- 批量下载缺文件数和总字节硬限制。 +- 模板、标准条款、审查规则、Prompt、知识库 chunk、法律全文等高价值资产需要摘要列表和详情计量。 +- AI Trace、Prompt、知识库、审查规则属于管理资产,需要严格角色授权和低频配额。 +- 下载审计需要补齐真实 user_id、tenant_id、IP 哈希、UA 哈希、字节数、trace id、数据分级。 +- runtime/schema-check 尚未检查反滥用配置是否启用。 + +P1 缺口: + +- 缺服务端 session 表、会话撤销、并发会话限制和风险会话冻结。 +- 缺统一安全事件表、行为风险评分、管理员“数据安全中心”。 +- Word/PDF 下载缺用户、租户、时间、trace id 水印。 +- 缺异常访问告警、配额临时放宽和解封闭环。 + +P2 缺口: + +- 可接入阿里云 WAF/Bot Management。 +- 可引入设备指纹作为辅助风险信号。 +- 可加入高风险导出审批、蜜罐资源标识、DLP 和泄露追踪。 + +## 4. 数据分级 + +| 等级 | 数据 | 访问原则 | +| --- | --- | --- | +| S4 极高敏感 | 合同正文、附件、审查报告、签署证据、AI Trace 原始输入输出 | 详情和下载必须授权、计量、审计、限流 | +| S3 高敏感 | 模板正文、变量 schema、标准条款、审查规则、Prompt、知识库 chunk | 列表只返回摘要,正文走详情并计量 | +| S2 内部资产 | 法律全文、法规影响扫描、全局审计、质量动作 | 分页、角色限制、搜索和详情计量 | +| S1 普通元数据 | 标题、状态、分类、摘要、创建时间、负责人 | 可列表展示,但仍分页和限流 | +| S0 公开信息 | 健康检查、公开宣传内容 | 可公开,但仍走基础 WAF 和速率限制 | + +原则:列表接口默认返回 S1 摘要;S3/S4 正文、完整 prompt、chunk、文件内容、报告内容都必须通过详情、预览、下载或导出接口单独授权和计量。 + +## 5. 威胁模型 + +重点防护对象: + +- 匿名爬虫扫描公开路径。 +- 已登录普通用户持续翻页下载。 +- 被盗 Cookie 或共享账号批量导出合同、附件、报告。 +- 同一租户多个账号轮换抓取。 +- 多 IP 或代理池绕过单 IP 限流。 +- 内部管理员批量导出 Prompt、AI Trace、知识库、审查规则。 +- 直接调用 BFF/API 绕过前端按钮和菜单。 + +核心结论:只靠 Nginx IP 限流不够。必须在应用层按租户、用户、会话、IP、接口风险等级、数据分级和出站量做统一控制。 + +## 6. 高风险接口清单 + +Contract API: + +- `GET /api/v1/contracts` +- `GET /api/v1/contracts/{contract_id}` +- `GET /api/v1/contracts/{contract_id}/download` +- `GET /api/v1/contracts/{contract_id}/review-report` +- `GET /api/v1/files` +- `GET /api/v1/files/{file_id}/preview` +- `GET /api/v1/files/{file_id}/download` +- `POST /api/v1/files/batch-download` +- `GET /api/v1/search` +- `GET /api/v1/templates/tree` +- `GET /api/v1/templates/{template_id}` +- `POST /api/v1/templates/{template_id}/draft` +- `GET /api/v1/legal-assets/*` +- `GET /api/v1/legal-fulltext/*` +- `POST /api/v1/legal-change-impacts/scan` +- `POST /api/v1/legal/qa` +- `GET /api/v1/audit/*` + +AI Platform API: + +- `GET /api/v1/audit/ai-runs` +- `GET /api/v1/audit/ai-runs/{run_id}` +- `GET /api/v1/prompt-templates` +- `GET /api/v1/prompt-templates/{name}` +- `GET /api/v1/knowledge-documents` +- `GET /api/v1/knowledge-documents/search` +- `GET /api/v1/knowledge-import-batches/{batch_id}/documents` +- `GET /api/v1/knowledge-import-batches/{batch_id}/chunks` +- `GET /api/v1/audit/quality-improvement-actions` +- `GET /api/v1/runtime/trace-governance/*` + +Contract Web BFF: + +- `/api/contract-platform/*` +- `/api/ai-platform/*` + +BFF 必须传递可信 session 上下文,不允许浏览器直接伪造用户、租户、角色。 + +## 7. 控制架构 + +建议采用十层防护: + +1. 可信身份上下文:Contract Web 从 HttpOnly session 解出 tenant/user/role,后端只接受 BFF 签名后的上下文。 +2. 后端 RBAC:每个 endpoint 绑定最小角色集合。 +3. 对象级授权:合同、文件、审查结果、模板草稿、AI Trace 必须检查 tenant_id 和权限关系。 +4. Endpoint risk policy:按接口定义数据等级、记录计量、字节计量、限流桶和默认动作。 +5. Redis 多维限流:按 user、tenant、session、IP 哈希、route、risk class 组合限流。 +6. 数据出站预算:按日统计记录数、文件数、字节数、S3/S4 详情次数。 +7. 下载授权:下载前创建短时 download grant,限制 jti、文件、用户、租户、过期时间和一次性使用。 +8. 审计和风险评分:每次 S3/S4 访问、搜索、导出、下载、429、403 都写安全事件。 +9. 告警与冻结:达到阈值后自动通知管理员,严重时临时冻结用户、会话或 IP 哈希。 +10. 水印与追踪:报告和 Word/PDF 下载增加用户、租户、时间、trace id 标记。 + +## 8. RBAC 和对象授权矩阵 + +| 能力 | admin | legal | business | approver | observer | 说明 | +| --- | --- | --- | --- | --- | --- | --- | +| 合同列表摘要 | 是 | 是 | 是 | 是 | 只读 | 只能本租户 | +| 合同详情正文 | 是 | 是 | 相关合同 | 审批相关 | 可配置 | S4 计量 | +| 合同下载 | 是 | 是 | 相关合同 | 审批相关 | 默认否 | S4 计量和水印 | +| 附件预览下载 | 是 | 是 | 相关合同 | 审批相关 | 默认否 | S4 字节预算 | +| 批量下载 | 是 | 是 | 默认否 | 默认否 | 否 | 单次硬限制 | +| 模板正文 | 是 | 是 | 起草可用模板 | 否 | 只读摘要 | S3 计量 | +| 条款规则管理 | 是 | 是 | 否 | 否 | 只读摘要 | 发布下线审计 | +| 法律全文 | 是 | 是 | 是 | 是 | 只读 | S2,可较宽松 | +| Prompt | admin | 否 | 否 | 否 | 否 | S3 管理资产 | +| AI Trace 详情 | admin | 指定审计员 | 否 | 否 | 否 | S4,强脱敏 | +| 全局审计 | admin | 指定审计员 | 否 | 否 | 否 | S2 | +| 用户租户管理 | admin | 否 | 否 | 否 | 否 | 超管另行区分 | + +生产环境禁止客户端通过 query/body 指定自己的 role。兼容字段如需保留,应仅用于日志观测或被后端忽略。 + +## 9. 限流和出站预算初值 + +以下为可配置默认值,不应写死: + +| 类型 | 用户级 | 租户级 | 说明 | +| --- | --- | --- | --- | +| 普通列表 | 60 次/分钟 | 600 次/分钟 | S1 摘要 | +| 搜索 | 20 次/分钟 | 200 次/分钟 | 统一搜索、知识库检索 | +| S3/S4 详情 | 30 次/分钟 | 300 次/分钟 | 计入每日记录预算 | +| 单文件下载 | 10 次/分钟 | 100 次/分钟 | 计入字节预算 | +| 批量下载 | 2 次/小时 | 20 次/小时 | 单次最多 20 个文件、100 MB | +| 合同报告下载 | 10 次/小时 | 100 次/小时 | 计入报告预算 | +| Prompt/AI Trace 详情 | 10 次/分钟 | 100 次/分钟 | 仅管理角色 | +| 法律全文详情 | 60 次/分钟 | 600 次/分钟 | S2,可较宽松 | +| 法律问答 | 10 次/分钟 | 100 次/分钟 | 同时受 AI 成本预算约束 | + +每日预算建议: + +- S3/S4 详情记录:普通用户 1000 条/天,法务 3000 条/天,管理员可配置。 +- 下载文件数:普通用户 50 个/天,法务 200 个/天。 +- 下载字节数:普通用户 500 MB/天,法务 2 GB/天。 +- 批量下载:普通用户默认关闭,法务 5 次/天,管理员 10 次/天。 +- 达到 70% 提示,90% 告警,100% 阻断或要求管理员放行。 + +429 响应必须带 `Retry-After`。高风险异常可以返回 403 并写安全事件。 + +## 10. 数据库设计建议 + +后续迁移建议新增: + +### security_access_events + +记录每次高风险访问。 + +关键字段: + +- `id` +- `tenant_id` +- `user_id` +- `session_id_hash` +- `ip_hash` +- `ua_hash` +- `route_key` +- `http_method` +- `resource_type` +- `resource_id` +- `data_class` +- `records_count` +- `bytes_count` +- `decision` +- `reason_code` +- `trace_id` +- `created_at` + +### data_egress_ledger + +记录每日出站预算消耗。 + +关键字段: + +- `tenant_id` +- `user_id` +- `date` +- `data_class` +- `records_count` +- `files_count` +- `bytes_count` +- `reports_count` +- `updated_at` + +### security_incidents + +记录风险评分后的事件。 + +关键字段: + +- `id` +- `tenant_id` +- `user_id` +- `severity` +- `incident_type` +- `signals` +- `status` +- `assigned_to` +- `created_at` +- `resolved_at` + +### security_blocks + +记录临时冻结。 + +关键字段: + +- `id` +- `dimension` +- `subject_hash` +- `tenant_id` +- `user_id` +- `reason` +- `starts_at` +- `expires_at` +- `created_by` +- `released_by` +- `release_reason` + +### tenant_security_policies + +租户级安全策略。 + +关键字段: + +- `tenant_id` +- `policy_json` +- `risk_mode` +- `download_watermark_enabled` +- `batch_download_enabled` +- `updated_by` +- `updated_at` + +### download_grants + +短时下载授权。 + +关键字段: + +- `jti` +- `tenant_id` +- `user_id` +- `resource_type` +- `resource_id` +- `scope` +- `expires_at` +- `used_at` +- `bytes_limit` +- `created_at` + +可选: + +- `user_sessions`:服务端会话、撤销、并发限制、设备信息、风险状态。 + +原始 IP 和 UA 不入库,统一使用独立 security secret 做 HMAC 哈希。 + +## 11. Redis key 设计 + +建议 key: + +- `abuse:rate:{tenant}:{user}:{route}:{window}` +- `abuse:tenant:{tenant}:{route}:{window}` +- `abuse:ip:{ip_hash}:{route}:{window}` +- `abuse:session:{session_hash}:{route}:{window}` +- `egress:records:{tenant}:{user}:{date}:{data_class}` +- `egress:files:{tenant}:{user}:{date}` +- `egress:bytes:{tenant}:{user}:{date}` +- `abuse:block:{dimension}:{subject_hash}` +- `download:grant:{jti}` + +Redis 不可用时的策略: + +- production 默认 fail closed 或 degrade strict,即高风险下载、批量下载、AI Trace、Prompt 详情不可用。 +- smoke/local 可 fail open 并记录 warning,方便开发。 + +## 12. Nginx 和 WAF 建议 + +Nginx 不只做全站一个限流区,应按风险分区: + +- `read_low`:普通页面、摘要列表。 +- `read_search`:搜索、法律全文检索、知识库检索。 +- `read_sensitive`:合同详情、模板详情、Prompt 详情、AI Trace 详情。 +- `download_single`:单文件、合同 Word/PDF、审查报告。 +- `download_batch`:批量打包下载。 +- `auth`:登录、验证码、邀请、密码重置。 + +建议: + +- 生产只对外暴露 Contract Web 和 HTTPS edge。 +- Contract API、AI Platform API 绑定内网或 loopback,经 BFF/网关访问。 +- 高风险路径设置更低 `rate` 和更小 `burst`。 +- 下载路径限制并发连接数和最大请求体。 +- 记录 request id、upstream time、status、bytes_sent、user agent 哈希所需原始信号。 +- 阿里云 WAF/Bot Management 可作为 P2 叠加层,拦截已知恶意 IP、代理、异常 UA 和自动化特征。 + +## 13. 下载授权和水印 + +下载流程: + +1. 用户点击下载。 +2. BFF 带可信 session 请求 Contract API 创建 download grant。 +3. Contract API 做 RBAC、对象授权、限流、预算、冻结状态检查。 +4. 通过后生成短时 grant,记录审计。 +5. 实际下载必须携带 grant jti,且一次性使用或短期有效。 +6. 下载完成写入 `bytes_count` 和结果状态。 + +水印: + +- Word/PDF 页脚或末尾加入租户名、用户邮箱脱敏、下载时间、trace id。 +- 审查报告和合同导出默认启用水印。 +- 原始附件如不能修改内容,至少文件名、压缩包清单、下载审计中保留 trace id。 + +## 14. 行为风险评分 + +风险信号: + +- 短时间翻页过快。 +- 合同详情命中率异常高。 +- 下载量或字节数快速增长。 +- 搜索关键字枚举特征明显。 +- 多账号同租户同 IP 或同 UA 轮换访问。 +- 同账号多 IP 快速切换。 +- 访问被隐藏菜单或无 UI 入口的管理 API。 +- 重复触发 429 后继续请求。 +- 夜间或非工作时间大量下载。 + +动作: + +- 低风险:提示或降低速率。 +- 中风险:写 incident、通知管理员、要求重新登录。 +- 高风险:冻结 session 或用户,阻断 S3/S4 接口。 +- 严重风险:冻结租户批量下载、通知超级管理员。 + +管理员需要能查看、解封、临时放宽配额,所有操作写审计。 + +## 15. 分阶段实施计划 + +### P0:公开 SaaS 上线前必须完成 + +1. 建立 endpoint risk policy 注册表,覆盖 Contract API、AI Platform API 和 BFF 高风险路由。 +2. Contract API 和 AI Platform API 增加统一 anti-abuse middleware。 +3. 统一可信身份上下文,生产禁止信任客户端 role。 +4. 接入 Redis 多维限流,支持 user、tenant、session、IP 哈希、route。 +5. 建立数据出站预算,先在 Redis 计量,再落库审计。 +6. 文件、合同、报告、批量下载增加硬限制、预算和安全审计。 +7. 模板、条款、规则、Prompt、知识库 chunk、法律全文列表改为摘要优先,正文走详情。 +8. AI Trace、Prompt、知识库管理接口收紧到 admin/legal 指定角色。 +9. runtime/schema-check 增加反滥用配置检查:Redis、security secret、risk policy、download limits。 +10. Nginx 分风险路径限流。 +11. 安全测试覆盖租户隔离、越权、批量下载、配额阻断和 429。 + +### P1:上线后第一阶段增强 + +1. 服务端 session 表、撤销和并发会话限制。 +2. 数据安全中心管理页。 +3. Word/PDF 水印和下载 trace id。 +4. 安全事件、incident、block、解封流程落库。 +5. 告警接入邮件、企业微信或钉钉。 +6. 租户级安全策略配置。 + +### P2:商业化和高安全客户增强 + +1. 阿里云 WAF/Bot Management。 +2. 设备指纹风险信号。 +3. 高风险导出审批。 +4. 蜜罐资源标识和泄露追踪。 +5. DLP、字段级脱敏策略、客户自定义配额。 + +## 16. 灰度和回滚 + +灰度策略: + +- `ANTI_ABUSE_ENABLED=false|observe|enforce` +- `DATA_EGRESS_BUDGET_ENABLED=false|observe|enforce` +- `DOWNLOAD_GRANT_REQUIRED=false|true` +- `SENSITIVE_LIST_SUMMARY_ONLY=false|true` + +推荐顺序: + +1. 本地和 smoke 开 `observe`,只记录不阻断。 +2. smoke 压测确认 429 比例、误伤率和 Redis 性能。 +3. production 先对下载和 AI Trace 开 enforce。 +4. 再对搜索、详情、Prompt、知识库开 enforce。 + +回滚: + +- middleware 支持环境变量快速退回 observe。 +- Redis 限流异常时可临时关闭高风险接口或切 strict fallback。 +- 数据库迁移只新增表和字段,不影响老业务读写。 +- Nginx 分区限流可单独回滚到旧配置。 + +## 17. 验收测试 + +安全测试: + +- A 租户用户无法访问 B 租户合同、文件、审查结果、AI Trace。 +- 普通用户不能访问 Prompt、AI Trace、全局审计、规则管理详情。 +- 修改 URL/ID 访问他人文件返回 403 或 404。 +- 批量下载超过文件数或字节数返回 429/403。 +- 达到每日预算后详情、下载被阻断,列表摘要仍可用。 +- 客户端传 `role=admin` 不影响后端授权。 +- Redis 不可用时 production 高风险接口按策略阻断。 + +性能测试: + +- 普通列表 p95 < 800ms。 +- 搜索和模板树 p95 < 1200ms。 +- anti-abuse middleware 单请求额外开销 p95 < 30ms。 +- Redis 连接池无耗尽。 +- 30 分钟压测无内存持续上涨。 + +误封测试: + +- 正常法务连续处理 50 份合同不触发冻结。 +- 正常下载 10 个附件不触发冻结。 +- 达到 70%/90% 预算时只提示和告警,不提前阻断。 +- 管理员临时放宽配额后立即生效且有审计。 + +## 18. 与当前上线计划关系 + +当前 V2 可以继续内部 UAT 或受控 smoke 验证,但如果目标是公开多租户 SaaS 或承载真实客户合同数据,不建议只靠现有 Nginx 限流直接上线。 + +生产上线建议门槛: + +- 至少完成 P0。 +- 跑安全扫描、接口压测、Docker smoke、schema-check。 +- 完成租户隔离和越权测试。 +- 下载、搜索、AI Trace、Prompt、知识库路径全部纳入 risk policy。 +- 形成数据安全回归报告。 + +最建议的第一片实现: + +1. `endpoint risk policy` + `anti-abuse middleware`。 +2. Redis 多维限流。 +3. 下载和批量下载出站预算。 +4. Prompt、AI Trace、知识库、模板正文等 S3/S4 详情计量。 +5. runtime/schema-check 增加反滥用配置检查。 + +这片能最快把“可被批量抓取”变成“可计量、可阻断、可审计、可告警”。 + +## 19. P0 第一片落地记录 + +日期:2026-07-22 + +已完成: + +- Contract API 新增 endpoint risk policy 和 anti-abuse middleware。 +- AI Platform API 新增 endpoint risk policy 和 anti-abuse middleware。 +- 支持 `disabled / observe / enforce` 三种模式。 +- 命中高风险接口时返回 `X-Anti-Abuse-Mode`、`X-Anti-Abuse-Decision`、`X-Anti-Abuse-Policy`、`X-Data-Class` 响应头。 +- `enforce` 模式下超过当前策略阈值返回 429,并带 `Retry-After`。 +- 当前第一片使用进程内滑动窗口限流,适合本地、测试和 smoke 验证行为;生产长期稳定运行仍需接 Redis。 +- Contract API runtime config-check 和 schema-check 已输出 `anti_abuse` / `anti_abuse_runtime`。 +- AI Platform schema-check 已输出 `anti_abuse_runtime`,即使 memory repository 模式也会报告该安全门。 +- `.env.example` 和 production compose env 示例已增加反滥用配置。 + +本片覆盖的高风险路径: + +- Contract API:合同列表、合同详情、合同下载、审查报告、文件列表、文件详情、文件预览、文件下载、批量下载、统一搜索、模板树、模板详情、条款规则、法律全文、法律问答、审计路径。 +- AI Platform API:AI Trace 列表/详情、质量动作、Prompt 列表/变更、知识库文档、知识库文档 chunk、知识库搜索、知识库批次文档/chunk、Trace governance。 + +下一片建议: + +- 将 `InMemorySlidingWindowLimiter` 替换为 Redis 多维限流实现,支持 user、tenant、session、IP hash、route 维度。 +- 建立 `security_access_events` 和 `data_egress_ledger`,把当前内存事件落库。 +- 给文件下载、合同 Word/报告下载、批量下载接入文件数和字节预算。 +- BFF 透传可信 user/session 上下文,逐步禁止后端信任客户端传入的 role。 + +## 20. P0 第二片落地记录 + +日期:2026-07-22 + +已完成: + +- Contract API 和 AI Platform API 的 anti-abuse guard 支持 Redis 滑动窗口限流。 +- Redis 不可用或未配置时自动回退到进程内滑动窗口,local/test 不阻断开发;staging/production 的 runtime check 会要求反滥用配置启用并配置 Redis。 +- 两个服务均新增 `security_access_events` 和 `data_egress_ledger` 模型与 Alembic 迁移。 +- middleware 命中高风险路径后会写入安全访问事件;S3/S4 访问会累计每日数据出站账本。 +- Contract API schema-check 已将两张安全表纳入核心表检查。 +- AI Platform schema-check 已将两张安全表纳入核心表和 tenant 列检查。 +- Python 依赖增加 `redis>=5.0`。 + +新增迁移: + +- Contract API:`20260722_0032_add_security_access_events.py` +- AI Platform API:`20260722_0022_add_security_access_events.py` + +当前边界: + +- 本片已经做到“可计量、可审计、可 Redis 限流”。 +- 数据出站预算目前先累计,不直接按每日预算阻断;预算阈值阻断应作为下一片实现。 +- 安全事件查询/管理页还未做,后续可进入“数据安全中心”。 + +下一片建议: + +- 增加每日记录数、文件数、字节数预算检查,超过阈值时返回 429/403。 +- 批量下载增加单次文件数和总字节硬限制。 +- BFF 透传 session id hash,安全事件补 `session_id_hash`。 +- 增加管理端安全事件列表和租户安全策略配置。 + +## 21. P0 第三片落地记录 + +日期:2026-07-23 + +已完成: + +- Contract API 与 AI Platform API 的 `check_egress_budget(...)` 从“只看既有账本是否已达阈值”升级为“既有账本 + 本次请求投影”判断,超过每日预算时返回 403。 +- S3/S4 records 预算开始按 `page_size` / `limit` 估算本次出站记录数,避免分页列表、知识库检索、AI Trace 列表按 1 次请求低估爬取量。 +- Contract API 文件下载、合同 Word 下载、审查报告下载接入 `reserve_egress_budget(...)`,在返回内容前累计每日文件数与字节数。 +- 批量下载保持仓储层单次硬限制: + - `CONTRACT_API_BATCH_DOWNLOAD_MAX_FILES`,默认 20。 + - `CONTRACT_API_BATCH_DOWNLOAD_MAX_BYTES`,默认 100 MiB。 +- 配置示例补齐每日 records/files/bytes 预算和批量下载硬上限: + - Contract API `.env.example` + - AI Platform `.env.example` + - compose `.env.example` + - compose `.env.production.example` +- 回归测试覆盖: + - S4 详情路径达到每日记录预算后返回 403。 + - 文件下载成功后累计 `files_count` 与 `bytes_count`。 + - 文件下载超过每日字节预算返回 403。 + - 批量下载超过单次文件数硬上限返回 409。 + - AI Platform 知识库列表按 `limit` 投影每日记录预算。 + +当前边界: + +- 批量下载的单次硬上限已经在仓储层前置执行;每日预算按生成后的 zip 实际字节数入账。 +- 列表类 records 预算采用请求参数估算,属于偏保守策略:空页也可能计入请求额度,但能更有效阻断枚举式爬取。 +- 下一片建议补“安全事件/出站账本管理页 + 租户级安全策略配置”,让运营可以查看、调整、临时放宽或封禁。 diff --git a/project-docs/V2-product-audit-and-development-backlog-2026-08-01.md b/project-docs/V2-product-audit-and-development-backlog-2026-08-01.md new file mode 100644 index 00000000..ca5096f8 --- /dev/null +++ b/project-docs/V2-product-audit-and-development-backlog-2026-08-01.md @@ -0,0 +1,451 @@ +# 钰麒合同 V2 产品审计与开发 Backlog + +日期:2026-08-01 +角色:产品经理、运营质量负责人、测试复核负责人 + +## 一、审计结论 + +当前 V2 的合同业务骨架已接近企业 UAT 水平,但尚不具备“面向个人、团队与企业的公开 SaaS”发布条件。主要阻断项不是模板或审查能力,而是: + +1. 服务端角色授权没有真正落到合同写接口。 +2. 未登录页面展示管理员导航和模拟业务数据。 +3. 产品承诺包含个人用户,但身份模型只闭环了“公司租户”。 +4. 平台超级管理员并非独立身份,而是依靠默认租户和固定用户 ID 推断。 +5. 真实 AI、内容签批、服务协议和运营交付仍是既有文档明确记录的 No-Go 项。 + +现有实现已经覆盖:30 个模板、模板起草、合同编辑、审批退回重提、异步 AI 审查、结构化风险与引用、条款修复、PDF 审查报告、Word 下载、文件仓库、版本、签署证据、履约和归档。 + +## 二、三类用户模型与关键旅程 + +### 2.1 个人用户 + +目标定义:独立自然人账号,默认拥有一个个人工作空间,不要求提供公司信息。 + +关键旅程: + +1. 注册账号并验证身份。 +2. 系统自动创建个人工作空间。 +3. 选择模板起草,或上传已有合同。 +4. 发起 AI 审阅,查看风险、依据和修改建议。 +5. 下载合同或审阅报告。 +6. 查看个人用量、订阅与历史记录。 + +当前状态:产品定位明确包含个人用户,但没有个人注册或个人空间创建路径。 + +### 2.2 企业租户用户 + +目标定义:组织工作空间中的成员,按管理员、法务、业务、审批人、观察者角色协作。 + +关键旅程: + +1. 平台管理员或自助流程创建组织工作空间。 +2. 组织管理员邀请成员。 +3. 员工通过邀请设置密码并登录。 +4. 业务人员起草或上传合同。 +5. 法务发起审查和修订,审批人处理审批。 +6. 合同完成签署、履约和归档,保留审计证据。 + +当前状态:流程基本闭环,但角色授权主要停留在展示层,登录多空间体验较差。 + +### 2.3 平台超级管理员 + +目标定义:平台级主体,负责租户、套餐、全局安全、AI 质量、运营和交付管理;默认不得参与客户合同流程或查看客户正文。 + +关键旅程: + +1. 安全 bootstrap 或受控邀请创建平台管理员。 +2. 创建、停用和审计工作空间。 +3. 管理 Provider、Prompt、全局知识资产和质量门。 +4. 监控安全事件、容量、失败任务和交付状态。 +5. 通过显式授权和完整审计执行租户支持操作。 + +当前状态:没有显式平台角色;依靠 `default` 租户和固定用户 ID 判断。 + +## 三、P0 问题与验收标准 + +### P0-1 服务端合同角色授权缺失 + +问题:详情和工作台接受客户端 `role` 参数,合同编辑、审批、审查、归档等写接口没有统一按照当前会话用户角色复核。 + +验收标准: + +- 角色必须由服务端根据当前 `tenant_id`、`user_id` 查询,不信任 URL、请求体或公开环境变量。 +- 业务用户直接调用审批、归档、规则发布等接口返回 `403`。 +- 审批人不能编辑合同,观察者不能修改或发起审查。 +- 客户端传入 `role=admin` 不影响服务端权限。 +- 所有读写权限边界都有 API 级回归测试。 + +### P0-2 未登录页面展示管理员工作台 + +问题:前端角色默认 `admin`,无会话时页面请求失败后回退模拟合同和待办,登录只表现为可选入口。 + +验收标准: + +- 未登录访问业务页和管理页统一跳转登录,API 返回 `401`。 +- 登录、注册、密码重置和邀请页使用独立认证布局,不展示业务和后台导航。 +- 实际会话角色驱动导航和页面守卫。 +- 未登录、接口故障、真实空数据三种状态严格分离。 + +### P0-3 真实空待办被替换成模拟待办 + +问题:只要 `pending_tasks` 为空就回退到模拟任务,真实零待办会显示虚假业务数据。 + +验收标准: + +- 合法的零待办显示空状态。 +- 接口超时或失败显示明确错误和重试入口。 +- 模拟数据只能在显式 demo 模式出现,并带清楚的演示标识。 + +### P0-4 平台超级管理员身份不可治理 + +问题:平台管理员由默认租户和固定用户 ID 推断,无法规范支持多人、停用、轮换和审计。 + +验收标准: + +- 增加显式平台权限声明或平台角色。 +- 支持多名平台管理员、停用、凭据轮换和完整审计。 +- 平台权限不依赖固定字符串 ID。 +- 企业管理员不能访问跨工作空间数据和平台级配置。 + +### P0-5 AI 与内容交付不满足公开生产承诺 + +验收标准: + +- 真实 Provider 启用,生产 fallback 关闭。 +- 审查、法律问答、生成和条款推荐黄金样本通过。 +- 高风险模板完成具名法务签批。 +- 引用准确率、无依据响应、幻觉和低置信度结果有明确门禁。 + +### P0-6 公开产品缺法定信任页面 + +验收标准: + +- 上线用户协议、隐私政策、服务协议、数据处理规则和 AI 免责声明。 +- 注册、上传、AI 问答和报告交付可访问相关协议。 +- 用户同意记录包含协议版本、时间和主体。 + +## 四、P1 Backlog + +### P1-1 将租户产品语义统一为工作空间 + +- 普通页面不再出现“租户 ID”。 +- “公司”只用于组织类型工作空间。 +- 平台运维页面可以保留租户技术字段。 + +### P1-2 建立个人工作空间 + +- 个人注册后原子创建账号、个人空间和 owner 成员。 +- 个人用户可以立即起草、上传、审查和下载报告。 +- 个人空间隐藏成员邀请、SSO 和复杂企业治理能力。 + +### P1-3 增加工作空间切换器 + +- 同一账号加入多个空间时无需手填工作空间 ID。 +- 登录后可以查看并切换有权访问的空间。 +- 切换后会话、权限、缓存和导航同步刷新。 + +### P1-4 分离平台后台与企业管理区 + +- 企业管理员只见成员、审批、企业资产和企业审计。 +- 平台管理员才见跨租户、安全、Provider、全局 AI 质量和运行配置。 +- 平台支持进入租户数据时需要显式授权并记录审计。 + +### P1-5 修正身份页和导航 + +- 登录页标题和品牌口径统一为“登录钰麒合同”。 +- 未登录不显示后台导航。 +- SSO 实际登录闭环完成前不得包装为可用能力。 +- 公开注册关闭时提供清晰的申请试用或联系服务入口。 + +### P1-6 收口主流程语义 + +- “起草新合同”进入模板起草,不进入合同台账。 +- 文件上传后提供“创建合同并发起审查”。 +- 去除用户可见的“V1/V2”等工程文案。 +- 合同状态、生命周期、下一步动作使用统一中文口径。 + +### P1-7 AI 结果风险沟通 + +- 问答页和合同审查结果页常驻人工复核提示。 +- 低置信度结果不得使用成功色。 +- 无引用或依据不足时不得包装成可靠结论。 +- 普通用户不能进入平台 AI Trace 管理页。 + +### P1-8 账号恢复与邀请运营 + +- 明确邀请到期、重发、撤销和可信渠道交付。 +- 覆盖员工离职、管理员转移和唯一管理员保护。 +- 无邮箱模式提供明确账号支持 SLA。 + +## 五、P2 Backlog + +- 套餐、配额与用量:个人、试用、团队、企业套餐,审查量、存储、成员、报告水印和超限提示。 +- 运营与客户成功:试点周期、负责人、活跃用户、起草/审查/报告转化、失败任务和续费风险。 +- 通知中心:审批、审查、履约、邀请和安全提醒形成可到达的入口与已读闭环。 +- 文档治理:功能状态、RC 结论、测试证据和运行环境保持一致,废止陈旧结论。 + +## 六、个人工作空间兼容方案 + +### 6.1 第一阶段:保持现有 tenant_id 边界 + +将现有 `TenantModel` 解释为通用工作空间,在现有 `settings` 或后续显式字段中加入: + +```text +workspace_type: system | personal | organization +owner_user_id: string | null +legal_name: string | null +``` + +迁移规则: + +- 现有客户租户默认标记为 `organization`。 +- `default` 标记为 `system`。 +- 个人注册创建 `personal` 空间。 +- 继续复用合同、文件、审查、知识库和审计表中的 `tenant_id`,不重写业务外键。 +- 个人空间中的 `admin` 在产品上显示为“所有者”。 + +### 6.2 第二阶段:全局账号与成员关系 + +引入: + +- `accounts`:全局唯一账号和凭据。 +- `workspace_memberships`:账号与工作空间的成员关系、角色和状态。 +- 现有 `tenant_users` 通过 `account_id` 兼容迁移,或逐步演化为 membership 表。 + +目标:解决同一邮箱在多个租户中拥有多套凭据、登录时必须输入空间 ID、密码策略不一致等问题。 + +## 七、回归测试清单 + +### 身份与会话 + +- 未登录访问全部业务与管理路由。 +- 个人注册、企业邀请、密码设置和登录。 +- 多空间查看和切换。 +- 停用用户、停用空间、邀请过期和管理员转移。 +- 平台管理员 bootstrap、停用和轮换。 + +### 权限与隔离 + +- 五类企业角色逐项验证读取、编辑、上传、审查、审批、下载、归档和规则发布。 +- 同时覆盖 UI、同源代理和直接 API 绕过。 +- 个人与企业、两个企业、平台后台三组交叉访问合同、文件、报告、搜索、审计和 AI Trace。 + +### 模板与合同 + +- 30 个模板全量生成。 +- 必填项、可选项、特殊字符、长文本和重复提交。 +- 草稿、审查、提交审批、退回、重提、签署、履约和归档的合法与非法状态转换。 + +### AI 审查与报告 + +- 上传后建合同、排队、运行、失败和重试。 +- 结构化风险、引用、条款修复、报告生成下载及旧版本一致性。 +- 无依据、低置信度、Provider 不可用和 fallback 禁用场景。 + +### 空态与故障 + +- 零合同、零待办、零文件和零通知。 +- API `401/403/500`、超时和服务不可用。 +- 任何故障都不得出现模拟业务数据或虚假成功。 + +### 运营交付 + +- 试用额度、报告水印和超限行为。 +- 协议版本、用户同意记录、数据导出和删除。 +- 客服升级、人工复核和交付审计证据。 + +## 八、开发执行顺序 + +1. P0 服务端授权与未登录边界。 +2. 移除隐式模拟数据和客户端角色来源。 +3. 引入工作空间类型和显式平台权限。 +4. 打通个人工作空间注册与多空间会话。 +5. 收口导航、身份文案和主流程入口。 +6. 补齐协议、运营、套餐和客户成功能力。 +7. 运行全量回归、真实 Provider 质量验收和生产发布门禁。 + +## 九、自动化开发阶段状态(2026-08-01) + +本轮已启动三个智能 Agent,分别负责服务端授权、前端身份边界和个人工作空间后端;随后追加个人/组织注册前端接入。所有改动保留在当前工作树,尚未提交、部署或应用到生产数据库。 + +### 9.1 已完成 + +- **P0-1 服务端角色授权**:新增统一 capability 授权层。代理认证开启时,角色只从当前 `tenant_id`、`user_id` 对应的活跃成员关系解析;客户端 `role=admin` 不再影响权限。合同、审批、审查、报告、下载、文件、归档、签署、履约、备份、模板、法务资产和审批规则接口已接入授权守卫。 +- **P0-2 未登录边界**:Next.js 请求代理验签会话 cookie;未登录访问业务路由跳转 `/login`,无权访问管理路由跳转 `/workspace`。认证页、邀请页、密码重置页和政策页使用独立外壳,不再显示业务后台导航。 +- **P0-3 隐式模拟数据**:合同列表、合同详情和工作台待办已移除生产路径中的模拟回退;合法零待办返回空列表,接口失败抛出错误,不再显示虚假合同或待办。 +- **P0-4 显式平台权限第一阶段**:`tenant_users.platform_permissions` 支持显式 `platform_admin`,运行时判断不再比较固定用户 ID。历史 `default/user-current-admin` 仅在迁移和 bootstrap 中用于给既有记录回填显式权限。 +- **P1-1/P1-2 工作空间与个人注册第一阶段**:保留全部 `tenant_id` 外键,新增 `workspace_type`、`owner_user_id`、`legal_name`;个人注册原子创建 `personal` 工作空间、owner/admin 成员、账户设置和密码凭据;组织注册继续创建 `organization` 工作空间。 +- **个人/组织注册入口**:注册页默认提供个人模式,并可切换组织模式;个人模式不要求公司名称或技术空间 ID。成功注册复用现有签名会话进入工作台。 +- **P1-5/P1-6/P1-7 部分项**:导航使用会话角色;“起草新合同”进入模板起草;用户可见的 V1/V2 工程文案已移除;法律问答和合同审查增加人工复核边界;低、中、高置信度使用警告、主色和成功色分级。 +- **法定信任页面载体**:已增加用户协议、隐私政策、服务协议和 AI 使用说明入口,并明确标注为产品草案、正式发布前需具名法务审核。 + +### 9.2 验证证据 + +- Contract API 按四个隔离数据库分片执行 182 个测试用例;发现 2 个本地兼容模式回归后已修复,相关防滥用、代理认证和越权测试 16 项复跑通过。 +- 服务端越权测试覆盖业务用户审批/归档、审批人编辑、观察者上传/审查以及 `role=admin` 伪造,均返回预期 `403`;缺少身份上下文返回 `401`。 +- 身份定向测试覆盖个人空间 owner、密码登录、企业/个人同邮箱多空间、重复注册冲突、事务回滚和显式平台权限。 +- AI Platform API 全量 93 项测试通过,包含新增生产发布就绪门禁测试。 +- `npm run web:typecheck` 通过;`npm run web:build` 通过,共生成 94 个路由并识别 Next.js Proxy。 +- Playwright 真实浏览器验证:未登录 `/workspace` 跳转 `/login`;登录页无业务侧栏且含四类政策链接;注册页个人/组织分段可切换;政策页显示草案和法务审核状态。 +- 新版 Contract API 临时实例完成一次真实个人注册和指定工作空间登录:返回 `workspace_type=personal`、owner 与用户一致、成员角色为 `admin`、认证方式为 `password`。 +- 迁移 `20260801_0033` 已由开发 Agent 在全新 SQLite 上完成 upgrade、downgrade、re-upgrade 验证。 + +### 9.3 仍未完成或不得作为上线完成项 + +- **P0-5** 的真实 AI Provider 单场景联网验收已通过:当前 Codex Provider/模型在隔离内存实例中完成法律问答端到端运行,未使用 fallback,质量与引用门禁均通过。该运行尚未持久化到共享或生产数据库,且审查、生成、条款推荐等完整黄金套件仍待执行,因此不得据此将 P0-5 整体标记为生产交付完成。 +- 协议版本与同意记录已持久化,但四份协议仍是产品草案,尚未经过具名法务审核和正式生效;草案留痕不得被解释为正式法律文本已发布。 +- 平台管理员的多人开通、停用、轮换和支持访问审计仍需补齐运营管理入口;当前完成的是显式权限数据和运行时识别。 +- 多工作空间兼容层与可视化切换器已完成,登录页不再要求填写工作空间 ID;但当前仍复用各 `tenant_user` 的历史凭据进行候选校验,尚未完成统一全局凭据、已验证邮箱归并和账号合并/拆分治理,因此不得把兼容层解释为最终账号体系。 +- 个人空间的套餐、配额、用量、支付、成员功能隐藏和数据退出仍属于后续 P1/P2。 +- 本地验收预览已使用隔离 SQLite 在 `8100` 重启当前 Contract API,并在 `3011` 启动当前 Web;这不代表生产或共享数据库已迁移。生产/共享环境启用前必须备份数据库、依次执行 Alembic `0033`、`0034` 与 `0035`、配置代理认证并重启服务。 + +### 9.4 第二阶段已完成 + +- **AI 生产发布门禁**:新增 `GET /api/v1/quality/release-readiness` 和可供 CI 调用的 `python -m yuqei_ai_platform_api.release_readiness_gate`。生产环境必须启用非占位 Provider、可解析 URL 与凭据、关闭 fallback、通过黄金资产和引用策略检查,并提供指定的真实 `acceptance_run_id`。离线黄金资产结果明确标记为不验证 Provider 输出。 +- **协议版本与同意留痕**:新增公开协议目录和受控同意记录查询接口。个人与组织注册必须逐项提交服务端目录中的当前精确版本;缺失、重复、额外或客户端自定义版本均被拒绝。 +- **注册事务一致性**:工作空间、owner/admin、账户、凭据和四条同意记录在同一事务中创建,任一写入失败整体回滚。`20260801_0034` 新增 `identity_agreement_consents`,记录工作空间、用户、协议类型、版本、服务端同意时间、注册来源、客户端 IP 和 User-Agent。 +- **注册前端闭环**:个人与组织模式均从同源协议目录加载必签项,逐项展示政策链接、精确版本和“草案未生效”状态;未全部勾选时提交禁用,模式切换清空已有同意。 +- **扩展角色权限矩阵**:补充法务备份、内部 review worker、业务用户管理审批规则、审批人创建合同、观察者发布模板等直接 API 越权场景,均要求返回 `403`。 + +### 9.5 第二阶段验证证据 + +- Contract API 当前完整 185 项按四个隔离数据库分片通过:合同工作流 78 项、核心治理 36 项、领域模块 40 项、身份/权限/迁移 31 项。 +- Contract API 身份管理 9 项通过;身份安全、代理权限和角色授权组合 21 项通过;`0034` 升级/降级自动化测试 1 项通过,共 31 项定向回归通过。 +- `0034` 在完整 SQLite 迁移链执行 upgrade、downgrade、re-upgrade 通过;表结构、唯一约束及新增请求来源字段均有断言。 +- AI Platform API 全量 93 项通过;生产默认 mock/fallback 配置被门禁判定为 `blocked`,CLI 返回退出码 `1`。 +- Web 类型检查和生产构建通过,构建包含 `/api/auth/agreements/current`、个人注册和组织注册路由,共生成 94 个路由。 +- 隔离实例真实注册验证返回 `workspace_type=personal`、角色 `admin`、4 条同意记录;版本、注册来源、服务端采集的 IP 和 User-Agent 均与请求上下文一致。 +- Playwright 在无网络 mock 的本地预览验证:四项协议和政策链接可见;未勾选时提交禁用;全部勾选后启用;切换到组织模式后全部恢复未选;稳定页面控制台无错误。 + +### 9.6 下一阶段优先项 + +1. 将真实 Provider 验收扩展到审查、生成、条款推荐等完整黄金套件,并在共享预发布环境持久化通过的审计运行 ID;单场景隔离验收不得替代完整发布证据。 +2. 完成四份政策的具名法务审核、正式版本号、生效时间、历史版本查询和再次同意策略。 +3. 在现有全局账号兼容层上收口统一凭据、已验证邮箱归并、账号合并/拆分和成员关系恢复流程,避免仅凭相同邮箱长期承担跨空间身份绑定语义。 +4. 补齐平台管理员多人治理、支持访问授权审计,以及个人套餐、配额、用量和数据退出能力。 + +### 9.7 真实 AI Provider 联网验收证据(2026-08-01) + +- 参数来源:仅在单次验收子进程中读取 Codex 当前 Provider 配置和凭据;Provider、模型和端点按运行时配置解析,不作为固定产品参数写入仓库、`.env`、数据库或日志。验收期间未输出任何密钥值。 +- 协议兼容性:当前 Provider 的 `/v1/responses` 与 `/v1/chat/completions` 均完成真实联网调用;项目现有 Chat Completions 适配器可正常调用当前 Provider,无需为本次验收改写协议。 +- 端到端用例:向隔离内存知识库加入《中华人民共和国劳动合同法》第十九条和第二十条,询问三年以上固定期限劳动合同的试用期上限及试用期工资下限。模型准确返回“六个月”“80%(百分之八十)”和“当地最低工资标准”,并正确列出对应法条。 +- 审计证据:运行 ID `run-06172b83f530`;Provider `ccswitch`;模型 `gpt-5.6-sol`;Provider 与运行状态均为 `succeeded`;fallback 未使用;响应引用 4 条;检索来源 2 个;总 token 4855;延迟 6258 ms;质量状态 `passed`,质量分 100。 +- 门禁结果:以该运行 ID 调用生产规则 `GET /api/v1/quality/release-readiness`,返回 `ready`、`release_allowed=true`、`provider_acceptance_verified=true`,无失败检查。 +- 证据边界:该运行 ID 属于验收进程内的隔离内存仓库,进程退出后不会出现在共享或生产数据库。它证明当前动态 Provider 参数、项目适配器、检索引用、质量评估和发布门禁能够端到端工作,但不构成生产部署、生产数据迁移、完整黄金套件通过或具名法务签批的证据。 + +### 9.8 第三阶段自动化开发结果(2026-08-01) + +- **全局账号兼容层**:新增 `identity_accounts` 与 `workspace_memberships`,保持合同、文件、审查和审计等业务表继续使用 `tenant_id`。同一标准化邮箱可关联个人与组织工作空间,每个成员关系保留目标空间中的 `tenant_user_id`、角色、状态和 owner 标记。 +- **迁移与旧数据回填**:新增 `20260801_0035`,从现有有效 `tenants` 与 `tenant_users` 回填全局账号和成员关系,不读取或搬运密码凭据;迁移支持 upgrade、downgrade、re-upgrade,并断言同邮箱只生成一个全局账号及多个空间成员关系。 +- **登录与空间切换**:登录页只提交邮箱与密码,不再暴露工作空间 ID。服务端在同邮箱多空间候选中校验实际匹配凭据后建立会话;登录后通过 `GET /api/v1/identity/workspaces` 获取有效成员空间,通过 `POST /api/v1/identity/workspaces/switch` 切换。目标空间必须属于同一有效全局账号,且工作空间、成员和用户均为有效状态,否则统一返回 `403`。 +- **前端会话闭环**:新增同源 workspace 列表和切换代理,均要求有效签名会话;切换成功后重新签发包含目标 `tenant_id`、`user_id` 和角色的前端会话。顶栏单空间保持紧凑展示,多空间显示选择器;失败时保留当前会话和当前空间。 +- **Provider 验收工具加固**:`run-ai-provider-acceptance.py` 默认将证据写入 `.runtime`,持久化字段限制为 Provider、模型、状态、计数和问题代码;不写入完整模型响应、合同内容、问题文本、引用 ID、审计 ID、tenant 或法务姓名。工具同时限制 URL、header、密钥文件大小、超时、用例数量和响应体,并显式使用 UTF-8。 +- **离线完整用例准备**:隔离 HTTP 服务已跑通 Phase13 的 10 条法律问答与 8 条合同审查,18/18 技术通过,法律状态均保持 `needs_legal_review`;该结果仅验证验收工具和样本编排,不代表真实 Provider 完整黄金套件通过。 + +### 9.9 第三阶段验证证据 + +- Contract API 186 项按四个独立 SQLite 分片全部通过:合同工作流 92 项、核心治理 45 项、领域模块 21 项、身份/权限/迁移 28 项。 +- 身份定向覆盖:同邮箱个人/组织空间归并、免工作空间 ID 的凭据匹配登录、空间列表、合法切换、跨账号/系统空间拒绝、成员状态过滤、注册事务回滚,以及 `0035` 升级/降级/再升级与旧数据回填。 +- Web 类型检查和生产构建通过,共生成 96 个路由,新增 `/api/auth/workspaces` 与 `/api/auth/workspaces/switch`;切换器状态测试 2/2 通过。 +- Provider 验收及 Bandit 相关测试 7 项通过;中文请求 UTF-8、证据脱敏、参数安全和 18 条隔离黄金用例均有自动化断言。 +- `git diff --check` 通过,仅存在 Windows 工作树 LF/CRLF 转换提示;未提交、未部署、未执行共享或生产数据库迁移。 + +### 9.10 第四阶段自动化开发结果(2026-08-02) + +- **统一全局凭据与邮箱验证**:新增迁移 `20260802_0036`、`identity_account_credentials` 和 `identity_accounts.email_verified_at`。一个全局账号最多保留一条全局密码凭据;同邮箱使用不同密码重复注册返回 `409` 并完整回滚,同一密码可继续创建或关联个人、组织工作空间。 +- **旧账号渐进兼容**:仅对“单成员、单凭据”的无歧义旧账号回填全局密码;历史 `tenant_user_credentials.email_verified_at` 不直接提升为全局邮箱验证。多空间、多凭据旧账号继续走兼容登录,完成邮箱验证或密码重置后再归并,避免迁移阶段错误合并账号。 +- **跨空间安全边界**:未验证全局邮箱只允许访问当前工作空间;验证后才开放同账号的有效工作空间列表与切换。全局密码登录只选择 `active` 用户,邀请未接受成员不能提前登录。密码重置旋转唯一全局密码后,新密码可登录全部成员空间,旧密码失效。 +- **动态 Provider 协议兼容**:AI Platform 新增 `responses` 与 `chat_completions` 可配置 wire API。Responses 适配支持 `/responses`、`output_text`/`output` 以及 `input_tokens`/`output_tokens`,HTTP 错误信息执行脱敏;当前参数仍从 Codex 运行时动态读取,不固定写入产品配置。 +- **多工作空间浏览器闭环**:个人/组织注册、四份协议、免工作空间 ID 登录、同邮箱候选凭据匹配、顶栏空间切换、非法切换、退出和并行会话隔离均通过真实浏览器验证。前端增加 `127.0.0.1` 开发资源允许来源,并将登录 `401/429` 稳定映射为不泄露账号状态的中文提示。 + +### 9.11 第四阶段真实 Provider 与回归证据 + +- 当前动态 Provider 摘要为 `ccswitch / gpt-5.6-sol / responses`。Key 只注入 `8123` 隔离内存进程,验收后进程和一次性服务密钥均已清理;安全扫描中 Provider Key、真实网关 URL、运行时 Authorization 均为 0 命中。 +- 真实法律问答和合同审查各执行 1 条,外部调用、运行审计和 Provider 状态均为 `succeeded`,无 Provider 错误,共计 3357 tokens。这证明当前鉴权、Responses 协议、模型和联网链路可用。 +- 黄金产品门禁结果为 `failed`、技术门禁 `0/2`:两条均出现 `expected_source_not_retrieved`,合同审查另有 `missing_citations` 与 `severity_below_expected`。根因是隔离内存知识文档使用随机 `doc-*`,未保留清单要求的 `LAW-*` 稳定 source ID;同时合同审查结构化 `risk_items` 仍由规则函数生成,Provider 目前只替换摘要。该结果不得表述为完整真实 Provider 验收通过。 +- 脱敏证据位于 `.runtime/v2-ai-provider-acceptance/2026-08-02-codex-current-full/ai-provider-acceptance-summary.json`,仅保留 Provider、模型、状态、计数和问题代码。 +- Contract API 最终 `186/186` 通过:工作流 97、治理 52、领域 5、身份/权限/迁移 32。`0036` 已在隔离 SQLite 完成 upgrade、downgrade、re-upgrade;未在共享或生产数据库执行迁移。 +- AI Platform API `94/94` 通过;Provider/发布工具定向 `7/7` 通过。生产预检仍因仓库示例配置包含 `placeholders` 而失败,这是有效发布阻断项,不能绕过。 +- Web workspace 状态测试 `2/2`、TypeScript 类型检查、生产构建均通过,共生成 96 个路由。重启后的 Playwright 复核显示登录页和注册页 `ready=complete`、注册策略已加载、控制台 0 error。 +- 本地服务已使用当前代码重启:Contract API `http://127.0.0.1:8110`、AI Platform `http://127.0.0.1:8111`、Web `http://127.0.0.1:3021/login`,均返回 200。后端 schema-check 的 `warning` 来自本地 SQLite、内存仓库和 Provider disabled,符合隔离开发运行边界。 + +### 9.12 下一阶段优先项(2026-08-02 更新) + +1. **P0 AI 黄金门禁**:为 Phase13 知识资产建立稳定 `LAW-*` source ID 装载;让 Provider 结构化输出参与合同审查 `risk_items`、严重度和引用生成;先通过 10 条法律问答与 8 条合同审查,再扩展合同生成和条款推荐,最后在共享预发布环境持久化验收运行 ID。 +2. **P1 账号治理**:增加账号合并、拆分、争议恢复、成员关系恢复的运营 API/UI;历史多密码账号需要明确的邮箱验证/密码重置引导;正式定义全局登录邮箱变更,避免与工作空间 `AccountSettings.email` 混淆。 +3. **P1 邀请与恢复**:已有全局账号接受邀请时要求已认证会话或显式恢复确认,避免邀请 token 被解释为无条件密码轮换授权。 +4. **P1 发布与法务**:完成四份政策的具名法务审核、正式版本、生效时间、历史版本和再次同意策略;清除生产示例占位符后重新执行严格预检。 +5. **P2 运营能力**:补齐平台管理员多人治理、支持访问审计、个人套餐/配额/用量/支付、成员功能隐藏和数据退出。 + +### 9.13 第五阶段自动化开发结果(2026-08-02) + +- **P0 AI 黄金技术门禁通过**:`LegalCitation` 从知识文档 `reference` 提取稳定 `LAW-*` 外部 source ID,memory/SQLite 内部仍使用原有随机 `doc-*` 主键,不改表、不迁移。Phase13 的 10 张知识卡以幂等方式装载到固定 `phase13-golden` 知识库,并明确标注为待法务复核材料、不是法条原文或法律批准。 +- **Provider 结构化合同审查**:合同审查提示词要求严格 JSON;服务端以 `extra=forbid` 模型校验摘要、总体风险、风险项、严重度、建议和 `citation_source_ids`。每个风险项至少引用一个本次实际检索 source ID,总体风险必须等于最高风险项严重度;自由文本、未知引用和严重度不一致均不进入业务风险对象,而是按现有 fallback/failed 策略处理并留痕。 +- **普通用户自助邮箱验证闭环**:普通有效成员只能为当前会话邮箱和当前工作空间发送、确认验证码;越权确认在消费验证码前被拒绝,不消耗失败次数。租户管理员保留本工作空间成员管理能力,平台管理员保留全局能力。确认成功返回刷新后的全局账号会话。 +- **Web 邮箱验证与会话刷新**:新增 `/verify-email`、`/api/auth/email-verification/send` 和 `/api/auth/email-verification/confirm`。同源代理强制使用签名会话中的邮箱,只有返回会话与当前工作空间、用户完全一致时才重写 cookie。所有登录、注册、邀请、密码重置和工作空间切换入口统一携带 `email_verified_at`;旧 cookie 兼容为未验证状态。 +- **账号设置权威边界收口**:个人设置 PATCH 只接受显示名、语言、时区、通知和偏好;`tenant_id`、`user_id`、登录邮箱、成员角色和审计操作者均由服务端上下文决定,客户端越权字段返回 `422`。读取设置时以全局账号邮箱和当前成员角色覆盖设置表中的历史副本,保持现有表结构兼容,不新增迁移。 +- **密码与邀请语义**:组织注册、邀请接受和密码重置统一要求 12-128 位密码;密码重置不再要求技术工作空间 ID。已有全局账号接受邀请时必须输入当前密码,不能借邀请 token 轮换全局凭据;邀请页同步区分“新用户设置密码”和“已有账号输入当前密码”,并提供密码重置入口。 + +### 9.14 第五阶段验证证据 + +- Contract API 使用全新隔离 SQLite 执行全量 `189/189` 通过;账号设置定向回归中身份管理 `11/11`、代理权限 `6/6` 通过。未执行共享或生产数据库迁移。 +- AI Platform API 全量 `99/99` 通过;Provider/发布工具全量 `13/13` 通过。Phase13 专项覆盖 memory/SQLite source ID 兼容、虚构引用拒绝、结构化输出失败策略和 Provider URL 日志脱敏。 +- 当前动态 Codex Provider 为 `ccswitch / gpt-5.6-sol / responses`。隔离内存实例执行法律问答 10 条、合同审查 8 条,技术门禁 `18/18`、问题数 `0`;状态为 `needs_legal_review`,法律批准 `0/18`。脱敏摘要位于 `.runtime/v2-ai-provider-acceptance/2026-08-02-phase13-structured-full/ai-provider-acceptance-summary.json`,不包含密钥、真实端点、完整提示词或完整响应。 +- Web 会话映射测试 `2/2`、工作空间状态测试 `2/2`、TypeScript 类型检查和生产构建通过;构建生成 99 个页面/路由,包含新的邮箱验证页面和代理入口。 +- Provider 验收临时端口和一次性服务密钥已清理;Provider Key 与真实 Provider URL 在修改代码和验收证据中均为 0 命中。常驻开发 AI 服务继续使用 Provider disabled,避免把真实凭据持久化到开发运行态。 +- 最终运行态已重启:Contract API `http://127.0.0.1:8110`、AI Platform `http://127.0.0.1:8111`、Web `http://127.0.0.1:3021/login` 均返回 200。 + +### 9.15 后续优先项(取代 9.12 的当前执行顺序) + +1. **P0 法律与预发布交付**:由具名法务复核 10 张知识卡及 18 条黄金结果,建立权威法条原文库、正式版本和生效记录;在共享预发布数据库持久化通过的验收运行 ID。当前隔离 memory 证据只能证明技术链路,不代表法律正确性或生产发布批准。 +2. **P1 邮件与账号恢复运营**:接入真实 SMTP/邮件服务并执行浏览器端到端验收;定义全局登录邮箱变更的旧邮箱确认、新邮箱验证、冲突检查和恢复流程。当前仅完成开发挑战码与 API/UI 闭环,尚未验收真实邮件投递。 +3. **P1 账号争议治理**:先建设 platform-only 只读冲突诊断列表和详情,再设计带 preview、幂等 operation ID、双人审批和完整审计的账号合并、拆分及成员关系恢复;不得直接以破坏性写接口起步。 +4. **P1 平台管理员治理**:增加平台管理员只读名册和“不得停用最后一名有效平台管理员”保护;随后补充授予/撤销、支持访问时限与原因、双人审批和专用 before/after 审计。 +5. **P2 运营能力**:补齐个人套餐、配额、用量、支付、成员功能隐藏、数据导出/删除和客户成功指标;四份公开政策仍需具名法务审核、正式版本、生效时间、历史版本与再次同意策略。 + +### 9.16 第六阶段自动化开发结果(2026-08-02) + +- **平台管理员只读名册**:新增 `GET /api/v1/identity/platform-administrators`,只返回管理员身份、工作空间、状态和最近登录等非凭据信息。后端以显式 `platform_admin` 权限为唯一跨空间边界;企业工作空间管理员返回 `403`,不能依赖固定用户 ID 或前端角色判断。 +- **最后有效管理员保护**:内存和 SQL 仓储均在成员状态更新时阻止停用最后一名有效平台管理员,API 返回 `409`。SQL 路径在同一事务内使用 `SELECT ... FOR UPDATE` 锁定有效管理员集合;Web 对该冲突显示中文运营提示。两名管理员时可停用其中一名,停用后其平台权限立即失效,独立凭据轮换不影响另一名管理员。 +- **平台专属账号冲突诊断**:新增 `GET /api/v1/identity/account-conflicts` 和详情路由。诊断复用现有全局账号、工作空间成员、租户用户和凭据表,不新增迁移、不写身份数据;支持严重度、冲突码、精确规范化邮箱和分页筛选。稳定 `diagnostic_id` 只由实体 ID 哈希生成,不把邮箱写入 URL 资源 ID。 +- **冲突分类与最小披露**:首期覆盖重复全局账号、缺失/孤立/重复成员关系、身份或状态漂移、所有者漂移、停用账号仍有有效成员、凭据身份/算法异常、有效身份无凭据和历史多凭据未归并。响应仅包含凭据作用域、状态、算法、更新时间和是否需要重置,禁止返回密码哈希、token、验证码或会话数据。相关访问纳入 `identity.account_conflicts`、`S3`、`sensitive=true` 的反滥用与访问留痕策略。 +- **Web 运营入口**:新增 `/admin/identity/conflicts` 只读列表与详情工具页。只有平台名册请求成功时,身份管理页才显示诊断入口;企业管理员直接访问时不渲染跨空间统计或邮箱。页面提供严重度、冲突类型和精确邮箱筛选,不提供合并、拆分或恢复写操作。 +- **工作空间语义收口**:身份管理、管理首页和侧栏的面向用户文案从“用户与租户/公司租户”改为“用户与工作空间/组织工作空间”;技术 `tenant_id` 继续保留在内部 API 和平台治理详情,兼容现有数据模型和个人工作空间方案。 + +### 9.17 第六阶段验证证据 + +- 平台管理员治理定向用例 `8/8`、身份管理/安全/代理鉴权组合回归 `31/31` 通过;加入账号冲突诊断后,身份域组合回归为 `35/35`。 +- Contract API 从仓库根目录首次执行全量时为 `191/193`,两项失败均因 Alembic 相对路径把 `migrations` 解析到仓库根目录,属于测试命令工作目录错误,不是业务断言失败。随后从 `services/contract-api` 目录、使用全新隔离 SQLite `.test-runtime/contract-full-phase6-20260802-1332.sqlite3` 重新执行,最终 `195/195` 通过,退出码为 0。未执行共享或生产数据库迁移。 +- 账号冲突专项 `4/4` 通过:健康身份零误报;平台管理员 `200`、企业管理员 `403`、缺代理凭据 `401`;重复账号、状态/所有者漂移、异常算法和缺失成员关系可稳定定位;未知详情 `404`、`page_size=101` 为 `422`;响应递归扫描不含密码哈希、token 或验证码字段。 +- Web 会话映射 `2/2`、工作空间状态 `2/2`、TypeScript 类型检查和生产构建通过;构建生成 100 个页面/路由,包含 `/admin/identity/conflicts`。 +- `git diff --check` 通过,仅有工作区既有 CRLF 提示。Contract API、AI Platform 和 Web 已重启并分别在 `8110`、`8111`、`3021` 返回 200。 +- 重启前在已登录 Codex 内置浏览器检查 `/legal-documents`、`/workspace`、`/verify-email`、`/admin/identity` 和 `/admin`,数据加载成功且控制台无 error;发现已登录访问 `/login` 仍显示登录表单,以及平台管理界面仍存在“租户”历史文案。后者本阶段已收口。Web 运行脚本每次启动随机生成会话签名密钥,因此重启后既有 cookie 失效;新诊断页的登录态可视化复核需用户重新登录后继续,不能把未完成的浏览器复核写成通过。 + +### 9.18 后续优先项(取代 9.15 的当前执行顺序) + +1. **P0 法律与预发布交付**:仍需具名法务复核 10 张知识卡和 18 条黄金结果,并在共享预发布环境持久化批准记录;当前技术门禁不代表法律正确性或生产批准。 +2. **P1 邮件与账号恢复运营**:接入真实 SMTP,完成发送、退信、限流、旧邮箱确认、新邮箱验证、冲突检查和恢复流程的浏览器端到端验收。 +3. **P1 账号争议写操作**:以本阶段只读诊断为入口,设计账号合并、拆分和成员恢复的 preview、幂等 operation ID、双人审批、回滚策略和完整 before/after 审计;不得直接增加破坏性写接口。SQL 诊断当前按需聚合全表,进入大规模预发布前应增加候选集查询和性能基线。 +4. **P1 平台管理员完整治理**:补充受控授予/撤销、支持访问原因和时限、双人审批及专用审计;最后管理员并发保护仍需真实 PostgreSQL 双请求集成验收。 +5. **P1 产品语义与会话体验**:将开发环境的“默认租户”种子名称改为系统工作空间口径;明确已登录访问 `/login` 时是重定向工作台还是提供显式“切换账号”,避免当前重复登录表单语义。 +6. **P2 运营与测试效率**:补齐个人套餐、配额、用量、支付、数据导出/删除和客户成功指标;在不降低密码哈希参数的前提下并行化身份测试夹具,缩短当前约 6 分钟的 Contract API 全量回归。 + +### 9.19 平台管理员登录态浏览器实测与修复复核(2026-08-02) + +- 使用已登录平台管理员 `admin@example.com` 的 Codex 内置浏览器会话实测,`/workspace`、`/admin/identity`、`/admin/identity/conflicts` 和 `/verify-email` 均正常加载;本轮未伪造会话、未绕过服务端权限,也未执行账号合并、拆分、恢复或其他争议身份写操作。 +- 身份管理页的平台管理员名册显示共 `1` 人、有效 `1` 人,平台专属账号冲突诊断入口可见;这与当前单管理员开发数据一致,但不替代多人治理、授权审批或并发保护验收。 +- 账号冲突列表显示严重冲突 `0`、提醒 `2`。精确邮箱筛选以包含大写字母和首尾空格的输入执行,服务端按既有规则规范化后只返回目标项;切换到“严重”筛选时显示明确的零结果空状态,没有把提醒误计为严重冲突。 +- 冲突详情可查看全局账号、租户用户、工作空间成员和凭据元数据。对页面返回对象执行递归字段扫描,未发现 `password_hash`、`credential_hash`、`verification_code`、`access_token` 或 `refresh_token`;该证据证明当前页面和 API 响应满足本阶段最小披露要求,不代表底层数据库不存在受控凭据字段。 +- 全程浏览器控制台 error 和 warning 均为 `0`,仅出现开发环境 HMR 信息;未观察到页面加载、筛选、详情请求或权限请求错误。 +- 首次实测发现三项产品质量问题:顶栏和管理员名册仍显示“默认租户”、组织注册按钮仍写“创建公司”、管理员初始密码被浏览器自动填入。另一开发 Agent 已将前两项统一为“系统工作空间”和“创建组织工作空间”,并将密码输入保持空值、设置为 `new-password` 自动填充语义,避免展示或复用已有凭据。 +- 已登录访问 `/login` 的语义确定为自动返回 `/workspace`。真实浏览器复核确认最终 URL 为 `/workspace`、工作台标题存在且登录表单不再渲染,控制台 error/warning 为 `0`;未登录 `/login` 及注册、密码重置、邀请和政策页继续保持公开访问。 +- 修复后 Web 路由测试 `4/4`、工作空间状态测试 `3/3`、身份页敏感默认值源码测试 `1/1`、会话映射测试 `2/2` 均通过,TypeScript 类型检查通过;浏览器刷新复核确认“系统工作空间”“创建组织工作空间”、密码空值和登录重定向均已生效。该复核只覆盖当前开发登录态和可见交互,不等同于真实 SMTP、共享预发布数据库或生产环境验收。 + +### 9.20 后续优先项(取代 9.18 的当前执行顺序) + +1. **P0 法律签批与共享预发布交付**:仍需具名法务复核 10 张知识卡和 18 条黄金结果,建立权威法条原文、正式版本、生效记录和批准责任人,并在共享预发布环境持久化验收运行 ID。当前技术门禁和浏览器实测均不能替代法律正确性确认或生产发布批准。 +2. **P1 真实邮件与账号恢复运营**:接入真实 SMTP/邮件服务,完成发送、退信、重试、限流、旧邮箱确认、新邮箱验证、冲突检查和恢复流程的浏览器端到端验收;当前开发挑战码闭环不得表述为真实邮件交付完成。 +3. **P1 账号争议写操作设计**:以已通过登录态实测的只读冲突列表和详情为入口,设计账号合并、拆分及成员关系恢复的 preview、幂等 operation ID、双人审批、回滚策略和完整 before/after 审计;在方案和验收标准明确前不得直接增加破坏性写接口。SQL 诊断进入大规模预发布前仍需候选集查询和性能基线。 +4. **P1 平台管理员完整治理与并发验收**:继续补充受控授予/撤销、支持访问原因和时限、双人审批及专用审计。最后有效管理员保护虽已通过本地定向回归,但仍需在真实 PostgreSQL 上执行双请求并发集成验收,不能以 SQLite 或单请求结果替代。 +5. **原 9.18 第 5 项已完成并移出阻断**:开发环境工作空间口径、组织创建命令和管理员密码输入语义已修正;已登录访问 `/login` 自动返回工作台,未登录及其他公开身份入口保持原行为。Web 路由 `4/4`、工作空间状态 `3/3`、身份页源码 `1/1`、会话映射 `2/2`、TypeScript 和登录态浏览器刷新复核均通过;后续如改变登录或切换账号策略,应作为独立会话需求重新评审,不回退本次已验收语义。 +6. **P2 运营与测试效率**:补齐个人套餐、配额、用量、支付、数据导出/删除和客户成功指标;在不降低密码哈希参数的前提下并行化身份测试夹具,并建立账号冲突诊断的规模化查询和回归性能基线。 diff --git a/yuqei-ai-platform/services/ai-platform-api/.env.example b/yuqei-ai-platform/services/ai-platform-api/.env.example index ecca7bd3..f4da6072 100644 --- a/yuqei-ai-platform/services/ai-platform-api/.env.example +++ b/yuqei-ai-platform/services/ai-platform-api/.env.example @@ -3,3 +3,9 @@ AI_PLATFORM_LOG_LEVEL=INFO AI_PLATFORM_DATABASE_URL=postgresql+psycopg://ai:ai@localhost:5432/yuqei_ai AI_PLATFORM_REDIS_URL=redis://localhost:6379/1 AI_PLATFORM_PROVIDER_CONFIG_SOURCE=database +AI_PLATFORM_ANTI_ABUSE_MODE=disabled +AI_PLATFORM_ANTI_ABUSE_HASH_SECRET= +AI_PLATFORM_ANTI_ABUSE_S3_RECORDS_DAILY_LIMIT=1000 +AI_PLATFORM_ANTI_ABUSE_S4_RECORDS_DAILY_LIMIT=1000 +AI_PLATFORM_ANTI_ABUSE_FILES_DAILY_LIMIT=50 +AI_PLATFORM_ANTI_ABUSE_BYTES_DAILY_LIMIT=524288000 diff --git a/yuqei-ai-platform/services/ai-platform-api/README.md b/yuqei-ai-platform/services/ai-platform-api/README.md index 1ab06e57..f36ae176 100644 --- a/yuqei-ai-platform/services/ai-platform-api/README.md +++ b/yuqei-ai-platform/services/ai-platform-api/README.md @@ -42,6 +42,29 @@ checks pass, so it can be used directly in CI or deployment smoke gates. Installed environments can also use the `yuqei-golden-sample-gate` console script. +## AI Release Readiness Gate + +Release readiness combines provider runtime configuration, the offline golden +asset integrity result, citation/source quality policy, and an explicitly +selected live provider audit run: + +```powershell +cd yuqei-ai-platform/services/ai-platform-api +$env:PYTHONPATH=(Resolve-Path .\src).Path +python -m yuqei_ai_platform_api.release_readiness_gate --format json +python -m yuqei_ai_platform_api.release_readiness_gate ` + --acceptance-run-id "" --format json +``` + +The same result is available from +`GET /api/v1/quality/release-readiness?acceptance_run_id=`. +The command performs no provider network call and exits non-zero when the +target environment is blocked. In production, it requires an enabled +non-placeholder provider, disabled fallback, a resolvable credential, passing +golden asset integrity and citation policy checks, and a matching successful +audit run. Golden asset integrity is reported as offline evidence only and is +never presented as proof that a live provider passed acceptance testing. + Provider calls are disabled by default. To enable an OpenAI-compatible provider: ```powershell diff --git a/yuqei-ai-platform/services/ai-platform-api/migrations/versions/20260722_0022_add_security_access_events.py b/yuqei-ai-platform/services/ai-platform-api/migrations/versions/20260722_0022_add_security_access_events.py new file mode 100644 index 00000000..3e9673be --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/migrations/versions/20260722_0022_add_security_access_events.py @@ -0,0 +1,107 @@ +"""add security access events and egress ledger + +Revision ID: 20260722_0022 +Revises: 20260703_0021 +Create Date: 2026-07-22 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260722_0022" +down_revision = "20260703_0021" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "security_access_events", + sa.Column("id", sa.String(length=64), nullable=False), + sa.Column("tenant_id", sa.String(length=64), nullable=False), + sa.Column("user_id", sa.String(length=128), nullable=True), + sa.Column("session_id_hash", sa.String(length=64), nullable=True), + sa.Column("ip_hash", sa.String(length=64), nullable=True), + sa.Column("ua_hash", sa.String(length=64), nullable=True), + sa.Column("route_key", sa.String(length=128), nullable=False), + sa.Column("http_method", sa.String(length=16), nullable=False), + sa.Column("path", sa.String(length=512), nullable=False), + sa.Column("resource_type", sa.String(length=64), nullable=True), + sa.Column("resource_id", sa.String(length=128), nullable=True), + sa.Column("data_class", sa.String(length=8), nullable=False), + sa.Column("records_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("bytes_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("decision", sa.String(length=32), nullable=False), + sa.Column("reason_code", sa.String(length=255), nullable=True), + sa.Column("status_code", sa.Integer(), nullable=False, server_default="0"), + sa.Column("trace_id", sa.String(length=128), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_security_access_events_tenant_id"), "security_access_events", ["tenant_id"]) + op.create_index(op.f("ix_security_access_events_user_id"), "security_access_events", ["user_id"]) + op.create_index(op.f("ix_security_access_events_session_id_hash"), "security_access_events", ["session_id_hash"]) + op.create_index(op.f("ix_security_access_events_ip_hash"), "security_access_events", ["ip_hash"]) + op.create_index(op.f("ix_security_access_events_route_key"), "security_access_events", ["route_key"]) + op.create_index(op.f("ix_security_access_events_data_class"), "security_access_events", ["data_class"]) + op.create_index(op.f("ix_security_access_events_decision"), "security_access_events", ["decision"]) + op.create_index(op.f("ix_security_access_events_trace_id"), "security_access_events", ["trace_id"]) + op.create_index(op.f("ix_security_access_events_created_at"), "security_access_events", ["created_at"]) + op.create_index("ix_security_access_events_tenant_created", "security_access_events", ["tenant_id", "created_at"]) + op.create_index("ix_security_access_events_user_created", "security_access_events", ["user_id", "created_at"]) + op.create_index("ix_security_access_events_route_created", "security_access_events", ["route_key", "created_at"]) + op.create_index("ix_security_access_events_decision_created", "security_access_events", ["decision", "created_at"]) + op.create_index("ix_security_access_events_ip_created", "security_access_events", ["ip_hash", "created_at"]) + + op.create_table( + "data_egress_ledger", + sa.Column("id", sa.String(length=64), nullable=False), + sa.Column("tenant_id", sa.String(length=64), nullable=False), + sa.Column("user_id", sa.String(length=128), nullable=False, server_default=""), + sa.Column("ledger_date", sa.Date(), nullable=False), + sa.Column("data_class", sa.String(length=8), nullable=False), + sa.Column("records_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("files_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("bytes_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("reports_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("tenant_id", "user_id", "ledger_date", "data_class", name="ux_data_egress_ledger_subject_day"), + ) + op.create_index(op.f("ix_data_egress_ledger_tenant_id"), "data_egress_ledger", ["tenant_id"]) + op.create_index(op.f("ix_data_egress_ledger_user_id"), "data_egress_ledger", ["user_id"]) + op.create_index(op.f("ix_data_egress_ledger_ledger_date"), "data_egress_ledger", ["ledger_date"]) + op.create_index(op.f("ix_data_egress_ledger_data_class"), "data_egress_ledger", ["data_class"]) + op.create_index(op.f("ix_data_egress_ledger_updated_at"), "data_egress_ledger", ["updated_at"]) + op.create_index("ix_data_egress_ledger_tenant_date", "data_egress_ledger", ["tenant_id", "ledger_date"]) + op.create_index("ix_data_egress_ledger_user_date", "data_egress_ledger", ["user_id", "ledger_date"]) + + +def downgrade() -> None: + op.drop_index("ix_data_egress_ledger_user_date", table_name="data_egress_ledger") + op.drop_index("ix_data_egress_ledger_tenant_date", table_name="data_egress_ledger") + op.drop_index(op.f("ix_data_egress_ledger_updated_at"), table_name="data_egress_ledger") + op.drop_index(op.f("ix_data_egress_ledger_data_class"), table_name="data_egress_ledger") + op.drop_index(op.f("ix_data_egress_ledger_ledger_date"), table_name="data_egress_ledger") + op.drop_index(op.f("ix_data_egress_ledger_user_id"), table_name="data_egress_ledger") + op.drop_index(op.f("ix_data_egress_ledger_tenant_id"), table_name="data_egress_ledger") + op.drop_table("data_egress_ledger") + + op.drop_index("ix_security_access_events_ip_created", table_name="security_access_events") + op.drop_index("ix_security_access_events_decision_created", table_name="security_access_events") + op.drop_index("ix_security_access_events_route_created", table_name="security_access_events") + op.drop_index("ix_security_access_events_user_created", table_name="security_access_events") + op.drop_index("ix_security_access_events_tenant_created", table_name="security_access_events") + op.drop_index(op.f("ix_security_access_events_created_at"), table_name="security_access_events") + op.drop_index(op.f("ix_security_access_events_trace_id"), table_name="security_access_events") + op.drop_index(op.f("ix_security_access_events_decision"), table_name="security_access_events") + op.drop_index(op.f("ix_security_access_events_data_class"), table_name="security_access_events") + op.drop_index(op.f("ix_security_access_events_route_key"), table_name="security_access_events") + op.drop_index(op.f("ix_security_access_events_ip_hash"), table_name="security_access_events") + op.drop_index(op.f("ix_security_access_events_session_id_hash"), table_name="security_access_events") + op.drop_index(op.f("ix_security_access_events_user_id"), table_name="security_access_events") + op.drop_index(op.f("ix_security_access_events_tenant_id"), table_name="security_access_events") + op.drop_table("security_access_events") diff --git a/yuqei-ai-platform/services/ai-platform-api/pyproject.toml b/yuqei-ai-platform/services/ai-platform-api/pyproject.toml index e11676ba..ebc28e8c 100644 --- a/yuqei-ai-platform/services/ai-platform-api/pyproject.toml +++ b/yuqei-ai-platform/services/ai-platform-api/pyproject.toml @@ -14,12 +14,14 @@ dependencies = [ "pypdf>=5.0", "python-multipart>=0.0.20", "python-docx>=1.1", + "redis>=5.0", "sqlalchemy>=2.0", "uvicorn>=0.34", ] [project.scripts] yuqei-golden-sample-gate = "yuqei_ai_platform_api.golden_sample_gate:main" +yuqei-release-readiness-gate = "yuqei_ai_platform_api.release_readiness_gate:main" yuqei-import-v1-prompt-assets = "yuqei_ai_platform_api.v1_prompt_assets_import:main" [build-system] diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/anti_abuse.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/anti_abuse.py new file mode 100644 index 00000000..74efd601 --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/anti_abuse.py @@ -0,0 +1,534 @@ +from __future__ import annotations + +import hashlib +import hmac +import time +from dataclasses import dataclass +from datetime import UTC, date, datetime +from typing import Literal +from uuid import uuid4 + +from fastapi import Request +from fastapi.responses import JSONResponse +from sqlalchemy.engine import Engine +from sqlalchemy.exc import SQLAlchemyError + + +AntiAbuseMode = Literal["disabled", "observe", "enforce"] +DataClass = Literal["S0", "S1", "S2", "S3", "S4"] +Decision = Literal["allow", "observe", "block"] + + +@dataclass(frozen=True) +class EndpointRiskPolicy: + route_key: str + method: str + path_prefix: str + data_class: DataClass + user_limit_per_minute: int + tenant_limit_per_minute: int + ip_limit_per_minute: int + budget_units: Literal["none", "records", "files", "bytes"] = "none" + sensitive: bool = False + path_contains: str | None = None + + +@dataclass(frozen=True) +class AntiAbuseDecision: + decision: Decision + policy: EndpointRiskPolicy | None + retry_after_seconds: int | None = None + reason: str | None = None + + +@dataclass(frozen=True) +class EgressBudgetLimits: + s3_records_per_day: int = 1000 + s4_records_per_day: int = 1000 + files_per_day: int = 50 + bytes_per_day: int = 500 * 1024 * 1024 + + +class InMemorySlidingWindowLimiter: + def __init__(self, *, window_seconds: int = 60, max_keys: int = 10000) -> None: + self._window_seconds = window_seconds + self._max_keys = max_keys + self._events: dict[str, list[float]] = {} + + def hit(self, key: str, *, limit: int, now: float | None = None) -> tuple[bool, int]: + if limit < 1: + return False, self._window_seconds + current_time = now if now is not None else time.time() + cutoff = current_time - self._window_seconds + events = [item for item in self._events.get(key, []) if item > cutoff] + allowed = len(events) < limit + if allowed: + events.append(current_time) + self._events[key] = events + if len(self._events) > self._max_keys: + self._prune(cutoff) + if allowed: + return True, 0 + oldest = min(events) if events else current_time + retry_after = max(1, int(self._window_seconds - (current_time - oldest))) + return False, retry_after + + def _prune(self, cutoff: float) -> None: + empty_keys = [key for key, events in self._events.items() if not any(item > cutoff for item in events)] + for key in empty_keys: + self._events.pop(key, None) + + +class RedisSlidingWindowLimiter: + def __init__( + self, + redis_url: str, + *, + window_seconds: int = 60, + key_prefix: str = "abuse:rate", + fallback: InMemorySlidingWindowLimiter | None = None, + ) -> None: + self._redis_url = redis_url + self._window_seconds = window_seconds + self._key_prefix = key_prefix + self._fallback = fallback or InMemorySlidingWindowLimiter(window_seconds=window_seconds) + self._client = None + self._redis_error_count = 0 + + @property + def fallback_active(self) -> bool: + return self._redis_error_count > 0 + + def hit(self, key: str, *, limit: int, now: float | None = None) -> tuple[bool, int]: + if not self._redis_url.strip(): + return self._fallback.hit(key, limit=limit, now=now) + try: + client = self._get_client() + current_time = now if now is not None else time.time() + redis_key = f"{self._key_prefix}:{key}" + member = f"{current_time:.6f}:{uuid4().hex}" + cutoff = current_time - self._window_seconds + pipe = client.pipeline() + pipe.zremrangebyscore(redis_key, 0, cutoff) + pipe.zcard(redis_key) + pipe.zadd(redis_key, {member: current_time}) + pipe.expire(redis_key, self._window_seconds * 2) + _, count_before, _, _ = pipe.execute() + allowed = int(count_before) < limit + if allowed: + return True, 0 + client.zrem(redis_key, member) + oldest_items = client.zrange(redis_key, 0, 0, withscores=True) + oldest = float(oldest_items[0][1]) if oldest_items else current_time + retry_after = max(1, int(self._window_seconds - (current_time - oldest))) + return False, retry_after + except Exception: + self._redis_error_count += 1 + return self._fallback.hit(key, limit=limit, now=now) + + def _get_client(self): + if self._client is None: + from redis import Redis + + self._client = Redis.from_url(self._redis_url, socket_connect_timeout=0.2, socket_timeout=0.2) + return self._client + + +class AntiAbuseGuard: + def __init__( + self, + *, + mode: AntiAbuseMode, + service_name: str, + policies: tuple[EndpointRiskPolicy, ...], + hash_secret: str = "", + limiter: InMemorySlidingWindowLimiter | None = None, + redis_url: str = "", + ) -> None: + self.mode = mode + self.service_name = service_name + self.policies = policies + self.hash_secret = hash_secret or f"{service_name}-development-anti-abuse-secret" + self.limiter = limiter or RedisSlidingWindowLimiter(redis_url, key_prefix=f"abuse:rate:{service_name}") + self.events: list[dict[str, object]] = [] + + @property + def enabled(self) -> bool: + return self.mode in {"observe", "enforce"} + + async def inspect(self, request: Request, *, tenant_id: str, user_id: str | None) -> AntiAbuseDecision: + if not self.enabled: + return AntiAbuseDecision(decision="allow", policy=None) + policy = match_policy(request.method, request.url.path, self.policies) + if policy is None: + return AntiAbuseDecision(decision="allow", policy=None) + + dimensions = ( + ("user", _normalize_subject(user_id) or "service", policy.user_limit_per_minute), + ("tenant", _normalize_subject(tenant_id) or "default", policy.tenant_limit_per_minute), + ("ip", self._subject_hash(_client_ip(request)), policy.ip_limit_per_minute), + ) + retry_after = 0 + blocked_dimensions: list[str] = [] + for dimension, subject, limit in dimensions: + key = f"{self.service_name}:{policy.route_key}:{dimension}:{subject}" + allowed, retry = self.limiter.hit(key, limit=limit) + if not allowed: + blocked_dimensions.append(dimension) + retry_after = max(retry_after, retry) + + redis_fallback_active = bool(getattr(self.limiter, "fallback_active", False)) + if not blocked_dimensions: + decision: Decision = "allow" + reason = "redis_fallback_active" if redis_fallback_active else None + elif self.mode == "observe": + decision = "observe" + reason = f"rate_limit_observed:{','.join(blocked_dimensions)}" + else: + decision = "block" + reason = f"rate_limit_exceeded:{','.join(blocked_dimensions)}" + + return AntiAbuseDecision( + decision=decision, + policy=policy, + retry_after_seconds=retry_after or None, + reason=reason, + ) + + def record( + self, + request: Request, + *, + decision: AntiAbuseDecision, + tenant_id: str, + user_id: str | None, + status_code: int, + trace_id: str | None = None, + ) -> None: + if not self.enabled or decision.policy is None: + return + self.events.append( + { + "service": self.service_name, + "route_key": decision.policy.route_key, + "method": request.method, + "path": request.url.path, + "data_class": decision.policy.data_class, + "tenant_id": tenant_id, + "user_id": user_id, + "user_id_present": bool(user_id), + "ip_hash": self._subject_hash(_client_ip(request)), + "ua_hash": self._subject_hash(request.headers.get("user-agent", "")), + "decision": decision.decision, + "reason": decision.reason, + "budget_units": decision.policy.budget_units, + "status_code": status_code, + "trace_id": trace_id, + "created_at": time.time(), + } + ) + if len(self.events) > 1000: + del self.events[: len(self.events) - 1000] + + def _subject_hash(self, value: str | None) -> str: + normalized = value or "unknown" + digest = hmac.new( + self.hash_secret.encode("utf-8"), + normalized.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return digest[:24] + + +def match_policy(method: str, path: str, policies: tuple[EndpointRiskPolicy, ...]) -> EndpointRiskPolicy | None: + normalized_method = method.upper() + matches = [ + policy + for policy in policies + if policy.method == normalized_method + and (path == policy.path_prefix or path.startswith(policy.path_prefix)) + and (policy.path_contains is None or policy.path_contains in path) + ] + if not matches: + return None + return max(matches, key=lambda item: len(item.path_prefix)) + + +def rate_limit_response(decision: AntiAbuseDecision) -> JSONResponse: + retry_after = decision.retry_after_seconds or 60 + headers = { + "Retry-After": str(retry_after), + "X-Anti-Abuse-Decision": "block", + "X-Anti-Abuse-Policy": decision.policy.route_key if decision.policy else "unknown", + } + return JSONResponse( + status_code=429, + content={ + "detail": "Too many requests for this data access path.", + "reason": decision.reason or "rate_limit_exceeded", + "retry_after_seconds": retry_after, + }, + headers=headers, + ) + + +def egress_budget_response(decision: AntiAbuseDecision) -> JSONResponse: + headers = { + "X-Anti-Abuse-Decision": "block", + "X-Anti-Abuse-Policy": decision.policy.route_key if decision.policy else "unknown", + "X-Anti-Abuse-Reason": decision.reason or "daily_egress_budget_exceeded", + } + return JSONResponse( + status_code=403, + content={ + "detail": "Daily data egress budget exceeded for this data access path.", + "reason": decision.reason or "daily_egress_budget_exceeded", + }, + headers=headers, + ) + + +def apply_anti_abuse_headers(response, decision: AntiAbuseDecision, *, mode: AntiAbuseMode) -> None: + if decision.policy is None: + return + response.headers["X-Anti-Abuse-Mode"] = mode + response.headers["X-Anti-Abuse-Decision"] = decision.decision + response.headers["X-Anti-Abuse-Policy"] = decision.policy.route_key + response.headers["X-Data-Class"] = decision.policy.data_class + + +def check_egress_budget( + engine: Engine | None, + decision: AntiAbuseDecision, + *, + tenant_id: str, + user_id: str | None, + limits: EgressBudgetLimits, + estimated_records_count: int = 0, + estimated_files_count: int = 0, + estimated_bytes_count: int = 0, +) -> AntiAbuseDecision: + if engine is None or decision.policy is None or decision.policy.data_class not in {"S3", "S4"}: + return decision + try: + from yuqei_ai_platform_api.models import DataEgressLedgerModel + + ledger_table = DataEgressLedgerModel.__table__ + with engine.connect() as connection: + row = connection.execute( + ledger_table.select().where( + ledger_table.c.tenant_id == tenant_id, + ledger_table.c.user_id == (user_id or ""), + ledger_table.c.ledger_date == date.today(), + ledger_table.c.data_class == decision.policy.data_class, + ) + ).mappings().first() + if row is None: + projected = _egress_budget_exceeded_decision( + decision.policy, + limits=limits, + next_records=max(estimated_records_count, 0), + next_files=max(estimated_files_count, 0), + next_bytes=max(estimated_bytes_count, 0), + ) + if projected is not None: + return projected + return decision + + reason: str | None = None + next_records = int(row["records_count"] or 0) + max(estimated_records_count, 0) + next_files = int(row["files_count"] or 0) + max(estimated_files_count, 0) + next_bytes = int(row["bytes_count"] or 0) + max(estimated_bytes_count, 0) + if decision.policy.budget_units == "bytes" and next_bytes > limits.bytes_per_day: + reason = "daily_bytes_budget_exceeded" + elif decision.policy.budget_units == "files" and next_files > limits.files_per_day: + reason = "daily_files_budget_exceeded" + elif decision.policy.budget_units == "records": + record_limit = limits.s4_records_per_day if decision.policy.data_class == "S4" else limits.s3_records_per_day + if next_records > record_limit: + reason = "daily_records_budget_exceeded" + if reason is None: + return decision + return AntiAbuseDecision( + decision="block", + policy=decision.policy, + retry_after_seconds=None, + reason=reason, + ) + except (SQLAlchemyError, ValueError, TypeError): + return decision + + +def estimate_records_count(request: Request, decision: AntiAbuseDecision) -> int: + if decision.policy is None or decision.policy.budget_units != "records": + return 1 + for key in ("page_size", "limit"): + raw_value = request.query_params.get(key) + if raw_value is None: + continue + try: + return min(max(int(raw_value), 1), 1000) + except ValueError: + return 1 + return 1 + + +def _egress_budget_exceeded_decision( + policy: EndpointRiskPolicy, + *, + limits: EgressBudgetLimits, + next_records: int, + next_files: int, + next_bytes: int, +) -> AntiAbuseDecision | None: + if next_bytes > limits.bytes_per_day: + reason = "daily_bytes_budget_exceeded" + elif next_files > limits.files_per_day: + reason = "daily_files_budget_exceeded" + else: + record_limit = limits.s4_records_per_day if policy.data_class == "S4" else limits.s3_records_per_day + reason = "daily_records_budget_exceeded" if next_records > record_limit else None + if reason is None: + return None + return AntiAbuseDecision( + decision="block", + policy=policy, + retry_after_seconds=None, + reason=reason, + ) + + +def persist_security_event(engine: Engine | None, event: dict[str, object], *, records_count: int = 1, bytes_count: int = 0) -> None: + if engine is None: + return + try: + from yuqei_ai_platform_api.models import DataEgressLedgerModel, SecurityAccessEventModel + + created_at = datetime.now(UTC) + tenant_id = str(event.get("tenant_id") or "default") + user_id = str(event.get("user_id") or "") or None + data_class = str(event.get("data_class") or "S1") + with engine.begin() as connection: + connection.execute( + SecurityAccessEventModel.__table__.insert().values( + id=f"security-access-{uuid4().hex[:16]}", + tenant_id=tenant_id, + user_id=user_id, + session_id_hash=str(event.get("session_id_hash") or "") or None, + ip_hash=str(event.get("ip_hash") or "") or None, + ua_hash=str(event.get("ua_hash") or "") or None, + route_key=str(event.get("route_key") or ""), + http_method=str(event.get("method") or ""), + path=str(event.get("path") or ""), + resource_type=str(event.get("resource_type") or "") or None, + resource_id=str(event.get("resource_id") or "") or None, + data_class=data_class, + records_count=max(records_count, 0), + bytes_count=max(bytes_count, 0), + decision=str(event.get("decision") or "allow"), + reason_code=str(event.get("reason") or "") or None, + status_code=int(event.get("status_code") or 0), + trace_id=str(event.get("trace_id") or "") or None, + created_at=created_at, + ) + ) + if data_class in {"S3", "S4"}: + ledger_table = DataEgressLedgerModel.__table__ + ledger_date = date.today() + existing = connection.execute( + ledger_table.select().where( + ledger_table.c.tenant_id == tenant_id, + ledger_table.c.user_id == (user_id or ""), + ledger_table.c.ledger_date == ledger_date, + ledger_table.c.data_class == data_class, + ) + ).mappings().first() + if existing is None: + connection.execute( + ledger_table.insert().values( + id=f"egress-ledger-{uuid4().hex[:16]}", + tenant_id=tenant_id, + user_id=user_id or "", + ledger_date=ledger_date, + data_class=data_class, + records_count=max(records_count, 0), + files_count=1 if event.get("budget_units") == "files" else 0, + bytes_count=max(bytes_count, 0), + reports_count=0, + updated_at=created_at, + ) + ) + else: + connection.execute( + ledger_table.update() + .where(ledger_table.c.id == existing["id"]) + .values( + records_count=int(existing["records_count"] or 0) + max(records_count, 0), + files_count=int(existing["files_count"] or 0) + + (1 if event.get("budget_units") == "files" else 0), + bytes_count=int(existing["bytes_count"] or 0) + max(bytes_count, 0), + updated_at=created_at, + ) + ) + except (SQLAlchemyError, ValueError, TypeError): + return + + +def ai_platform_endpoint_risk_policies(api_prefix: str) -> tuple[EndpointRiskPolicy, ...]: + p = api_prefix.rstrip("/") + return ( + EndpointRiskPolicy("ai_runs.list", "GET", f"{p}/audit/ai-runs", "S2", 20, 200, 60, "records", True), + EndpointRiskPolicy("ai_runs.detail", "GET", f"{p}/audit/ai-runs/", "S4", 10, 100, 30, "records", True), + EndpointRiskPolicy("quality_actions", "GET", f"{p}/audit/quality-improvement-actions", "S2", 20, 200, 60, "records", True), + EndpointRiskPolicy("prompts.list", "GET", f"{p}/prompt-templates", "S3", 20, 200, 60, "records", True), + EndpointRiskPolicy("prompts.changes", "GET", f"{p}/prompt-templates/changes", "S3", 20, 200, 60, "records", True), + EndpointRiskPolicy("knowledge.documents", "GET", f"{p}/knowledge-documents", "S3", 30, 300, 80, "records", True), + EndpointRiskPolicy("knowledge.document_chunks", "GET", f"{p}/knowledge-documents/", "S3", 20, 200, 60, "records", True, "/chunks"), + EndpointRiskPolicy("knowledge.search", "GET", f"{p}/knowledge-documents/search", "S3", 20, 200, 60, "records", True), + EndpointRiskPolicy("knowledge.chunk_search", "GET", f"{p}/knowledge-chunks/search", "S3", 20, 200, 60, "records", True), + EndpointRiskPolicy("knowledge.batch_documents", "GET", f"{p}/knowledge-import-batches/", "S3", 20, 200, 60, "records", True, "/documents"), + EndpointRiskPolicy("knowledge.batch_chunks", "GET", f"{p}/knowledge-import-batches/", "S3", 10, 100, 30, "records", True, "/chunks"), + EndpointRiskPolicy("trace_governance", "GET", f"{p}/runtime/trace-governance", "S2", 20, 200, 60, "records", True), + ) + + +def anti_abuse_config_status( + *, + mode: AntiAbuseMode, + environment: str, + redis_url: str, + hash_secret: str, + policy_count: int, +) -> tuple[str, str, dict[str, str | bool | int]]: + strict = environment in {"staging", "production"} + details = { + "mode": mode, + "redis_configured": bool(redis_url.strip()), + "hash_secret_configured": len(hash_secret.strip()) >= 32, + "policy_count": policy_count, + } + if mode == "disabled" and strict: + return "error", "Staging and production must enable anti-abuse controls in observe or enforce mode.", details + if mode == "enforce" and not redis_url.strip() and strict: + return "error", "Enforced anti-abuse controls require Redis in staging and production.", details + if mode in {"observe", "enforce"} and len(hash_secret.strip()) < 32 and strict: + return "error", "Anti-abuse controls require a dedicated hash secret of at least 32 characters.", details + if mode == "disabled": + return "warning", "Anti-abuse controls are disabled.", details + if mode == "observe": + return "warning", "Anti-abuse controls are observing sensitive access without blocking.", details + return "ok", "Anti-abuse controls are enabled.", details + + +def _client_ip(request: Request) -> str: + forwarded_for = request.headers.get("x-forwarded-for") + if forwarded_for: + return forwarded_for.split(",", 1)[0].strip() + return request.client.host if request.client else "unknown" + + +def _normalize_subject(value: str | None) -> str | None: + if value is None: + return None + stripped = value.strip() + return stripped or None diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/legal.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/legal.py index 3b5fb996..27f49edf 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/legal.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/legal.py @@ -8,6 +8,7 @@ from yuqei_ai_platform_api.contract_review import ( ContractReviewRequest, ContractReviewResponse, build_contract_review_query, + parse_provider_contract_review, review_contract, ) from yuqei_ai_platform_api.legal_qa import LegalCitation, LegalQaRequest, LegalQaResponse, answer_legal_question @@ -209,13 +210,32 @@ def register_legal_routes( provider_response = provider_adapter.generate( ProviderRequest(prompt=prompt, provider_config=provider_config) ) + provider_status = provider_response.status + provider_error = provider_response.error_message if provider_response.status == "succeeded" and provider_response.answer: - response = review_contract( - request, - provider_answer=provider_response.answer, + structured_response = parse_provider_contract_review( + provider_response.answer, retrieved_citations=citations, ) - audit_status = "succeeded" + if structured_response is not None: + response = structured_response + audit_status = "succeeded" + else: + provider_status = "failed" + provider_error = "Provider contract review output failed structured validation." + if settings.provider_fallback_enabled: + response = review_contract(request, retrieved_citations=citations) + audit_status = "fallback" + else: + response = ContractReviewResponse( + summary="AI service returned an invalid structured review. Please try again later.", + risk_level="unknown", + risk_items=[], + citations=citations, + primary_sources=citations[:3], + confidence=0.0, + ) + audit_status = "failed" elif settings.provider_fallback_enabled: response = review_contract(request, retrieved_citations=citations) audit_status = "fallback" @@ -234,8 +254,8 @@ def register_legal_routes( quality_status, quality_score, quality_labels, quality_summary = _evaluate_ai_run_quality( rules=quality_rules, status=audit_status, - provider_status=provider_response.status, - provider_error=provider_response.error_message, + provider_status=provider_status, + provider_error=provider_error, citations_count=len(response.citations), retrieved_sources_count=len(retrieval_sources), total_tokens=provider_response.total_tokens, @@ -249,8 +269,8 @@ def register_legal_routes( prompt_template_id=prompt_template.id if prompt_template else None, provider=provider_response.provider or (provider_config.provider if provider_config else None), model=provider_response.model or (provider_config.model if provider_config else None), - provider_status=provider_response.status, - provider_error=provider_response.error_message, + provider_status=provider_status, + provider_error=provider_error, prompt_preview=_preview_text(prompt), question=retrieval_query, answer=response.summary, diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/quality.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/quality.py index 056febc8..a098d088 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/quality.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/quality.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Query from yuqei_ai_platform_api.config import AiPlatformSettings from yuqei_ai_platform_api.golden_samples import ( @@ -12,6 +12,10 @@ from yuqei_ai_platform_api.repository import ( AiQualityRuleConfig, AiQualityRuleConfigCreate, ) +from yuqei_ai_platform_api.release_readiness import ( + AiReleaseReadiness, + evaluate_ai_release_readiness, +) def register_quality_routes( @@ -56,3 +60,17 @@ def register_quality_routes( ) def evaluate_golden_samples() -> GoldenSampleEvaluationReport: return run_golden_sample_evaluation() + + @app.get( + f"{settings.api_prefix}/quality/release-readiness", + response_model=AiReleaseReadiness, + tags=["quality", "runtime"], + ) + def get_release_readiness( + acceptance_run_id: str | None = Query(default=None, min_length=1), + ) -> AiReleaseReadiness: + return evaluate_ai_release_readiness( + settings=settings, + store=store, + acceptance_run_id=acceptance_run_id, + ) diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/security.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/security.py new file mode 100644 index 00000000..3a6be5dd --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/security.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +from datetime import date, datetime + +from fastapi import FastAPI, Query +from pydantic import BaseModel +from sqlalchemy import func, select + +from yuqei_ai_platform_api.config import AiPlatformSettings +from yuqei_ai_platform_api.models import DataEgressLedgerModel, SecurityAccessEventModel +from yuqei_ai_platform_api.repository import AiPlatformRepository +from yuqei_ai_platform_api.tenant_context import current_tenant_id + + +class SecurityAccessEventItem(BaseModel): + id: str + tenant_id: str + user_id: str | None = None + session_id_hash: str | None = None + ip_hash: str | None = None + ua_hash: str | None = None + route_key: str + http_method: str + path: str + resource_type: str | None = None + resource_id: str | None = None + data_class: str + records_count: int + bytes_count: int + decision: str + reason_code: str | None = None + status_code: int + trace_id: str | None = None + created_at: datetime + + +class SecurityAccessEventPage(BaseModel): + items: list[SecurityAccessEventItem] + total: int + page: int + page_size: int + + +class DataEgressLedgerItem(BaseModel): + id: str + tenant_id: str + user_id: str + ledger_date: date + data_class: str + records_count: int + files_count: int + bytes_count: int + reports_count: int + updated_at: datetime + + +class DataEgressLedgerPage(BaseModel): + items: list[DataEgressLedgerItem] + total: int + page: int + page_size: int + + +def register_security_routes( + app: FastAPI, + *, + settings: AiPlatformSettings, + store: AiPlatformRepository, +) -> None: + @app.get( + f"{settings.api_prefix}/security/access-events", + response_model=SecurityAccessEventPage, + tags=["security"], + ) + def list_security_access_events( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + tenant_id: str | None = Query(None), + user_id: str | None = Query(None), + route_key: str | None = Query(None), + decision: str | None = Query(None), + data_class: str | None = Query(None), + created_from: datetime | None = Query(None), + created_to: datetime | None = Query(None), + ) -> SecurityAccessEventPage: + session_factory = getattr(store, "_session_factory", None) + if not callable(session_factory): + return SecurityAccessEventPage(items=[], total=0, page=page, page_size=page_size) + filters = _security_event_filters( + tenant_id=tenant_id or current_tenant_id(), + user_id=user_id, + route_key=route_key, + decision=decision, + data_class=data_class, + created_from=created_from, + created_to=created_to, + ) + with session_factory() as session: + total = session.scalar(select(func.count()).select_from(SecurityAccessEventModel).where(*filters)) or 0 + rows = session.scalars( + select(SecurityAccessEventModel) + .where(*filters) + .order_by(SecurityAccessEventModel.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ).all() + return SecurityAccessEventPage( + items=[_security_event_item(row) for row in rows], + total=total, + page=page, + page_size=page_size, + ) + + @app.get( + f"{settings.api_prefix}/security/egress-ledger", + response_model=DataEgressLedgerPage, + tags=["security"], + ) + def list_data_egress_ledger( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + tenant_id: str | None = Query(None), + user_id: str | None = Query(None), + data_class: str | None = Query(None), + ledger_date: date | None = Query(None), + ) -> DataEgressLedgerPage: + session_factory = getattr(store, "_session_factory", None) + if not callable(session_factory): + return DataEgressLedgerPage(items=[], total=0, page=page, page_size=page_size) + filters = _egress_ledger_filters( + tenant_id=tenant_id or current_tenant_id(), + user_id=user_id, + data_class=data_class, + ledger_date=ledger_date, + ) + with session_factory() as session: + total = session.scalar(select(func.count()).select_from(DataEgressLedgerModel).where(*filters)) or 0 + rows = session.scalars( + select(DataEgressLedgerModel) + .where(*filters) + .order_by(DataEgressLedgerModel.ledger_date.desc(), DataEgressLedgerModel.updated_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ).all() + return DataEgressLedgerPage( + items=[_egress_ledger_item(row) for row in rows], + total=total, + page=page, + page_size=page_size, + ) + + +def _security_event_filters( + *, + tenant_id: str | None, + user_id: str | None, + route_key: str | None, + decision: str | None, + data_class: str | None, + created_from: datetime | None, + created_to: datetime | None, +): + filters = [] + if tenant_id: + filters.append(SecurityAccessEventModel.tenant_id == tenant_id.strip()) + if user_id: + filters.append(SecurityAccessEventModel.user_id == user_id.strip()) + if route_key: + filters.append(SecurityAccessEventModel.route_key == route_key.strip()) + if decision: + filters.append(SecurityAccessEventModel.decision == decision.strip()) + if data_class: + filters.append(SecurityAccessEventModel.data_class == data_class.strip()) + if created_from: + filters.append(SecurityAccessEventModel.created_at >= created_from) + if created_to: + filters.append(SecurityAccessEventModel.created_at <= created_to) + return filters + + +def _egress_ledger_filters( + *, + tenant_id: str | None, + user_id: str | None, + data_class: str | None, + ledger_date: date | None, +): + filters = [] + if tenant_id: + filters.append(DataEgressLedgerModel.tenant_id == tenant_id.strip()) + if user_id: + filters.append(DataEgressLedgerModel.user_id == user_id.strip()) + if data_class: + filters.append(DataEgressLedgerModel.data_class == data_class.strip()) + if ledger_date: + filters.append(DataEgressLedgerModel.ledger_date == ledger_date) + return filters + + +def _security_event_item(row: SecurityAccessEventModel) -> SecurityAccessEventItem: + return SecurityAccessEventItem( + id=row.id, + tenant_id=row.tenant_id, + user_id=row.user_id, + session_id_hash=row.session_id_hash, + ip_hash=row.ip_hash, + ua_hash=row.ua_hash, + route_key=row.route_key, + http_method=row.http_method, + path=row.path, + resource_type=row.resource_type, + resource_id=row.resource_id, + data_class=row.data_class, + records_count=row.records_count, + bytes_count=row.bytes_count, + decision=row.decision, + reason_code=row.reason_code, + status_code=row.status_code, + trace_id=row.trace_id, + created_at=row.created_at, + ) + + +def _egress_ledger_item(row: DataEgressLedgerModel) -> DataEgressLedgerItem: + return DataEgressLedgerItem( + id=row.id, + tenant_id=row.tenant_id, + user_id=row.user_id, + ledger_date=row.ledger_date, + data_class=row.data_class, + records_count=row.records_count, + files_count=row.files_count, + bytes_count=row.bytes_count, + reports_count=row.reports_count, + updated_at=row.updated_at, + ) diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/system.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/system.py index 12ee1d8a..1cd5400d 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/system.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/api/system.py @@ -90,6 +90,9 @@ def register_system_routes( default_provider_config=default_provider_config, service_auth_required=settings.require_service_auth, service_secret_configured=len(settings.service_shared_secret.strip()) >= 32, + anti_abuse_mode=settings.anti_abuse_mode, + redis_url=settings.redis_url, + anti_abuse_hash_secret=settings.anti_abuse_hash_secret, ) @app.get( diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/config.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/config.py index 6668a061..46140a3a 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/config.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/config.py @@ -3,6 +3,8 @@ from typing import Literal from pydantic_settings import BaseSettings, SettingsConfigDict +from yuqei_ai_platform_api.anti_abuse import ai_platform_endpoint_risk_policies, anti_abuse_config_status + class AiPlatformSettings(BaseSettings): service_name: str = "ai-platform-api" @@ -18,6 +20,7 @@ class AiPlatformSettings(BaseSettings): provider_api_key: str | None = None provider_api_keys: dict[str, str] = {} provider_base_url: str | None = None + provider_wire_api: Literal["chat_completions", "responses"] = "chat_completions" provider_request_timeout_seconds: float = 30.0 provider_fallback_enabled: bool = True service_shared_secret: str = "" @@ -39,6 +42,12 @@ class AiPlatformSettings(BaseSettings): object_storage_access_key_id: str | None = None object_storage_secret_access_key: str | None = None object_storage_region_name: str = "us-east-1" + anti_abuse_mode: Literal["disabled", "observe", "enforce"] = "disabled" + anti_abuse_hash_secret: str = "" + anti_abuse_s3_records_daily_limit: int = 1000 + anti_abuse_s4_records_daily_limit: int = 1000 + anti_abuse_files_daily_limit: int = 50 + anti_abuse_bytes_daily_limit: int = 500 * 1024 * 1024 model_config = SettingsConfigDict( env_file=".env", @@ -50,3 +59,13 @@ class AiPlatformSettings(BaseSettings): @lru_cache def get_settings() -> AiPlatformSettings: return AiPlatformSettings() + + +def check_anti_abuse_runtime_config(settings: AiPlatformSettings): + return anti_abuse_config_status( + mode=settings.anti_abuse_mode, + environment=settings.environment, + redis_url=settings.redis_url, + hash_secret=settings.anti_abuse_hash_secret, + policy_count=len(ai_platform_endpoint_risk_policies(settings.api_prefix)), + ) diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/contract_review.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/contract_review.py index 7485c940..568e943b 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/contract_review.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/contract_review.py @@ -1,4 +1,8 @@ -from pydantic import BaseModel, Field +import json +import re +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, ValidationError from yuqei_ai_platform_api.legal_qa import LegalCitation @@ -20,6 +24,7 @@ class ContractReviewRiskItem(BaseModel): suggestion: str related_clause: str | None = None citation_refs: list[str] = Field(default_factory=list) + citation_source_ids: list[str] = Field(default_factory=list) class ContractReviewResponse(BaseModel): @@ -32,6 +37,29 @@ class ContractReviewResponse(BaseModel): audit_run_id: str | None = None +class ProviderContractReviewRiskItem(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + id: str = Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]*$") + title: str = Field(min_length=1, max_length=500) + severity: Literal["low", "medium", "high", "critical"] + suggestion: str = Field(min_length=1, max_length=4000) + related_clause: str | None = Field(default=None, max_length=2000) + citation_source_ids: list[str] = Field(min_length=1, max_length=20) + + +class ProviderContractReviewPayload(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + summary: str = Field(min_length=1, max_length=8000) + risk_level: Literal["low", "medium", "high", "critical"] + risk_items: list[ProviderContractReviewRiskItem] = Field(min_length=1, max_length=50) + + +_JSON_FENCE = re.compile(r"^```(?:json)?\s*\r?\n(?P.*)\r?\n```$", re.IGNORECASE | re.DOTALL) +_SEVERITY_RANK = {"low": 1, "medium": 2, "high": 3, "critical": 4} + + def review_contract( request: ContractReviewRequest, *, @@ -51,6 +79,7 @@ def review_contract( related_clause="\u4ed8\u6b3e / \u9a8c\u6536", suggestion="\u5efa\u8bae\u660e\u786e\u4ea4\u4ed8\u6807\u51c6\u3001\u9a8c\u6536\u65f6\u9650\u3001\u5f02\u8bae\u671f\u548c\u4ed8\u6b3e\u89e6\u53d1\u6761\u4ef6\u3002", citation_refs=_citation_refs(citations), + citation_source_ids=_citation_source_ids(citations), ) ) @@ -63,6 +92,7 @@ def review_contract( related_clause="\u8fdd\u7ea6\u8d23\u4efb", suggestion="\u5efa\u8bae\u533a\u5206\u901a\u7528\u8fdd\u7ea6\u3001\u903e\u671f\u4ed8\u6b3e\u3001\u903e\u671f\u4ea4\u4ed8\u548c\u4fdd\u5bc6\u8fdd\u7ea6\u7684\u8d23\u4efb\u540e\u679c\u3002", citation_refs=_citation_refs(citations), + citation_source_ids=_citation_source_ids(citations), ) ) @@ -75,6 +105,7 @@ def review_contract( related_clause="\u79df\u8d41 / \u62bc\u91d1 / \u7ef4\u4fee", suggestion="\u5efa\u8bae\u8865\u5145\u4ea4\u4ed8\u6e05\u5355\u3001\u62bc\u91d1\u6263\u9664\u6761\u4ef6\u3001\u8fd4\u8fd8\u65f6\u9650\u548c\u7ef4\u4fee\u8d23\u4efb\u5206\u914d\u3002", citation_refs=_citation_refs(citations), + citation_source_ids=_citation_source_ids(citations), ) ) @@ -87,6 +118,7 @@ def review_contract( related_clause="\u4e3b\u8981\u6761\u6b3e", suggestion="\u5efa\u8bae\u6838\u5bf9\u4e3b\u4f53\u3001\u6807\u7684\u3001\u91d1\u989d\u3001\u671f\u9650\u3001\u89e3\u9664\u3001\u4e89\u8bae\u89e3\u51b3\u548c\u8bc1\u636e\u7559\u5b58\u6761\u6b3e\u3002", citation_refs=_citation_refs(citations), + citation_source_ids=_citation_source_ids(citations), ) ) @@ -102,6 +134,77 @@ def review_contract( ) +def parse_provider_contract_review( + provider_answer: str, + *, + retrieved_citations: list[LegalCitation], +) -> ContractReviewResponse | None: + """Validate provider JSON and bind risk citations to retrieved sources only.""" + raw = provider_answer.strip() + fenced = _JSON_FENCE.fullmatch(raw) + if fenced: + raw = fenced.group("body").strip() + try: + payload = ProviderContractReviewPayload.model_validate(json.loads(raw)) + except (json.JSONDecodeError, TypeError, ValidationError): + return None + + citation_by_source_id: dict[str, LegalCitation] = {} + for citation in retrieved_citations: + for source_id in citation.source_ids: + citation_by_source_id.setdefault(source_id, citation) + if not citation_by_source_id: + return None + + risk_items: list[ContractReviewRiskItem] = [] + cited_source_ids: list[str] = [] + for item in payload.risk_items: + source_ids = list(dict.fromkeys(item.citation_source_ids)) + if any(source_id not in citation_by_source_id for source_id in source_ids): + return None + cited_source_ids.extend(source_ids) + risk_items.append( + ContractReviewRiskItem( + id=item.id, + title=item.title, + severity=item.severity, + suggestion=item.suggestion, + related_clause=item.related_clause, + citation_refs=[ + _citation_ref(citation_by_source_id[source_id], source_id) + for source_id in source_ids + ], + citation_source_ids=source_ids, + ) + ) + + highest_severity = max( + (item.severity for item in risk_items), + key=lambda severity: _SEVERITY_RANK[severity], + ) + if payload.risk_level != highest_severity: + return None + + selected_citations: list[LegalCitation] = [] + selected_document_ids: set[str] = set() + for source_id in dict.fromkeys(cited_source_ids): + citation = citation_by_source_id[source_id] + citation_key = citation.chunk_id or citation.document_id or f"{citation.title}:{citation.reference}" + if citation_key in selected_document_ids: + continue + selected_document_ids.add(citation_key) + selected_citations.append(citation) + + return ContractReviewResponse( + summary=payload.summary, + risk_level=payload.risk_level, + risk_items=risk_items, + citations=selected_citations, + primary_sources=selected_citations[:3], + confidence=0.78, + ) + + def build_contract_review_query(request: ContractReviewRequest) -> str: focus = request.review_focus or "\u5408\u540c\u98ce\u9669\u5ba1\u67e5" return f"{request.contract_title} {request.contract_type or ''} {request.counterparty or ''} {focus}" @@ -125,5 +228,13 @@ def _citation_refs(citations: list[LegalCitation]) -> list[str]: return [f"{citation.title} {citation.reference}".strip() for citation in citations[:3]] +def _citation_source_ids(citations: list[LegalCitation]) -> list[str]: + return list(dict.fromkeys(source_id for citation in citations[:3] for source_id in citation.source_ids)) + + +def _citation_ref(citation: LegalCitation, source_id: str) -> str: + return f"{source_id} {citation.title} {citation.reference}".strip() + + def _contains_any(text: str, keywords: list[str]) -> bool: return any(keyword.lower() in text for keyword in keywords) diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/golden_assets/v1_phase13_legal_knowledge_template_library/inventories/legal-knowledge-card-inventory.csv b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/golden_assets/v1_phase13_legal_knowledge_template_library/inventories/legal-knowledge-card-inventory.csv new file mode 100644 index 00000000..ef39eead --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/golden_assets/v1_phase13_legal_knowledge_template_library/inventories/legal-knowledge-card-inventory.csv @@ -0,0 +1,11 @@ +knowledge_card_id,title,source_ids,legal_question,applicable_scenarios,non_applicable_scenarios,original_reference,answer_boundary,status,legal_reviewer,production_ready +KC-CIVIL-CONTRACT-FORM-001,合同形式与电子数据形式,LAW-CIVIL-001;LAW-E-SIGN-001,纸质合同、电子合同、电子邮件和系统订单能否作为合同形式和履约证据,采购合同;服务合同;软件与软件即服务合同;电子签署,特殊形式合同;强监管合同;跨境签署,民法典合同形式规则;电子签名法数据电文与可靠电子签名规则,只能回答合同形式和电子证据的一般审核方向;特殊形式、监管审批、跨境签署必须人工复核,LEGAL_REVIEW,待法务复核,false +KC-CIVIL-STANDARD-CLAUSES-001,格式条款提示说明与效力边界,LAW-CIVIL-001;LAW-CONTRACT-001,标准合同和平台条款如何提示说明并避免格式条款无效风险,标准合同;平台服务条款;采购模板;服务模板,充分逐条协商条款;个案效力判断,民法典格式条款规则;民法典合同编通则司法解释相关规则,只能提示格式条款风险;单个条款效力需人工复核,LEGAL_REVIEW,待法务复核,false +KC-CIVIL-BREACH-LIQUIDATED-DAMAGES-001,违约责任与违约金调整边界,LAW-CIVIL-001;LAW-CONTRACT-001,如何约定违约责任、赔偿范围和违约金以降低争议风险,付款;交付;验收;质量;保密;软件服务,行政处罚;刑事责任;已进入争议程序的损失计算,民法典违约责任与违约金规则;民法典合同编通则司法解释相关规则,只能给出结构化审核方向;具体比例和金额需结合法务复核,LEGAL_REVIEW,待法务复核,false +KC-CIVIL-RESCISSION-NOTICE-001,合同解除条件与通知留痕,LAW-CIVIL-001;LAW-CONTRACT-001,解除权、解除通知、视为送达和解除后果如何约定,采购合同;服务合同;软件服务合同;技术合同,劳动合同解除;消费者合同解除;个案解除效力判断,民法典合同解除规则;民法典合同编通则司法解释相关规则,不能直接判断某次解除是否有效;涉及争议时必须人工复核,LEGAL_REVIEW,待法务复核,false +KC-LABOR-CONTRACT-TERM-PROBATION-001,劳动合同订立、期限与试用期边界,LAW-LABOR-001;LAW-LABOR-002,劳动合同如何约定期限、岗位、地点、工资、社保和试用期,劳动合同模板;人事合同审核;劳务边界识别,退休返聘;平台用工;工伤;竞业限制;劳动争议个案,劳动合同法劳动合同订立和试用期规则;劳动法劳动保护规则,不能直接认定劳动关系或计算解除补偿;复杂用工必须人工复核,LEGAL_REVIEW,待法务复核,false +KC-DATA-PI-PROCESSING-001,个人信息处理与委托处理条款边界,LAW-DATA-001;LAW-DATA-002;LAW-DATA-003,合同涉及个人信息和数据处理时如何约定处理目的、范围、授权和安全义务,软件与软件即服务合同;数据处理协议;外包服务;平台服务,数据出境;重要数据;关键信息基础设施;行业监管数据,个人信息保护法处理规则;数据安全法安全管理规则;网络安全法网络运营者义务,只能做合同条款层面初筛;数据出境和高风险处理必须合规专项复核,LEGAL_REVIEW,待法务复核,false +KC-IP-SOFTWARE-COPYRIGHT-OWNERSHIP-001,软件成果著作权归属与许可边界,LAW-IP-001;LAW-CIVIL-001;LAW-TECH-001,软件开发和技术服务合同中如何约定成果归属、源代码交付和授权使用,技术开发;软件开发;系统实施;软件服务,开源合规;专利申请权;商业秘密争议;职务作品争议,著作权法作品权利与许可规则;民法典技术合同规则;技术合同司法解释相关规则,不能直接判断具体成果权属;开源、专利、商业秘密需专项复核,LEGAL_REVIEW,待法务复核,false +KC-COMPANY-AUTHORIZATION-001,签约主体资格与授权代表核验,LAW-COMPANY-001;LAW-CIVIL-001,签约前如何核验主体资格、代表权限、授权委托和盖章风险,采购合同;服务合同;技术合同;授权代表签署,境外主体;重大资产交易;国资交易;个案表见代理判断,公司法公司主体与治理规则;民法典代理和合同效力规则,只能提示主体和授权核验事项;越权代表和表见代理需人工复核,LEGAL_REVIEW,待法务复核,false +KC-DISPUTE-ARBITRATION-LITIGATION-001,仲裁与诉讼争议解决条款选择,LAW-ARBITRATION-001;LAW-CIVIL-PROCEDURE-001;LAW-CIVIL-001,争议解决条款如何选择仲裁或诉讼并避免机构、管辖冲突,采购合同;服务合同;保密协议;技术开发合同,涉外合同;劳动争议;消费者争议;专属管辖事项,仲裁法仲裁协议规则;民事诉讼法管辖规则;民法典合同争议规则,不能判断具体仲裁协议或管辖条款最终效力;争议发生后需律师复核,LEGAL_REVIEW,待法务复核,false +KC-E-SIGN-EVIDENCE-RETENTION-001,电子签名与电子合同证据留存,LAW-E-SIGN-001;LAW-CIVIL-001;LAW-CIVIL-PROCEDURE-001,电子合同签署后如何保存签署证据、身份认证、时间戳和履约记录,电子签署;线上订单;系统确认;邮件签署,不得适用电子文书的场景;电子签名技术鉴定,电子签名法数据电文与可靠电子签名规则;民法典合同形式规则;民事诉讼法电子数据证据规则,不能判断某一电子签名是否满足可靠电子签名标准;证据能力和技术鉴定需人工复核,LEGAL_REVIEW,待法务复核,false diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/legal_qa.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/legal_qa.py index 67780747..d2c1ecbb 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/legal_qa.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/legal_qa.py @@ -1,6 +1,11 @@ +import re + from pydantic import BaseModel, Field +STABLE_SOURCE_ID_PATTERN = re.compile(r"\bLAW-[A-Z0-9]+(?:-[A-Z0-9]+)*\b") + + class LegalQaRequest(BaseModel): question: str = Field(min_length=1) matter_context: str | None = None @@ -24,6 +29,7 @@ class LegalCitation(BaseModel): hybrid_score: float | None = None retrieval_strategy: str | None = None matched_terms: list[str] = Field(default_factory=list) + source_ids: list[str] = Field(default_factory=list) class LegalQaResponse(BaseModel): @@ -33,6 +39,13 @@ class LegalQaResponse(BaseModel): confidence: float | None = None +def extract_stable_source_ids(reference: str | None) -> list[str]: + """Return ordered Phase13 source IDs carried by a document reference.""" + if not reference: + return [] + return list(dict.fromkeys(STABLE_SOURCE_ID_PATTERN.findall(reference.upper()))) + + def answer_legal_question( request: LegalQaRequest, *, diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/logging.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/logging.py index 06a77ae0..1e9ed672 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/logging.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/logging.py @@ -25,3 +25,6 @@ def configure_logging(level: str) -> None: } ) logging.getLogger("uvicorn.access").setLevel(level.upper()) + # httpx INFO records include the complete provider URL; keep runtime logs endpoint-agnostic. + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("httpcore").setLevel(logging.WARNING) diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/main.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/main.py index ef287f51..405898e3 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/main.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/main.py @@ -2,12 +2,24 @@ from fastapi import FastAPI from fastapi.responses import JSONResponse from yuqei_ai_platform_api import __version__ +from yuqei_ai_platform_api.anti_abuse import ( + AntiAbuseGuard, + EgressBudgetLimits, + ai_platform_endpoint_risk_policies, + apply_anti_abuse_headers, + check_egress_budget, + egress_budget_response, + estimate_records_count, + persist_security_event, + rate_limit_response, +) from yuqei_ai_platform_api.api.audit import register_audit_routes from yuqei_ai_platform_api.api.knowledge import register_knowledge_routes from yuqei_ai_platform_api.api.legal import register_legal_routes from yuqei_ai_platform_api.api.model_configs import register_model_config_routes from yuqei_ai_platform_api.api.prompts import register_prompt_routes from yuqei_ai_platform_api.api.quality import register_quality_routes +from yuqei_ai_platform_api.api.security import register_security_routes from yuqei_ai_platform_api.api.system import register_system_routes from yuqei_ai_platform_api.config import AiPlatformSettings, get_settings from yuqei_ai_platform_api.logging import configure_logging @@ -44,6 +56,20 @@ def create_app( secret_access_key=resolved_settings.object_storage_secret_access_key, region_name=resolved_settings.object_storage_region_name, ) + anti_abuse_guard = AntiAbuseGuard( + mode=resolved_settings.anti_abuse_mode, + service_name=resolved_settings.service_name, + policies=ai_platform_endpoint_risk_policies(resolved_settings.api_prefix), + hash_secret=resolved_settings.anti_abuse_hash_secret, + redis_url=resolved_settings.redis_url, + ) + app.state.anti_abuse_guard = anti_abuse_guard + egress_budget_limits = EgressBudgetLimits( + s3_records_per_day=resolved_settings.anti_abuse_s3_records_daily_limit, + s4_records_per_day=resolved_settings.anti_abuse_s4_records_daily_limit, + files_per_day=resolved_settings.anti_abuse_files_daily_limit, + bytes_per_day=resolved_settings.anti_abuse_bytes_daily_limit, + ) @app.middleware("http") async def tenant_context_middleware(request, call_next): @@ -53,10 +79,69 @@ def create_app( status_code=401, content={"detail": "Authenticated AI Platform service access is required."}, ) - token = set_current_tenant_id(request.headers.get("X-Tenant-Id")) + tenant_id = request.headers.get("X-Tenant-Id") + user_id = request.headers.get("X-User-Id") + token = set_current_tenant_id(tenant_id) + anti_abuse_decision = await anti_abuse_guard.inspect( + request, + tenant_id=current_tenant_id(), + user_id=user_id, + ) + estimated_records_count = estimate_records_count(request, anti_abuse_decision) + if anti_abuse_decision.decision == "block": + anti_abuse_guard.record( + request, + decision=anti_abuse_decision, + tenant_id=current_tenant_id(), + user_id=user_id, + status_code=429, + trace_id=request.headers.get("X-Trace-Id"), + ) + if anti_abuse_guard.events: + persist_security_event(getattr(store, "_engine", None), anti_abuse_guard.events[-1]) + return rate_limit_response(anti_abuse_decision) + anti_abuse_decision = check_egress_budget( + getattr(store, "_engine", None), + anti_abuse_decision, + tenant_id=current_tenant_id(), + user_id=user_id, + limits=egress_budget_limits, + estimated_records_count=estimated_records_count, + ) + if anti_abuse_decision.decision == "block": + anti_abuse_guard.record( + request, + decision=anti_abuse_decision, + tenant_id=current_tenant_id(), + user_id=user_id, + status_code=403, + trace_id=request.headers.get("X-Trace-Id"), + ) + if anti_abuse_guard.events: + persist_security_event( + getattr(store, "_engine", None), + anti_abuse_guard.events[-1], + records_count=0, + ) + return egress_budget_response(anti_abuse_decision) try: response = await call_next(request) response.headers["X-Tenant-Id"] = current_tenant_id() + apply_anti_abuse_headers(response, anti_abuse_decision, mode=resolved_settings.anti_abuse_mode) + anti_abuse_guard.record( + request, + decision=anti_abuse_decision, + tenant_id=current_tenant_id(), + user_id=user_id, + status_code=response.status_code, + trace_id=request.headers.get("X-Trace-Id"), + ) + if anti_abuse_guard.events: + persist_security_event( + getattr(store, "_engine", None), + anti_abuse_guard.events[-1], + records_count=estimated_records_count, + ) return response finally: reset_current_tenant_id(token) @@ -70,6 +155,7 @@ def create_app( register_model_config_routes(app, settings=resolved_settings, store=store) register_prompt_routes(app, settings=resolved_settings, store=store) register_quality_routes(app, settings=resolved_settings, store=store) + register_security_routes(app, settings=resolved_settings, store=store) register_knowledge_routes( app, settings=resolved_settings, diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/models.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/models.py index 6a8a479b..99c1b551 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/models.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/models.py @@ -1,6 +1,6 @@ -from datetime import UTC, datetime +from datetime import UTC, date, datetime -from sqlalchemy import Boolean, DateTime, Float, Integer, String, Text +from sqlalchemy import Boolean, Date, DateTime, Float, Integer, String, Text, UniqueConstraint, Index from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column @@ -391,3 +391,54 @@ class AiQualityImprovementActionModel(Base): index=True, ) resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class SecurityAccessEventModel(Base): + __tablename__ = "security_access_events" + __table_args__ = ( + Index("ix_security_access_events_tenant_created", "tenant_id", "created_at"), + Index("ix_security_access_events_user_created", "user_id", "created_at"), + Index("ix_security_access_events_route_created", "route_key", "created_at"), + Index("ix_security_access_events_decision_created", "decision", "created_at"), + Index("ix_security_access_events_ip_created", "ip_hash", "created_at"), + ) + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True, default=DEFAULT_TENANT_ID) + user_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + session_id_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + ip_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + ua_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) + route_key: Mapped[str] = mapped_column(String(128), nullable=False, index=True) + http_method: Mapped[str] = mapped_column(String(16), nullable=False) + path: Mapped[str] = mapped_column(String(512), nullable=False, default="") + resource_type: Mapped[str | None] = mapped_column(String(64), nullable=True) + resource_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + data_class: Mapped[str] = mapped_column(String(8), nullable=False, index=True) + records_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + bytes_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + decision: Mapped[str] = mapped_column(String(32), nullable=False, index=True) + reason_code: Mapped[str | None] = mapped_column(String(255), nullable=True) + status_code: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + trace_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + + +class DataEgressLedgerModel(Base): + __tablename__ = "data_egress_ledger" + __table_args__ = ( + UniqueConstraint("tenant_id", "user_id", "ledger_date", "data_class", name="ux_data_egress_ledger_subject_day"), + Index("ix_data_egress_ledger_tenant_date", "tenant_id", "ledger_date"), + Index("ix_data_egress_ledger_user_date", "user_id", "ledger_date"), + ) + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True, default=DEFAULT_TENANT_ID) + user_id: Mapped[str] = mapped_column(String(128), nullable=False, default="") + ledger_date: Mapped[date] = mapped_column(Date, nullable=False, index=True) + data_class: Mapped[str] = mapped_column(String(8), nullable=False, index=True) + records_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + files_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + bytes_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + reports_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/prompts.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/prompts.py index fc8481da..cf58f0a2 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/prompts.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/prompts.py @@ -95,6 +95,7 @@ def render_contract_review_prompt( citations: list[LegalCitation], prompt_template: PromptTemplate | None, ) -> str: + structured_output_instructions = _contract_review_output_instructions(citations) variables = { "contract_title": request.contract_title.strip(), "contract_type": (request.contract_type or "").strip() or "未指定", @@ -104,6 +105,7 @@ def render_contract_review_prompt( "citations": _format_citations(citations), "legal_article_refs": _format_citations(citations), "party_position": "未指定审查立场,请按权利义务平衡原则审查", + "structured_output_instructions": structured_output_instructions, } if prompt_template: imported_prompt = _render_v1_prompt_asset( @@ -113,13 +115,15 @@ def render_contract_review_prompt( "补充审查上下文:\n" f"合同名称:{variables['contract_title']}\n" f"相对方:{variables['counterparty']}\n" - f"审查重点:{variables['review_focus']}" + f"审查重点:{variables['review_focus']}\n\n" + f"{structured_output_instructions}" ), ) if imported_prompt: return imported_prompt template = prompt_template.content if prompt_template else _default_contract_review_template() - return _render_template(template, _default_contract_review_template(), variables) + rendered = _render_template(template, _default_contract_review_template(), variables) + return f"{rendered}\n\n{structured_output_instructions}" def _render_v1_prompt_asset( @@ -172,12 +176,35 @@ def _format_citations(citations: list[LegalCitation]) -> str: lines: list[str] = [] for index, citation in enumerate(citations, start=1): + source_ids = ",".join(citation.source_ids) or "none" lines.append( - f"{index}. [{citation.source_type}] {citation.title} {citation.reference}: {citation.quote}" + f"{index}. [{citation.source_type}] [source_ids={source_ids}] " + f"{citation.title} {citation.reference}: {citation.quote}" ) return "\n".join(lines) +def _contract_review_output_instructions(citations: list[LegalCitation]) -> str: + allowed_source_ids = list( + dict.fromkeys( + source_id + for citation in citations + for source_id in citation.source_ids + ) + ) + allowed = ", ".join(allowed_source_ids) or "none" + return ( + "Return exactly one JSON object and no prose or Markdown. " + "Schema: {\"summary\": string, \"risk_level\": \"low|medium|high|critical\", " + "\"risk_items\": [{\"id\": ASCII slug, \"title\": string, " + "\"severity\": \"low|medium|high|critical\", \"suggestion\": string, " + "\"related_clause\": string|null, \"citation_source_ids\": [string]}]}. " + "risk_level must equal the highest risk_items severity. Every risk item must cite at least " + "one retrieved source ID. Never invent a source ID; only use IDs from this allowlist: " + f"{allowed}. If the retrieved sources do not support a finding, do not include it." + ) + + def _default_legal_qa_template() -> str: return ( "你是企业合同法律助手。请基于下列检索依据回答用户问题,必须先给结论,再列主要依据。\n" diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/providers.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/providers.py index 95db200b..333c9781 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/providers.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/providers.py @@ -10,6 +10,9 @@ from yuqei_ai_platform_api.repository import ProviderConfig from yuqei_ai_platform_api.secret_refs import resolve_secret_ref +SYSTEM_PROMPT = "You are a rigorous enterprise contract legal assistant. Base every answer on the supplied sources." + + @dataclass(frozen=True) class ProviderRequest: prompt: str @@ -79,36 +82,26 @@ class OpenAICompatibleProviderAdapter: model=config.model, ) + path, request_payload = self._build_request(request.prompt, config) try: with httpx.Client( timeout=self._settings.provider_request_timeout_seconds, transport=self._transport, ) as client: response = client.post( - f"{base_url}/chat/completions", + f"{base_url}/{path}", headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", }, - json={ - "model": config.model, - "temperature": config.temperature, - "max_tokens": config.max_tokens, - "messages": [ - { - "role": "system", - "content": "你是严谨的企业合同法律助手,回答必须基于给定依据。", - }, - {"role": "user", "content": request.prompt}, - ], - }, + json=request_payload, ) response.raise_for_status() except httpx.HTTPError as exc: return ProviderResponse( answer="", status="failed", - error_message=str(exc), + error_message=f"Provider HTTP request failed ({type(exc).__name__}).", provider=config.provider, model=config.model, ) @@ -131,15 +124,17 @@ class OpenAICompatibleProviderAdapter: provider=config.provider, model=config.model, ) + answer = _extract_answer(payload) usage = _extract_usage(payload) + response_model = str(payload.get("model") or config.model) if not answer: return ProviderResponse( answer="", status="failed", error_message="Provider response has no answer.", provider=config.provider, - model=str(payload.get("model") or config.model) if isinstance(payload, dict) else config.model, + model=response_model, prompt_tokens=usage["prompt_tokens"], completion_tokens=usage["completion_tokens"], total_tokens=usage["total_tokens"], @@ -148,13 +143,31 @@ class OpenAICompatibleProviderAdapter: return ProviderResponse( answer=answer, provider=config.provider, - model=str(payload.get("model") or config.model) if isinstance(payload, dict) else config.model, + model=response_model, prompt_tokens=usage["prompt_tokens"], completion_tokens=usage["completion_tokens"], total_tokens=usage["total_tokens"], estimated_cost=self._estimate_cost(usage), ) + def _build_request(self, prompt: str, config: ProviderConfig) -> tuple[str, dict[str, Any]]: + if self._settings.provider_wire_api == "responses": + return "responses", { + "model": config.model, + "instructions": SYSTEM_PROMPT, + "input": prompt, + "max_output_tokens": config.max_tokens, + } + return "chat/completions", { + "model": config.model, + "temperature": config.temperature, + "max_tokens": config.max_tokens, + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": prompt}, + ], + } + def _resolve_api_key(self, config: ProviderConfig) -> str | None: referenced_key = resolve_secret_ref(config.api_key_secret_ref) if referenced_key: @@ -176,6 +189,28 @@ def build_provider_adapter(settings: AiPlatformSettings) -> ModelProviderAdapter def _extract_answer(payload: dict[str, Any]) -> str: + output_text = payload.get("output_text") + if isinstance(output_text, str) and output_text.strip(): + return output_text.strip() + + output = payload.get("output") + if isinstance(output, list): + parts: list[str] = [] + for item in output: + if not isinstance(item, dict): + continue + content = item.get("content") + if not isinstance(content, list): + continue + for content_item in content: + if not isinstance(content_item, dict): + continue + text = content_item.get("text") + if isinstance(text, str) and text.strip(): + parts.append(text.strip()) + if parts: + return "\n".join(parts) + choices = payload.get("choices") if not isinstance(choices, list) or not choices: return "" @@ -195,8 +230,8 @@ def _extract_usage(payload: dict[str, Any]) -> dict[str, int]: usage = payload.get("usage") if not isinstance(usage, dict): return {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} - prompt_tokens = _coerce_usage_int(usage.get("prompt_tokens")) - completion_tokens = _coerce_usage_int(usage.get("completion_tokens")) + prompt_tokens = _coerce_usage_int(usage.get("prompt_tokens") or usage.get("input_tokens")) + completion_tokens = _coerce_usage_int(usage.get("completion_tokens") or usage.get("output_tokens")) total_tokens = _coerce_usage_int(usage.get("total_tokens")) or prompt_tokens + completion_tokens return { "prompt_tokens": prompt_tokens, diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/release_readiness.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/release_readiness.py new file mode 100644 index 00000000..d0459346 --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/release_readiness.py @@ -0,0 +1,311 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Literal +from urllib.parse import urlparse + +from pydantic import BaseModel, Field + +from yuqei_ai_platform_api.config import AiPlatformSettings +from yuqei_ai_platform_api.golden_samples import ( + GoldenSampleEvaluationReport, + run_golden_sample_evaluation, +) +from yuqei_ai_platform_api.repository import ( + AiPlatformRepository, + AiQualityRuleConfig, + AiRunAudit, + ProviderConfig, +) +from yuqei_ai_platform_api.secret_refs import resolve_secret_ref + + +ReleaseReadinessStatus = Literal["ready", "needs_review", "blocked"] +ReleaseReadinessCheckStatus = Literal["passed", "warning", "failed"] +NON_PRODUCTION_PROVIDER_NAMES = {"disabled", "fake", "local", "mock", "none", "test"} + + +class ReleaseReadinessCheck(BaseModel): + code: str + status: ReleaseReadinessCheckStatus + blocking: bool + message: str + evidence_scope: str + details: dict[str, str | bool | int | float | None] = Field(default_factory=dict) + + +class AiReleaseReadiness(BaseModel): + environment: str + status: ReleaseReadinessStatus + release_allowed: bool + provider_acceptance_verified: bool + generated_at: datetime + summary: str + checks: list[ReleaseReadinessCheck] + + +def evaluate_ai_release_readiness( + *, + settings: AiPlatformSettings, + store: AiPlatformRepository, + acceptance_run_id: str | None = None, + golden_evaluation: GoldenSampleEvaluationReport | None = None, +) -> AiReleaseReadiness: + default_provider = _get_default_provider(store) + quality_rules = store.get_quality_rule_config() + golden_report = golden_evaluation or run_golden_sample_evaluation() + acceptance_run = _get_acceptance_run(store, acceptance_run_id) + + checks = [ + _provider_runtime_check(settings, default_provider), + _golden_asset_check(golden_report), + _citation_policy_check(settings.environment, quality_rules), + _provider_acceptance_check( + environment=settings.environment, + acceptance_run_id=acceptance_run_id, + acceptance_run=acceptance_run, + provider_config=default_provider, + quality_rules=quality_rules, + ), + ] + blocking_failures = [check for check in checks if check.blocking and check.status == "failed"] + attention_checks = [check for check in checks if check.status != "passed"] + if blocking_failures: + status: ReleaseReadinessStatus = "blocked" + elif attention_checks: + status = "needs_review" + else: + status = "ready" + release_allowed = not blocking_failures + acceptance_verified = next( + check.status == "passed" for check in checks if check.code == "provider_acceptance" + ) + return AiReleaseReadiness( + environment=settings.environment, + status=status, + release_allowed=release_allowed, + provider_acceptance_verified=acceptance_verified, + generated_at=datetime.now(UTC), + summary=( + f"{status}: {sum(check.status == 'passed' for check in checks)}/{len(checks)} checks passed, " + f"{len(blocking_failures)} blocking failure(s)." + ), + checks=checks, + ) + + +def _get_default_provider(store: AiPlatformRepository) -> ProviderConfig | None: + try: + return store.get_default_provider_config() + except Exception: # pragma: no cover - backend-specific repository failures. + return None + + +def _get_acceptance_run(store: AiPlatformRepository, run_id: str | None) -> AiRunAudit | None: + if not run_id: + return None + try: + return store.get_ai_run_audit(run_id) + except (KeyError, LookupError): + return None + + +def _provider_runtime_check( + settings: AiPlatformSettings, + provider_config: ProviderConfig | None, +) -> ReleaseReadinessCheck: + strict = settings.environment in {"staging", "production"} + provider_name = (provider_config.provider if provider_config else "").strip().lower() + base_url = (provider_config.base_url if provider_config else None) or settings.provider_base_url + has_credential = _has_resolvable_provider_credential(settings, provider_config) + problems: list[str] = [] + if settings.provider_call_mode != "enabled": + problems.append("provider_call_mode must be enabled") + if settings.provider_fallback_enabled: + problems.append("provider fallback must be disabled") + if provider_config is None or not provider_config.enabled: + problems.append("an enabled default provider is required") + if not provider_name or provider_name in NON_PRODUCTION_PROVIDER_NAMES: + problems.append("a non-placeholder provider is required") + if not _is_http_url(base_url): + problems.append("a valid provider base URL is required") + if not has_credential: + problems.append("a resolvable provider credential is required") + + if problems: + return ReleaseReadinessCheck( + code="provider_runtime", + status="failed" if strict else "warning", + blocking=strict, + message="Provider runtime is not ready for this environment.", + evidence_scope="static_runtime_configuration", + details={ + "provider": provider_name or None, + "model": provider_config.model if provider_config else None, + "provider_call_mode": settings.provider_call_mode, + "provider_fallback_enabled": settings.provider_fallback_enabled, + "base_url_configured": _is_http_url(base_url), + "credential_resolvable": has_credential, + "problems": "; ".join(problems), + }, + ) + return ReleaseReadinessCheck( + code="provider_runtime", + status="passed", + blocking=strict, + message="A non-placeholder provider is enabled with fallback disabled.", + evidence_scope="static_runtime_configuration", + details={ + "provider": provider_name, + "model": provider_config.model if provider_config else None, + "provider_call_mode": settings.provider_call_mode, + "provider_fallback_enabled": settings.provider_fallback_enabled, + "base_url_configured": True, + "credential_resolvable": True, + }, + ) + + +def _golden_asset_check(report: GoldenSampleEvaluationReport) -> ReleaseReadinessCheck: + passed = report.status == "passed" + return ReleaseReadinessCheck( + code="golden_asset_integrity", + status="passed" if passed else "failed", + blocking=True, + message=( + "Golden sample assets and required suite coverage passed offline integrity checks." + if passed + else "Golden sample asset integrity or required suite coverage failed." + ), + evidence_scope="offline_asset_integrity_only", + details={ + "evaluation_id": report.id, + "evaluation_mode": report.mode, + "evaluation_status": report.status, + "total_samples": report.total_samples, + "passed_suites": report.passed_suites, + "total_suites": report.total_suites, + "failed_checks": report.failed_checks, + "verifies_provider_outputs": False, + }, + ) + + +def _citation_policy_check( + environment: str, + quality_rules: AiQualityRuleConfig, +) -> ReleaseReadinessCheck: + strict = environment in {"staging", "production"} + configured = quality_rules.min_citation_count >= 1 and quality_rules.min_source_count >= 1 + return ReleaseReadinessCheck( + code="citation_quality_policy", + status="passed" if configured else ("failed" if strict else "warning"), + blocking=strict, + message=( + "Citation and retrieved-source minimums are configured." + if configured + else "Citation and retrieved-source minimums must both be at least one." + ), + evidence_scope="quality_rule_configuration", + details={ + "min_citation_count": quality_rules.min_citation_count, + "min_source_count": quality_rules.min_source_count, + "verifies_provider_outputs": False, + }, + ) + + +def _provider_acceptance_check( + *, + environment: str, + acceptance_run_id: str | None, + acceptance_run: AiRunAudit | None, + provider_config: ProviderConfig | None, + quality_rules: AiQualityRuleConfig, +) -> ReleaseReadinessCheck: + blocking = environment == "production" + if acceptance_run is None: + return ReleaseReadinessCheck( + code="provider_acceptance", + status="failed" if blocking else "warning", + blocking=blocking, + message=( + "The specified provider acceptance run was not found." + if acceptance_run_id + else "No provider acceptance run was supplied; live provider quality is not verified." + ), + evidence_scope="live_provider_audit_run", + details={ + "acceptance_run_id": acceptance_run_id, + "verified": False, + "problems": "missing acceptance run evidence", + }, + ) + + problems: list[str] = [] + if provider_config is None or acceptance_run.provider_config_id != provider_config.id: + problems.append("run does not use the active default provider configuration") + if provider_config is None or acceptance_run.provider != provider_config.provider: + problems.append("run provider does not match the active default provider") + if provider_config is None or acceptance_run.model != provider_config.model: + problems.append("run model does not match the active default provider model") + if acceptance_run.provider_status != "succeeded" or acceptance_run.status != "succeeded": + problems.append("provider run did not succeed without fallback") + if acceptance_run.quality_status != "passed": + problems.append("provider run quality status is not passed") + if acceptance_run.citations_count < quality_rules.min_citation_count: + problems.append("provider run citation count is below policy") + if len(acceptance_run.retrieved_sources) < quality_rules.min_source_count: + problems.append("provider run retrieved-source count is below policy") + + if problems: + return ReleaseReadinessCheck( + code="provider_acceptance", + status="failed" if blocking else "warning", + blocking=blocking, + message="Provider acceptance evidence does not satisfy the release policy.", + evidence_scope="live_provider_audit_run", + details={ + "acceptance_run_id": acceptance_run.id, + "verified": False, + "provider_status": acceptance_run.provider_status, + "quality_status": acceptance_run.quality_status, + "citations_count": acceptance_run.citations_count, + "retrieved_source_count": len(acceptance_run.retrieved_sources), + "problems": "; ".join(problems), + }, + ) + return ReleaseReadinessCheck( + code="provider_acceptance", + status="passed", + blocking=blocking, + message="The selected live provider audit run satisfies provider and citation quality checks.", + evidence_scope="live_provider_audit_run", + details={ + "acceptance_run_id": acceptance_run.id, + "verified": True, + "provider_status": acceptance_run.provider_status, + "quality_status": acceptance_run.quality_status, + "citations_count": acceptance_run.citations_count, + "retrieved_source_count": len(acceptance_run.retrieved_sources), + }, + ) + + +def _has_resolvable_provider_credential( + settings: AiPlatformSettings, + provider_config: ProviderConfig | None, +) -> bool: + if provider_config is None: + return False + if resolve_secret_ref(provider_config.api_key_secret_ref): + return True + provider_key = settings.provider_api_keys.get(provider_config.provider) + return bool((provider_key or settings.provider_api_key or "").strip()) + + +def _is_http_url(value: str | None) -> bool: + if not value: + return False + parsed = urlparse(value) + return parsed.scheme in {"http", "https"} and bool(parsed.netloc) diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/release_readiness_gate.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/release_readiness_gate.py new file mode 100644 index 00000000..e741c6a1 --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/release_readiness_gate.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import argparse +import json +import sys +from collections.abc import Sequence +from typing import TextIO + +from yuqei_ai_platform_api.config import get_settings +from yuqei_ai_platform_api.release_readiness import AiReleaseReadiness, evaluate_ai_release_readiness +from yuqei_ai_platform_api.state import get_repository + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Evaluate offline AI Platform release readiness without calling a model provider.", + ) + parser.add_argument( + "--acceptance-run-id", + help="Audited live provider run that proves the active provider met citation quality policy.", + ) + parser.add_argument( + "--format", + choices=("json", "text"), + default="text", + help="Output format. Use json for CI artifacts.", + ) + args = parser.parse_args(argv) + + settings = get_settings() + repository = get_repository(settings) + report = evaluate_ai_release_readiness( + settings=settings, + store=repository, + acceptance_run_id=args.acceptance_run_id, + ) + if args.format == "json": + _write_json_report(report, sys.stdout) + else: + _write_text_report(report, sys.stdout) + return 0 if report.release_allowed else 1 + + +def _write_json_report(report: AiReleaseReadiness, output: TextIO) -> None: + json.dump(report.model_dump(mode="json"), output, ensure_ascii=False, indent=2) + output.write("\n") + + +def _write_text_report(report: AiReleaseReadiness, output: TextIO) -> None: + output.write(f"AI Platform release readiness: {report.status}\n") + output.write(f"Environment: {report.environment}\n") + output.write(f"Release allowed: {report.release_allowed}\n") + output.write(f"Provider acceptance verified: {report.provider_acceptance_verified}\n") + output.write(f"Summary: {report.summary}\n") + for check in report.checks: + output.write( + f"- [{check.status}] {check.code} " + f"(blocking={check.blocking}, evidence={check.evidence_scope}): {check.message}\n" + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py index 3a06dbd0..e60c9ea0 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py @@ -24,7 +24,7 @@ from yuqei_ai_platform_api.embeddings import ( LocalHashEmbeddingProvider, tokenize_embedding_text, ) -from yuqei_ai_platform_api.legal_qa import LegalCitation +from yuqei_ai_platform_api.legal_qa import LegalCitation, extract_stable_source_ids from yuqei_ai_platform_api.models import ( AiProviderConfigChangeModel, AiQualityImprovementActionModel, @@ -3421,6 +3421,9 @@ def document_to_citation(document: KnowledgeDocument) -> LegalCitation: title=document.title, reference=document.reference, quote=document.content, + document_id=document.id, + knowledge_base_id=document.knowledge_base_id, + source_ids=extract_stable_source_ids(document.reference), ) @@ -3441,6 +3444,7 @@ def chunk_to_citation(result: KnowledgeSearchResult) -> LegalCitation: hybrid_score=result.hybrid_score, retrieval_strategy=result.retrieval_strategy, matched_terms=result.matched_terms, + source_ids=extract_stable_source_ids(chunk.reference), ) diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/schema_check.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/schema_check.py index 8e634558..fa4406ae 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/schema_check.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/schema_check.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, Field from sqlalchemy import inspect as sqlalchemy_inspect, text from sqlalchemy.engine import Engine +from yuqei_ai_platform_api.anti_abuse import ai_platform_endpoint_risk_policies, anti_abuse_config_status from yuqei_ai_platform_api.repository import build_sqlalchemy_engine @@ -24,6 +25,8 @@ CORE_TABLES = ( "worker_heartbeats", "worker_run_executions", "trace_governance_policies", + "security_access_events", + "data_egress_ledger", "prompt_templates", "prompt_template_changes", "ai_quality_rule_configs", @@ -44,6 +47,8 @@ TENANT_COLUMNS_BY_TABLE = { "ai_run_audits": ("tenant_id",), "ai_quality_improvement_actions": ("tenant_id",), "trace_governance_policies": ("tenant_id",), + "security_access_events": ("tenant_id",), + "data_egress_ledger": ("tenant_id",), } TRACE_GOVERNANCE_COLUMNS_BY_TABLE = { "ai_run_audits": ( @@ -100,22 +105,34 @@ def check_ai_platform_schema( default_provider_config: Any | None = None, service_auth_required: bool | None = None, service_secret_configured: bool | None = None, + anti_abuse_mode: str = "disabled", + redis_url: str = "", + anti_abuse_hash_secret: str = "", ) -> RuntimeSchemaStatus: + checks: list[RuntimeSchemaCheck] = [ + _check_anti_abuse_runtime( + environment=environment, + mode=anti_abuse_mode, + redis_url=redis_url, + hash_secret=anti_abuse_hash_secret, + ) + ] if repository_backend != "sqlalchemy" and engine is None: + checks.insert( + 0, + RuntimeSchemaCheck( + name="repository_backend", + status="warning", + message="Schema check is skipped because AI Platform is not using the SQLAlchemy repository.", + details={"repository_backend": repository_backend}, + ), + ) return RuntimeSchemaStatus( - status="warning", - checks=[ - RuntimeSchemaCheck( - name="repository_backend", - status="warning", - message="Schema check is skipped because AI Platform is not using the SQLAlchemy repository.", - details={"repository_backend": repository_backend}, - ) - ], + status=_aggregate_status(checks), + checks=checks, ) strict = environment in {"staging", "production"} - checks: list[RuntimeSchemaCheck] = [] if provider_call_mode is not None: checks.append( _check_provider_runtime( @@ -206,6 +223,28 @@ def check_ai_platform_schema( return RuntimeSchemaStatus(status=_aggregate_status(checks), checks=checks) +def _check_anti_abuse_runtime( + *, + environment: str, + mode: str, + redis_url: str, + hash_secret: str, +) -> RuntimeSchemaCheck: + status, message, details = anti_abuse_config_status( + mode=mode, # type: ignore[arg-type] + environment=environment, + redis_url=redis_url, + hash_secret=hash_secret, + policy_count=len(ai_platform_endpoint_risk_policies("/api/v1")), + ) + return RuntimeSchemaCheck( + name="anti_abuse_runtime", + status=status, # type: ignore[arg-type] + message=message, + details=details, + ) + + def _check_service_auth( *, environment: str, diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_anti_abuse.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_anti_abuse.py new file mode 100644 index 00000000..ae885ac5 --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_anti_abuse.py @@ -0,0 +1,149 @@ +from datetime import UTC, date, datetime + +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from yuqei_ai_platform_api.config import AiPlatformSettings +from yuqei_ai_platform_api.main import create_app +from yuqei_ai_platform_api.models import DataEgressLedgerModel, SecurityAccessEventModel + + +def make_settings(**overrides) -> AiPlatformSettings: + values = { + "environment": "test", + "repository_backend": "memory", + "anti_abuse_hash_secret": "test-ai-platform-anti-abuse-secret-0001", + } + values.update(overrides) + return AiPlatformSettings(**values) + + +def test_ai_platform_anti_abuse_observe_marks_sensitive_responses() -> None: + client = TestClient(create_app(make_settings(anti_abuse_mode="observe"))) + + response = client.get("/api/v1/audit/ai-runs") + + assert response.status_code == 200 + assert response.headers["X-Anti-Abuse-Mode"] == "observe" + assert response.headers["X-Anti-Abuse-Decision"] in {"allow", "observe"} + assert response.headers["X-Anti-Abuse-Policy"] == "ai_runs.list" + assert response.headers["X-Data-Class"] == "S2" + + +def test_ai_platform_anti_abuse_enforce_blocks_after_policy_limit() -> None: + client = TestClient(create_app(make_settings(anti_abuse_mode="enforce", redis_url="redis://localhost:6379/1"))) + + last_response = None + for _ in range(12): + last_response = client.get("/api/v1/audit/ai-runs/run-missing", headers={"X-User-Id": "user-1"}) + + assert last_response is not None + assert last_response.status_code == 429 + assert last_response.headers["X-Anti-Abuse-Decision"] == "block" + assert last_response.headers["X-Anti-Abuse-Policy"] == "ai_runs.detail" + assert "Retry-After" in last_response.headers + + +def test_ai_platform_schema_check_reports_anti_abuse_runtime() -> None: + client = TestClient(create_app(make_settings(anti_abuse_mode="observe"))) + + response = client.get("/api/v1/runtime/schema-check") + + assert response.status_code == 200 + checks = {item["name"]: item for item in response.json()["checks"]} + assert checks["anti_abuse_runtime"]["status"] == "warning" + assert checks["anti_abuse_runtime"]["details"]["mode"] == "observe" + + +def test_ai_platform_anti_abuse_persists_security_events(tmp_path) -> None: + database_path = tmp_path / "ai-platform.sqlite3" + app = create_app( + make_settings( + repository_backend="sqlalchemy", + database_url=f"sqlite+pysqlite:///{database_path.as_posix()}", + database_auto_create=True, + anti_abuse_mode="observe", + ) + ) + client = TestClient(app) + + response = client.get("/api/v1/audit/ai-runs/run-missing", headers={"X-User-Id": "user-1"}) + + assert response.status_code == 404 + engine = create_engine( + f"sqlite+pysqlite:///{database_path.as_posix()}", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + future=True, + ) + Session = sessionmaker(bind=engine, future=True) + with Session() as session: + assert session.query(SecurityAccessEventModel).count() == 1 + assert session.query(DataEgressLedgerModel).count() == 1 + + +def test_ai_platform_security_admin_endpoints_return_events_and_ledger(tmp_path) -> None: + database_path = tmp_path / "ai-platform-security.sqlite3" + app = create_app( + make_settings( + repository_backend="sqlalchemy", + database_url=f"sqlite+pysqlite:///{database_path.as_posix()}", + database_auto_create=True, + anti_abuse_mode="observe", + ) + ) + client = TestClient(app) + + client.get("/api/v1/audit/ai-runs/run-missing", headers={"X-User-Id": "user-1"}) + events_response = client.get("/api/v1/security/access-events", headers={"X-User-Id": "admin-user"}) + ledger_response = client.get("/api/v1/security/egress-ledger", headers={"X-User-Id": "admin-user"}) + + assert events_response.status_code == 200 + assert events_response.json()["total"] >= 1 + assert events_response.json()["items"][0]["route_key"] == "ai_runs.detail" + assert ledger_response.status_code == 200 + assert ledger_response.json()["total"] >= 1 + + +def test_ai_platform_daily_record_budget_uses_requested_limit(tmp_path) -> None: + database_path = tmp_path / "ai-platform-budget.sqlite3" + database_url = f"sqlite+pysqlite:///{database_path.as_posix()}" + app = create_app( + make_settings( + repository_backend="sqlalchemy", + database_url=database_url, + database_auto_create=True, + anti_abuse_mode="enforce", + anti_abuse_s3_records_daily_limit=100, + ) + ) + engine = create_engine( + database_url, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + future=True, + ) + Session = sessionmaker(bind=engine, future=True) + with Session.begin() as session: + session.add( + DataEgressLedgerModel( + id="egress-ledger-ai-record-budget", + tenant_id="default", + user_id="user-1", + ledger_date=date.today(), + data_class="S3", + records_count=95, + files_count=0, + bytes_count=0, + reports_count=0, + updated_at=datetime.now(UTC), + ) + ) + client = TestClient(app) + + response = client.get("/api/v1/knowledge-documents", params={"limit": 10}, headers={"X-User-Id": "user-1"}) + + assert response.status_code == 403 + assert response.headers["X-Anti-Abuse-Reason"] == "daily_records_budget_exceeded" diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_health.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_health.py index 859fafaf..988092d7 100644 --- a/yuqei-ai-platform/services/ai-platform-api/tests/test_health.py +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_health.py @@ -89,7 +89,7 @@ def test_runtime_schema_check_warns_for_local_sqlite_auto_created_schema(tmp_pat assert checks["alembic_version"]["status"] == "warning" assert checks["core_tables"]["status"] == "ok" assert checks["tenant_columns"]["status"] == "ok" - assert checks["tenant_columns"]["details"]["checked_tables"] == 8 + assert checks["tenant_columns"]["details"]["checked_tables"] == 10 assert checks["trace_governance_columns"]["status"] == "ok" assert checks["trace_governance_columns"]["details"]["checked_columns"] == 11 assert checks["secret_ref_columns"]["status"] == "ok" diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_phase13_provider_gate.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_phase13_provider_gate.py new file mode 100644 index 00000000..122b6e65 --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_phase13_provider_gate.py @@ -0,0 +1,256 @@ +import csv +import json +import logging +import re +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from yuqei_ai_platform_api.config import AiPlatformSettings +from yuqei_ai_platform_api.contract_review import parse_provider_contract_review +from yuqei_ai_platform_api.legal_qa import LegalCitation +from yuqei_ai_platform_api.logging import configure_logging +from yuqei_ai_platform_api.main import create_app +from yuqei_ai_platform_api.providers import ProviderRequest, ProviderResponse +from yuqei_ai_platform_api.repository import ( + InMemoryAiPlatformRepository, + KnowledgeDocumentCreate, + ProviderConfigCreate, + SqlAlchemyAiPlatformRepository, + chunk_to_citation, +) + + +PHASE13_ROOT = ( + Path(__file__).resolve().parents[1] + / "src" + / "yuqei_ai_platform_api" + / "golden_assets" + / "v1_phase13_legal_knowledge_template_library" +) +KNOWLEDGE_BASE_ID = "phase13-golden" + + +def _rows(name: str) -> list[dict[str, str]]: + with (PHASE13_ROOT / "inventories" / name).open(encoding="utf-8-sig", newline="") as stream: + return [dict(row) for row in csv.DictReader(stream)] + + +def _split_ids(value: str) -> list[str]: + return [item.strip() for item in value.split(";") if item.strip()] + + +def _knowledge_content(row: dict[str, str]) -> str: + return "\n".join( + ( + f"知识卡编号:{row['knowledge_card_id']}", + f"法律问题:{row['legal_question']}", + f"适用场景:{row['applicable_scenarios']}", + f"不适用场景:{row['non_applicable_scenarios']}", + f"依据范围:{row['original_reference']}", + f"回答边界:{row['answer_boundary']}", + "本知识卡待法务复核,不是法条原文,也不代表法律批准。", + ) + ) + + +class OfflinePhase13Provider: + def __init__(self, review_rows: list[dict[str, str]]) -> None: + self.review_by_id = {row["sample_id"]: row for row in review_rows} + + def generate(self, request: ProviderRequest) -> ProviderResponse: + source_ids = list( + dict.fromkeys(re.findall(r"\bLAW-[A-Z0-9]+(?:-[A-Z0-9]+)*\b", request.prompt)) + ) + sample_id = next((key for key in self.review_by_id if key in request.prompt), None) + if sample_id is None: + answer = "已基于检索到的待复核知识卡回答;具体个案仍需法务复核。" + else: + row = self.review_by_id[sample_id] + expected_ids = _split_ids(row["expected_source_citations"]) + cited_ids = [source_id for source_id in expected_ids if source_id in source_ids] + answer = json.dumps( + { + "summary": "已识别合同风险;结论需由法务结合原始法源复核。", + "risk_level": row["expected_severity"], + "risk_items": [ + { + "id": f"risk-{sample_id.lower()}", + "title": row["risk_categories"], + "severity": row["expected_severity"], + "suggestion": "按检索知识卡补充条款并进入人工复核。", + "related_clause": row["risk_categories"], + "citation_source_ids": cited_ids, + } + ], + }, + ensure_ascii=False, + ) + return ProviderResponse( + answer=answer, + status="succeeded", + provider="offline-phase13", + model="deterministic-structured-test", + prompt_tokens=100, + completion_tokens=50, + total_tokens=150, + ) + + +def test_phase13_offline_gate_runs_all_10_qa_and_8_review_cases(monkeypatch) -> None: + knowledge_rows = _rows("legal-knowledge-card-inventory.csv") + qa_rows = _rows("kb-qa-golden-sample-inventory.csv") + review_rows = _rows("review-golden-sample-inventory.csv") + repository = InMemoryAiPlatformRepository() + repository.seed_defaults() + for row in knowledge_rows: + repository.add_knowledge_document( + KnowledgeDocumentCreate( + knowledge_base_id=KNOWLEDGE_BASE_ID, + title=f"{row['knowledge_card_id']} {row['title']}", + source_type="v1_phase13_knowledge_card_pending_legal_review", + reference=";".join(_split_ids(row["source_ids"])), + content=_knowledge_content(row), + ) + ) + repository.upsert_provider_config( + ProviderConfigCreate( + provider="offline-phase13", + model="deterministic-structured-test", + display_name="Offline Phase13", + base_url="https://offline.invalid/v1", + enabled=True, + is_default=True, + ) + ) + provider = OfflinePhase13Provider(review_rows) + monkeypatch.setattr("yuqei_ai_platform_api.main.build_provider_adapter", lambda _settings: provider) + client = TestClient( + create_app( + AiPlatformSettings(environment="test", provider_call_mode="enabled"), + repository=repository, + ) + ) + + qa_responses = [] + for row in qa_rows: + response = client.post( + "/api/v1/legal/qa", + json={"question": row["question"], "knowledge_base_ids": [KNOWLEDGE_BASE_ID]}, + ) + assert response.status_code == 200 + payload = response.json() + observed = {source_id for item in payload["citations"] for source_id in item["source_ids"]} + assert observed & set(_split_ids(row["expected_source_ids"])) + qa_responses.append(payload) + + review_responses = [] + for row in review_rows: + response = client.post( + "/api/v1/legal/contract-review", + json={ + "contract_title": row["sample_id"], + "contract_text": (PHASE13_ROOT / row["input_sample_path"]).read_text(encoding="utf-8"), + "contract_type": row["contract_type"], + "review_focus": row["risk_categories"], + "knowledge_base_ids": [KNOWLEDGE_BASE_ID], + }, + ) + assert response.status_code == 200 + payload = response.json() + expected_ids = set(_split_ids(row["expected_source_citations"])) + assert payload["risk_level"] == row["expected_severity"] + assert expected_ids & set(payload["risk_items"][0]["citation_source_ids"]) + assert expected_ids & { + source_id for item in payload["citations"] for source_id in item["source_ids"] + } + review_responses.append(payload) + + audits = client.get("/api/v1/audit/ai-runs?limit=100").json() + assert len(knowledge_rows) == 10 + assert len(qa_responses) == 10 + assert len(review_responses) == 8 + assert len(audits) == 18 + assert all(audit["status"] == "succeeded" for audit in audits) + assert all(audit["provider_status"] == "succeeded" for audit in audits) + + +@pytest.mark.parametrize("backend", ["memory", "sql"]) +def test_stable_source_ids_do_not_replace_internal_document_ids(tmp_path: Path, backend: str) -> None: + if backend == "memory": + repository = InMemoryAiPlatformRepository() + else: + database_url = f"sqlite+pysqlite:///{(tmp_path / 'phase13.sqlite').as_posix()}" + repository = SqlAlchemyAiPlatformRepository(database_url) + document = repository.add_knowledge_document( + KnowledgeDocumentCreate( + knowledge_base_id=KNOWLEDGE_BASE_ID, + title="电子签名证据知识卡", + source_type="v1_phase13_knowledge_card_pending_legal_review", + reference="LAW-E-SIGN-001;LAW-CIVIL-001", + content="电子签名和合同证据留存应进入人工复核。", + ) + ) + + result = repository.search_knowledge_chunks( + "电子签名证据", + knowledge_base_ids=[KNOWLEDGE_BASE_ID], + limit=1, + )[0] + citation = chunk_to_citation(result) + + assert document.id.startswith("doc-") + assert citation.document_id == document.id + assert citation.source_ids == ["LAW-E-SIGN-001", "LAW-CIVIL-001"] + + +def test_provider_contract_review_rejects_unretrieved_or_free_text_citations() -> None: + citation = LegalCitation( + source_type="knowledge_card", + title="格式条款知识卡", + reference="LAW-CIVIL-001", + quote="格式条款需要提示说明并保留确认记录。", + document_id="doc-random", + source_ids=["LAW-CIVIL-001"], + ) + valid_payload = { + "summary": "存在格式条款提示说明风险。", + "risk_level": "high", + "risk_items": [ + { + "id": "risk-format-clause", + "title": "格式条款提示不足", + "severity": "high", + "suggestion": "增加醒目提示并保留单独确认记录。", + "related_clause": "免责条款", + "citation_source_ids": ["LAW-CIVIL-001"], + } + ], + } + + parsed = parse_provider_contract_review( + json.dumps(valid_payload, ensure_ascii=False), + retrieved_citations=[citation], + ) + assert parsed is not None + assert parsed.risk_items[0].citation_source_ids == ["LAW-CIVIL-001"] + assert parsed.citations[0].document_id == "doc-random" + + invented = json.loads(json.dumps(valid_payload)) + invented["risk_items"][0]["citation_source_ids"] = ["LAW-INVENTED-001"] + assert parse_provider_contract_review( + json.dumps(invented), + retrieved_citations=[citation], + ) is None + assert parse_provider_contract_review( + "审查结论:该条款高风险,请直接批准。", + retrieved_citations=[citation], + ) is None + + +def test_provider_http_loggers_do_not_emit_endpoint_urls_at_info() -> None: + configure_logging("INFO") + + assert logging.getLogger("httpx").getEffectiveLevel() >= logging.WARNING + assert logging.getLogger("httpcore").getEffectiveLevel() >= logging.WARNING diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_flow.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_flow.py index 7ed73a2f..771942e6 100644 --- a/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_flow.py +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_flow.py @@ -201,6 +201,58 @@ def test_openai_compatible_provider_extracts_chat_answer() -> None: assert response.estimated_cost == 0.00028 +def test_openai_compatible_provider_supports_responses_wire_api() -> None: + repository = InMemoryAiPlatformRepository() + created = repository.upsert_provider_config( + ProviderConfigCreate( + provider="openai-compatible", + model="legal-responses", + display_name="Legal Responses", + base_url="https://ai.example.test/v1", + enabled=True, + is_default=True, + ) + ) + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/v1/responses" + request_payload = json.loads(request.read()) + assert request_payload["input"] == "hello" + assert request_payload["max_output_tokens"] == 4096 + assert "messages" not in request_payload + return httpx.Response( + 200, + json={ + "model": "legal-responses", + "output": [ + { + "type": "message", + "content": [{"type": "output_text", "text": "Responses OK"}], + } + ], + "usage": {"input_tokens": 14, "output_tokens": 9, "total_tokens": 23}, + }, + ) + + adapter = OpenAICompatibleProviderAdapter( + AiPlatformSettings( + environment="test", + provider_call_mode="enabled", + provider_wire_api="responses", + provider_api_key="test-key", + ), + transport=httpx.MockTransport(handler), + ) + + response = adapter.generate(ProviderRequest(prompt="hello", provider_config=created)) + + assert response.status == "succeeded" + assert response.answer == "Responses OK" + assert response.prompt_tokens == 14 + assert response.completion_tokens == 9 + assert response.total_tokens == 23 + + def test_openai_compatible_provider_resolves_config_secret_ref(monkeypatch) -> None: monkeypatch.setenv("AI_PROVIDER_DEEPSEEK_KEY", "secret-ref-key") repository = InMemoryAiPlatformRepository() diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_release_readiness.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_release_readiness.py new file mode 100644 index 00000000..feee15b4 --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_release_readiness.py @@ -0,0 +1,244 @@ +import json +import os +import subprocess +import sys +from pathlib import Path + +from fastapi.testclient import TestClient + +from yuqei_ai_platform_api.config import AiPlatformSettings +from yuqei_ai_platform_api.main import create_app +from yuqei_ai_platform_api.repository import ( + AiRunAudit, + InMemoryAiPlatformRepository, + ProviderConfigCreate, + RetrievalSource, +) + + +def _client( + environment: str, + *, + provider_call_mode: str = "disabled", + provider_fallback_enabled: bool = True, + provider_api_key: str | None = None, +) -> tuple[TestClient, InMemoryAiPlatformRepository]: + repository = InMemoryAiPlatformRepository() + repository.seed_defaults() + settings = AiPlatformSettings( + environment=environment, + provider_call_mode=provider_call_mode, + provider_fallback_enabled=provider_fallback_enabled, + provider_api_key=provider_api_key, + ) + return TestClient(create_app(settings, repository=repository)), repository + + +def _configure_real_provider(repository: InMemoryAiPlatformRepository): + return repository.upsert_provider_config( + ProviderConfigCreate( + provider="openai-compatible", + model="legal-model-v1", + display_name="Production legal model", + base_url="https://provider.invalid/v1", + enabled=True, + is_default=True, + ) + ) + + +def _acceptance_run(provider_config_id: str) -> AiRunAudit: + sources = [ + RetrievalSource( + chunk_id=f"chunk-{index}", + title=f"Source {index}", + source_type="law", + reference=f"LAW-{index}", + ) + for index in range(1, 3) + ] + return AiRunAudit( + id="provider-acceptance-001", + operation="legal_qa", + provider_config_id=provider_config_id, + provider="openai-compatible", + model="legal-model-v1", + provider_status="succeeded", + citations_count=2, + retrieved_sources=sources, + total_tokens=100, + quality_status="passed", + quality_score=100, + status="succeeded", + ) + + +def test_local_readiness_is_non_blocking_but_does_not_claim_provider_acceptance() -> None: + client, _ = _client("local") + + response = client.get("/api/v1/quality/release-readiness") + + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == "needs_review" + assert payload["release_allowed"] is True + assert payload["provider_acceptance_verified"] is False + checks = {item["code"]: item for item in payload["checks"]} + assert checks["provider_runtime"]["status"] == "warning" + assert checks["provider_runtime"]["blocking"] is False + assert checks["golden_asset_integrity"]["status"] == "passed" + assert checks["golden_asset_integrity"]["details"]["verifies_provider_outputs"] is False + assert checks["provider_acceptance"]["status"] == "warning" + + +def test_staging_readiness_blocks_placeholder_provider_configuration() -> None: + client, _ = _client("staging") + + payload = client.get("/api/v1/quality/release-readiness").json() + + assert payload["status"] == "blocked" + assert payload["release_allowed"] is False + checks = {item["code"]: item for item in payload["checks"]} + assert checks["provider_runtime"]["status"] == "failed" + assert "non-placeholder provider" in checks["provider_runtime"]["details"]["problems"] + assert checks["provider_acceptance"]["status"] == "warning" + assert checks["provider_acceptance"]["blocking"] is False + + +def test_staging_can_deploy_configured_provider_before_live_acceptance_is_selected() -> None: + client, repository = _client( + "staging", + provider_call_mode="enabled", + provider_fallback_enabled=False, + provider_api_key="configured-but-not-called", + ) + _configure_real_provider(repository) + + payload = client.get("/api/v1/quality/release-readiness").json() + + assert payload["status"] == "needs_review" + assert payload["release_allowed"] is True + assert payload["provider_acceptance_verified"] is False + checks = {item["code"]: item for item in payload["checks"]} + assert checks["provider_runtime"]["status"] == "passed" + assert checks["provider_acceptance"]["status"] == "warning" + assert checks["provider_acceptance"]["blocking"] is False + + +def test_production_readiness_blocks_provider_fallback_and_missing_acceptance() -> None: + client, repository = _client( + "production", + provider_call_mode="enabled", + provider_fallback_enabled=True, + provider_api_key="configured-but-not-called", + ) + _configure_real_provider(repository) + + payload = client.get("/api/v1/quality/release-readiness").json() + + assert payload["status"] == "blocked" + assert payload["release_allowed"] is False + assert payload["provider_acceptance_verified"] is False + checks = {item["code"]: item for item in payload["checks"]} + assert "provider fallback must be disabled" in checks["provider_runtime"]["details"]["problems"] + assert checks["provider_acceptance"]["status"] == "failed" + assert checks["provider_acceptance"]["details"]["verified"] is False + + +def test_production_readiness_accepts_explicit_matching_audit_evidence_without_network() -> None: + client, repository = _client( + "production", + provider_call_mode="enabled", + provider_fallback_enabled=False, + provider_api_key="configured-but-not-called", + ) + provider = _configure_real_provider(repository) + repository.add_ai_run_audit(_acceptance_run(provider.id)) + + response = client.get( + "/api/v1/quality/release-readiness", + params={"acceptance_run_id": "provider-acceptance-001"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == "ready" + assert payload["release_allowed"] is True + assert payload["provider_acceptance_verified"] is True + checks = {item["code"]: item for item in payload["checks"]} + assert checks["provider_runtime"]["status"] == "passed" + assert checks["citation_quality_policy"]["status"] == "passed" + assert checks["provider_acceptance"]["status"] == "passed" + assert checks["provider_acceptance"]["evidence_scope"] == "live_provider_audit_run" + + +def test_production_readiness_rejects_fallback_or_low_quality_audit_evidence() -> None: + client, repository = _client( + "production", + provider_call_mode="enabled", + provider_fallback_enabled=False, + provider_api_key="configured-but-not-called", + ) + provider = _configure_real_provider(repository) + repository.add_ai_run_audit( + _acceptance_run(provider.id).model_copy( + update={ + "id": "provider-acceptance-fallback", + "provider_status": "failed", + "status": "fallback", + "quality_status": "needs_review", + "citations_count": 0, + "retrieved_sources": [], + } + ) + ) + + payload = client.get( + "/api/v1/quality/release-readiness", + params={"acceptance_run_id": "provider-acceptance-fallback"}, + ).json() + + assert payload["status"] == "blocked" + assert payload["release_allowed"] is False + check = next(item for item in payload["checks"] if item["code"] == "provider_acceptance") + assert check["status"] == "failed" + assert "without fallback" in check["details"]["problems"] + assert "citation count is below policy" in check["details"]["problems"] + + +def test_release_readiness_gate_returns_environment_specific_ci_exit_codes() -> None: + service_root = Path(__file__).resolve().parents[1] + env = os.environ.copy() + env["PYTHONPATH"] = str(service_root / "src") + os.pathsep + env.get("PYTHONPATH", "") + env["PYTHONIOENCODING"] = "utf-8" + env["AI_PLATFORM_REPOSITORY_BACKEND"] = "memory" + + env["AI_PLATFORM_ENVIRONMENT"] = "local" + local = subprocess.run( + [sys.executable, "-m", "yuqei_ai_platform_api.release_readiness_gate", "--format", "json"], + cwd=service_root, + env=env, + capture_output=True, + text=True, + encoding="utf-8", + timeout=30, + check=False, + ) + assert local.returncode == 0, local.stderr + assert json.loads(local.stdout)["release_allowed"] is True + + env["AI_PLATFORM_ENVIRONMENT"] = "production" + production = subprocess.run( + [sys.executable, "-m", "yuqei_ai_platform_api.release_readiness_gate", "--format", "json"], + cwd=service_root, + env=env, + capture_output=True, + text=True, + encoding="utf-8", + timeout=30, + check=False, + ) + assert production.returncode == 1 + payload = json.loads(production.stdout) + assert payload["status"] == "blocked" + assert payload["release_allowed"] is False diff --git a/yuqei-contract-platform/apps/contract-web/next.config.ts b/yuqei-contract-platform/apps/contract-web/next.config.ts index ce199092..572e97c3 100644 --- a/yuqei-contract-platform/apps/contract-web/next.config.ts +++ b/yuqei-contract-platform/apps/contract-web/next.config.ts @@ -1,6 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + allowedDevOrigins: ["127.0.0.1"], transpilePackages: ["@yuqei/design-system"] }; diff --git a/yuqei-contract-platform/apps/contract-web/package.json b/yuqei-contract-platform/apps/contract-web/package.json index 434698ae..f1e5eb2f 100644 --- a/yuqei-contract-platform/apps/contract-web/package.json +++ b/yuqei-contract-platform/apps/contract-web/package.json @@ -6,6 +6,9 @@ "dev": "next dev -p 3000", "build": "next build", "start": "next start -p 3000", + "test:auth-entry-routing": "node --no-warnings --experimental-strip-types --test src/lib/auth-entry-routing.test.mjs", + "test:identity-session": "node --no-warnings --experimental-strip-types --test src/lib/identity-session-mapping.test.mjs", + "test:workspace-access": "node --no-warnings --experimental-strip-types --test src/lib/workspace-access-state.test.mjs", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/yuqei-contract-platform/apps/contract-web/src/app/admin/identity/conflicts/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/admin/identity/conflicts/page.tsx new file mode 100644 index 00000000..ff3a8d84 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/app/admin/identity/conflicts/page.tsx @@ -0,0 +1,263 @@ +import Link from "next/link"; +import { AlertTriangle, ArrowLeft, CheckCircle2, Search, ShieldCheck } from "lucide-react"; +import { + getAccountConflict, + getAccountConflicts, + type AccountConflictCode, + type AccountConflictDetail, + type AccountConflictItem, + type AccountConflictSeverity, +} from "@/services/contracts"; + +type PageProps = { + searchParams?: Promise<{ + page?: string; + page_size?: string; + severity?: string; + conflict_code?: string; + email?: string; + id?: string; + }>; +}; + +const conflictLabels: Record = { + duplicate_global_accounts: "重复全局账号", + missing_global_account: "缺少全局账号", + missing_membership: "缺少工作空间成员关系", + orphan_membership: "孤立成员关系", + membership_identity_mismatch: "成员身份不一致", + duplicate_membership_mapping: "重复成员映射", + membership_state_drift: "成员状态漂移", + owner_flag_drift: "所有者标记漂移", + disabled_account_with_active_membership: "停用账号仍有有效成员关系", + credential_identity_mismatch: "凭据身份不一致", + unsupported_credential_scheme: "不支持的凭据算法", + active_identity_without_credential: "有效账号缺少凭据", + legacy_multi_credential_unified_missing: "历史多凭据尚未归并", +}; + +const severityLabels: Record = { + critical: "严重", + warning: "提醒", +}; + +export default async function AccountConflictPage({ searchParams }: PageProps) { + const query = await searchParams; + const page = parsePositiveInt(query?.page, 1); + const pageSize = Math.min(parsePositiveInt(query?.page_size, 20), 100); + const severity = isSeverity(query?.severity) ? query?.severity : undefined; + const conflictCode = isConflictCode(query?.conflict_code) ? query?.conflict_code : undefined; + const email = query?.email?.trim() || undefined; + const [conflictPage, detail] = await Promise.all([ + getAccountConflicts({ page, page_size: pageSize, severity, conflict_code: conflictCode, email }), + query?.id ? getAccountConflict(query.id) : Promise.resolve(null), + ]); + + if (!conflictPage) { + return ( + <> +
+
+

账号冲突诊断

+

该工具仅供平台管理员查看,企业工作空间管理员不会看到跨工作空间身份数据。

+
+
+
+ + 当前会话无法读取平台诊断 + 请确认当前账号具有平台管理员权限,或稍后重试诊断服务。 + 返回身份管理 +
+ + ); + } + + const currentQuery = buildQuery({ page, page_size: pageSize, severity, conflict_code: conflictCode, email }); + const totalPages = Math.max(1, Math.ceil(conflictPage.total / conflictPage.page_size)); + return ( + <> +
+
+

账号冲突诊断

+

只读检查全局账号、工作空间成员关系和凭据映射,不执行合并、拆分或恢复操作。

+
+ + 返回身份管理 + +
+ +
+ + 全部 + + + 严重 {conflictPage.critical_total} + + + 提醒 {conflictPage.warning_total} + +
+ +
+ + + + + + 重置 +
+ +
+ 0 ? "danger" : undefined} /> + + +
+ +
+
+
+
+

Read-only diagnostics

+

待处理身份冲突

+
+ 第 {conflictPage.page} 页 +
+
+ {conflictPage.items.length === 0 ? ( +
未发现符合条件的冲突当前筛选范围内没有需要平台运营介入的身份异常。
+ ) : ( +
+ {conflictPage.items.map((item) => )} +
+ )} + {totalPages > 1 && ( +
+ {page > 1 && 上一页} + {page} / {totalPages} + {page < totalPages && 下一页} +
+ )} +
+
+ +
+
+
+

Evidence

+

冲突详情

+
+
+
+ {detail ? :
未选择诊断详情。
} +
+
+
+ + ); +} + +function ConflictRow({ item, query }: { item: AccountConflictItem; query: URLSearchParams }) { + return ( + + +
+ {item.normalized_email ?? "无邮箱实体组"} +
{item.conflict_codes.map((code) => conflictLabels[code]).join("、")}
+
{item.account_count} 个账号 / {item.user_count} 个成员 / {item.workspace_count} 个工作空间
+
+ {severityLabels[item.severity]} + + ); +} + +function ConflictDetail({ detail }: { detail: AccountConflictDetail }) { + return ( +
+
+ {detail.normalized_email ?? "无邮箱实体组"} + {detail.conflict_codes.map((code) => conflictLabels[code]).join("、")} +
+ {detail.issues.map((issue) => ( +
+ +
{conflictLabels[issue.conflict_code]}
{issue.entity_type}: {issue.entity_ids.join(", ")}
+ {severityLabels[issue.severity]} +
+ ))} +
全局凭据:{detail.credential_coverage.global_active ? "存在" : "缺失"};历史凭据:{detail.credential_coverage.legacy_present} 条;缺少凭据的有效成员:{detail.credential_coverage.active_users_without_credential} 人
+ {detail.accounts.map((account) => ( +
+ +
{account.display_name}
{account.email} / {account.id}
+ {account.status} +
+ ))} + {detail.users.map((user) => ( +
+ +
{user.display_name}
{user.email} / {user.tenant_id} / {user.id}
+ {user.deleted ? "已删除" : user.status} +
+ ))} + {detail.memberships.map((membership) => ( +
+ +
工作空间成员关系
{membership.tenant_id} / {membership.tenant_user_id} / {membership.account_id}
+ {membership.status} +
+ ))} + {detail.credentials.map((credential) => ( +
+ +
{credential.scope === "global" ? "全局凭据" : "历史工作空间凭据"}
{credential.id} / {credential.scheme} / {credential.status}
+ {credential.supported ? "支持" : "需处理"} +
+ ))} +
+ ); +} + +function Metric({ icon: Icon, label, value, tone }: { icon: typeof AlertTriangle; label: string; value: number; tone?: "danger" }) { + return
{label}{value}
; +} + +function isSeverity(value: string | undefined): value is AccountConflictSeverity { + return value === "critical" || value === "warning"; +} + +function isConflictCode(value: string | undefined): value is AccountConflictCode { + return Boolean(value && value in conflictLabels); +} + +function parsePositiveInt(value: string | undefined, fallback: number) { + const parsed = Number.parseInt(value ?? "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function buildQuery(params: { + page: number; + page_size: number; + severity?: AccountConflictSeverity; + conflict_code?: AccountConflictCode; + email?: string; +}) { + const query = new URLSearchParams(); + query.set("page", String(params.page)); + query.set("page_size", String(params.page_size)); + if (params.severity) query.set("severity", params.severity); + if (params.conflict_code) query.set("conflict_code", params.conflict_code); + if (params.email) query.set("email", params.email); + return query; +} + +function withFilter(query: URLSearchParams, patch: Record) { + const next = new URLSearchParams(query); + for (const [key, value] of Object.entries(patch)) { + if (value) next.set(key, value); + else next.delete(key); + } + return next.toString(); +} diff --git a/yuqei-contract-platform/apps/contract-web/src/app/admin/identity/page-content.test.mjs b/yuqei-contract-platform/apps/contract-web/src/app/admin/identity/page-content.test.mjs new file mode 100644 index 00000000..20cf179a --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/app/admin/identity/page-content.test.mjs @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const pageSource = await readFile(new URL("./page.tsx", import.meta.url), "utf8"); + +test("organization workspace creation does not expose a default administrator password", () => { + assert.doesNotMatch(pageSource, /admin654321/i); + assert.match(pageSource, /admin_password:\s*""/); + assert.match(pageSource, /autoComplete="new-password"/); + assert.match(pageSource, /placeholder="组织名称"/); + assert.match(pageSource, />\s*创建组织工作空间\s*\s*创建公司\s*([]); const [users, setUsers] = useState([]); const [roles, setRoles] = useState({ roles: [] }); + const [platformAdministrators, setPlatformAdministrators] = useState(null); const [account, setAccount] = useState(null); const [selectedTenantId, setSelectedTenantId] = useState("default"); const [loading, setLoading] = useState(true); @@ -116,8 +121,6 @@ export default function IdentityAdminPage() { }); const [accountDraft, setAccountDraft] = useState({ display_name: "", - email: "", - role: "admin" as UserRole, locale: "zh-CN", timezone: "Asia/Shanghai", }); @@ -159,24 +162,24 @@ export default function IdentityAdminPage() { async function loadInitialData() { setLoading(true); try { - const [tenantPage, rolePage, accountSettings, identityCapabilities] = await Promise.all([ + const [tenantPage, rolePage, accountSettings, identityCapabilities, platformAdministratorPage] = await Promise.all([ getIdentityTenants(), getIdentityRoles(), getAccountSettings(), getIdentityCapabilities().catch(() => null), + getPlatformAdministrators(), ]); setCapabilities(identityCapabilities); setTenants(tenantPage.items); setRoles(rolePage); setAccount(accountSettings); + setPlatformAdministrators(platformAdministratorPage); if (tenantPage.items[0]?.id) { setSelectedTenantId(tenantPage.items[0].id); } if (accountSettings) { setAccountDraft({ display_name: accountSettings.display_name, - email: accountSettings.email, - role: accountSettings.role, locale: accountSettings.locale, timezone: accountSettings.timezone, }); @@ -259,9 +262,9 @@ export default function IdentityAdminPage() { admin_department: "", admin_title: "", }); - setMessage("公司租户和首个管理员已创建。"); + setMessage("组织工作空间和首个管理员已创建。"); } catch (error) { - setMessage(error instanceof Error ? error.message : "公司租户创建失败。"); + setMessage(error instanceof Error ? error.message : "组织工作空间创建失败。"); } finally { setSaving(null); } @@ -281,9 +284,9 @@ export default function IdentityAdminPage() { } const updated = await response.json() as TenantInfo; setTenants((items) => items.map((item) => (item.id === updated.id ? updated : item))); - setMessage("租户状态已更新。"); + setMessage("工作空间状态已更新。"); } catch (error) { - setMessage(error instanceof Error ? error.message : "租户更新失败。"); + setMessage(error instanceof Error ? error.message : "工作空间更新失败。"); } finally { setSaving(null); } @@ -359,10 +362,17 @@ export default function IdentityAdminPage() { body: JSON.stringify({ ...patch, updated_by: account?.display_name ?? "admin" }), }); if (!response.ok) { + if (response.status === 409 && patch.status === "disabled") { + throw new Error("至少需要保留一名有效平台管理员,无法停用最后一名管理员。"); + } throw new Error(await readError(response)); } const updated = await response.json() as TenantUserItem; setUsers((items) => items.map((item) => (item.id === updated.id ? updated : item))); + const platformAdministratorPage = await getPlatformAdministrators(); + if (platformAdministratorPage) { + setPlatformAdministrators(platformAdministratorPage); + } setMessage("用户权限已更新。"); } catch (error) { setMessage(error instanceof Error ? error.message : "用户更新失败。"); @@ -379,7 +389,7 @@ export default function IdentityAdminPage() { const response = await fetch("/api/contract-platform/identity/account", { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...accountDraft, updated_by: accountDraft.display_name }), + body: JSON.stringify(accountDraft), }); if (!response.ok) { throw new Error(await readError(response)); @@ -388,8 +398,6 @@ export default function IdentityAdminPage() { setAccount(updated); setAccountDraft({ display_name: updated.display_name, - email: updated.email, - role: updated.role, locale: updated.locale, timezone: updated.timezone, }); @@ -520,11 +528,13 @@ export default function IdentityAdminPage() {
-

用户与租户

-

租户、账号、角色和启停状态集中管理。

+

用户与工作空间

+

工作空间、账号、角色和启停状态集中管理。

- 当前租户:{selectedTenant?.name ?? selectedTenantId} + 当前管理空间:{selectedTenant + ? workspaceDisplayName(selectedTenant.id, selectedTenant.name) + : selectedTenantId}
@@ -544,12 +554,47 @@ export default function IdentityAdminPage() {
)} + {platformAdministrators && ( +
+
+
+

Platform administrators

+

平台管理员名册

+

+ 只读治理视图;共 {platformAdministrators.total} 人,当前有效 {platformAdministrators.active_total} 人。 +

+
+ + + 账号冲突诊断 + +
+
+ {platformAdministrators.items.map((administrator) => ( +
+
+ {administrator.display_name} +

{administrator.email}

+

+ {workspaceDisplayName(administrator.tenant_id, administrator.tenant_name)} / {administrator.tenant_id} +

+
+ {getRoleLabel(administrator.role)} + + {userStatusLabels[administrator.status]} + +
+ ))} +
+
+ )} +
-

Tenants

-

租户设置

+

Workspaces

+

工作空间设置

@@ -562,7 +607,7 @@ export default function IdentityAdminPage() { type="button" > - {tenant.name} + {workspaceDisplayName(tenant.id, tenant.name)} {tenant.id} / {tenant.plan ?? "-"} @@ -572,22 +617,22 @@ export default function IdentityAdminPage() { ))}
-
+
- 新建公司租户 + 新建组织工作空间
setTenantDraft((draft) => ({ ...draft, name: event.target.value }))} - placeholder="公司名称" + placeholder="组织名称" required />
setTenantDraft((draft) => ({ ...draft, id: event.target.value }))} - placeholder="租户 ID,可留空" + placeholder="工作空间 ID,可留空" /> setTenantDraft((draft) => ({ ...draft, admin_password: event.target.value }))} - placeholder="管理员初始密码,至少 8 位" + autoComplete="new-password" + name="organization-admin-initial-password" + placeholder="管理员初始密码,至少 12 位" type="password" - minLength={8} + minLength={12} + maxLength={128} required />
- 这里创建的是一个公司数据空间;首个管理员会自动归属该公司,后续新增员工也会加入当前选中的租户。 + 这里创建的是组织工作空间;首个管理员会自动归属该组织,后续新增员工也会加入当前选中的工作空间。
@@ -649,13 +697,17 @@ export default function IdentityAdminPage() {

Users

-

{selectedTenant?.name ?? "租户"} / 用户账号

+

+ {selectedTenant + ? workspaceDisplayName(selectedTenant.id, selectedTenant.name) + : "工作空间"} / 成员账号 +

{selectedTenant && (
- 租户状态 + 工作空间状态 setAccountDraft((draft) => ({ ...draft, email: event.target.value }))} - placeholder="邮箱" + aria-label="登录邮箱" + readOnly + value={account?.email ?? ""} + placeholder="登录邮箱" type="email" - required />
- + setAccountDraft((draft) => ({ ...draft, timezone: event.target.value }))} placeholder="时区" />
+

登录邮箱由账号验证流程管理,成员角色由工作空间管理员管理。

{emailChallenge?.dev_code && ( @@ -891,7 +937,9 @@ export default function IdentityAdminPage() {

- 当前配置租户:{selectedTenant?.name ?? selectedTenantId}。先保存 SAML/OIDC 元数据入口,正式启用前再接入回调校验和 IdP 联调。 + 当前配置空间:{selectedTenant + ? workspaceDisplayName(selectedTenant.id, selectedTenant.name) + : selectedTenantId}。先保存 SAML/OIDC 元数据入口,正式启用前再接入回调校验和 IdP 联调。

diff --git a/yuqei-contract-platform/apps/contract-web/src/app/admin/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/admin/page.tsx index 65438adc..d7809a94 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/admin/page.tsx +++ b/yuqei-contract-platform/apps/contract-web/src/app/admin/page.tsx @@ -1,7 +1,8 @@ import Link from "next/link"; import type { LucideIcon } from "lucide-react"; import { Activity, Bot, BookOpen, BookOpenText, ClipboardList, FileText, Scale, ServerCog, Settings, ShieldCheck, SlidersHorizontal, CheckSquare, UsersRound } from "lucide-react"; -import { canAccessAdminCapability, getRoleLabel, resolveCurrentRole, type AdminCapability } from "@/lib/access-control"; +import { canAccessAdminCapability, getRoleLabel, normalizeUserRole, type AdminCapability } from "@/lib/access-control"; +import { getContractWebSession } from "@/lib/contract-session"; type AdminSection = { capability: AdminCapability; @@ -14,18 +15,25 @@ type AdminSection = { const adminSections: AdminSection[] = [ { capability: "identity_management", - title: "用户与租户", - description: "管理租户状态、账号角色、用户启停和当前账号偏好设置。", + title: "用户与工作空间", + description: "管理工作空间状态、成员角色、账号启停和当前账号偏好设置。", icon: UsersRound, href: "/admin/identity" }, { capability: "audit_center", title: "全局审计日志", - description: "聚合合同创建、审批、下载、审查和报告交付事件,按租户追溯关键操作。", + description: "聚合合同创建、审批、下载、审查和报告交付事件,按工作空间追溯关键操作。", icon: ClipboardList, href: "/admin/audit" }, + { + capability: "security_center", + title: "数据安全中心", + description: "查看安全访问事件和数据出站账本,定位异常下载、批量抓取和预算阻断。", + icon: ShieldCheck, + href: "/admin/security" + }, { capability: "enterprise_permissions", title: "企业权限", @@ -117,8 +125,9 @@ const adminSections: AdminSection[] = [ } ]; -export default function AdminPage() { - const currentRole = resolveCurrentRole(); +export default async function AdminPage() { + const session = await getContractWebSession(); + const currentRole = normalizeUserRole(session?.role); const visibleSections = adminSections.filter((section) => canAccessAdminCapability(section.capability, currentRole)); return ( diff --git a/yuqei-contract-platform/apps/contract-web/src/app/admin/security/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/admin/security/page.tsx new file mode 100644 index 00000000..2ff9226b --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/app/admin/security/page.tsx @@ -0,0 +1,483 @@ +import Link from "next/link"; +import { AlertTriangle, DatabaseZap, Download, Route, ShieldCheck, UserSearch } from "lucide-react"; +import { + getDataEgressLedger, + getSecurityAccessEvents, + type DataEgressLedgerItem, + type SecurityAccessEventItem, + type SecuritySource, +} from "@/services/contracts"; + +type PageProps = { + searchParams?: Promise<{ + source?: string; + page?: string; + page_size?: string; + tenant_id?: string; + user_id?: string; + route_key?: string; + decision?: string; + data_class?: string; + ledger_date?: string; + created_from?: string; + created_to?: string; + }>; +}; + +const sourceLabels: Record = { + contract: "Contract API", + ai: "AI Platform", +}; + +const sourceOptions: SecuritySource[] = ["contract", "ai"]; + +const decisionLabels: Record = { + allow: "放行", + observe: "观察", + block: "阻断", +}; + +const decisionTone: Record = { + allow: "tag-success", + observe: "tag-warning", + block: "tag-danger", +}; + +export default async function AdminSecurityPage({ searchParams }: PageProps) { + const query = await searchParams; + const source = normalizeSource(query?.source); + const page = parsePositiveInt(query?.page, 1); + const pageSize = Math.min(parsePositiveInt(query?.page_size, 20), 100); + const baseQuery = { + page, + page_size: pageSize, + tenant_id: query?.tenant_id, + user_id: query?.user_id, + route_key: query?.route_key, + decision: query?.decision, + data_class: query?.data_class, + ledger_date: query?.ledger_date, + created_from: query?.created_from, + created_to: query?.created_to, + }; + const [eventsPage, ledgerPage] = await Promise.all([ + getSecurityAccessEvents(source, baseQuery), + getDataEgressLedger(source, baseQuery), + ]); + const blockedCount = eventsPage.items.filter((item) => item.decision === "block").length; + const observedCount = eventsPage.items.filter((item) => item.decision === "observe").length; + const bytesTotal = ledgerPage.items.reduce((sum, item) => sum + item.bytes_count, 0); + const fileTotal = ledgerPage.items.reduce((sum, item) => sum + item.files_count, 0); + const routeRiskRows = buildRouteRiskRows(eventsPage.items); + const subjectEgressRows = buildSubjectEgressRows(ledgerPage.items); + const currentQuery = buildQueryParams(query, source); + const today = formatDateInput(new Date()); + + return ( + <> +
+
+

数据安全中心

+

查看高风险接口访问事件和每日数据出站账本,定位异常下载、批量抓取和预算阻断。

+
+
+ +
+ {sourceOptions.map((option) => ( + + {sourceLabels[option]} + + ))} + + 只看阻断 + + + 只看 S4 + + + 今日出站 + +
+ +
+ + + + +
+ + + + + + + + + + + + 重置 + + +
+
+
+

路由风险排行

+ 当前页 Top {routeRiskRows.length} +
+
+ {routeRiskRows.length === 0 ? ( +
暂无可聚合的路由风险。
+ ) : ( +
+ {routeRiskRows.map((row) => ( +
+ +
+ + {row.route_key} + +
+ 阻断 {row.block} / 观察 {row.observe} / 放行 {row.allow},{row.records_count} 条,{formatBytes(row.bytes_count)} +
+
+ 0 ? "tag-danger" : row.observe > 0 ? "tag-warning" : "tag-success"}`}> + {row.score} + +
+ ))} +
+ )} +
+
+ +
+
+

出站主体排行

+ 当前页 Top {subjectEgressRows.length} +
+
+ {subjectEgressRows.length === 0 ? ( +
暂无可聚合的出站主体。
+ ) : ( +
+ {subjectEgressRows.map((row) => ( +
+ +
+ + {row.user_id || "匿名/服务"} + +
+ {row.tenant_id} / {row.data_classes.join(", ")},{row.records_count} 条,{row.files_count} 个文件,{formatBytes(row.bytes_count)} +
+
+ {row.ledger_dates[0] ?? "-"} +
+ ))} +
+ )} +
+
+
+ +
+
+

安全访问事件

+ {sourceLabels[source]} / 共 {eventsPage.total} 条 +
+
+ {eventsPage.items.length === 0 ? ( +
暂无匹配的安全访问事件。
+ ) : ( +
+ + + + + + + + + + + + + + + {eventsPage.items.map((item) => ( + + + + + + + + + + + ))} + +
时间决策路由用户数据响应原因Trace
{formatDateTime(item.created_at)}{decisionLabels[item.decision] ?? item.decision} +
+ + {item.route_key} + + {item.http_method} {item.path} +
+
+
+ + {item.user_id || "匿名/服务"} + + + {item.tenant_id} + +
+
{item.data_class} / {item.records_count} 条 / {formatBytes(item.bytes_count)}{item.status_code}{item.reason_code || "-"}{item.trace_id || item.ip_hash?.slice(0, 12) || "-"}
+
+ )} +
+
+ +
+
+

数据出站账本

+ 当前页 {formatBytes(bytesTotal)} / {fileTotal} 个文件 +
+
+ {ledgerPage.items.length === 0 ? ( +
暂无匹配的数据出站账本。
+ ) : ( +
+ + + + + + + + + + + + + + + {ledgerPage.items.map((item) => ( + + + + + + + + + + + ))} + +
日期租户 / 用户级别记录数文件数字节数报告数更新时间
{item.ledger_date} +
+ + {item.tenant_id} + + + {item.user_id || "匿名/服务"} + +
+
{item.data_class}{item.records_count}{item.files_count}{formatBytes(item.bytes_count)}{item.reports_count}{formatDateTime(item.updated_at)}
+
+ )} +
+
+ +
+ + 上一页 + + 第 {page} 页 + + 下一页 + + + 20 条/页 + + + 50 条/页 + + + 100 条/页 + +
+ + ); +} + +function Metric({ icon: Icon, label, value }: { icon: typeof ShieldCheck; label: string; value: number; tone?: "danger" }) { + return ( +
+ + {value} + {label} +
+ ); +} + +function normalizeSource(value: string | undefined): SecuritySource { + return value === "ai" ? "ai" : "contract"; +} + +function parsePositiveInt(value: string | undefined, fallback: number) { + const parsed = Number.parseInt(value ?? "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function formatDateTime(value: string) { + return new Intl.DateTimeFormat("zh-CN", { + dateStyle: "short", + timeStyle: "short", + hour12: false, + }).format(new Date(value)); +} + +function formatBytes(value: number) { + if (value < 1024) { + return `${value} B`; + } + if (value < 1024 * 1024) { + return `${(value / 1024).toFixed(1)} KB`; + } + return `${(value / 1024 / 1024).toFixed(1)} MB`; +} + +function buildQueryParams(query: Awaited, source: SecuritySource) { + const params = new URLSearchParams(); + params.set("source", source); + for (const key of ["tenant_id", "user_id", "route_key", "decision", "data_class", "ledger_date", "created_from", "created_to", "page_size"]) { + const value = query?.[key as keyof NonNullable]; + if (value) { + params.set(key, value); + } + } + return params; +} + +function withSource(params: URLSearchParams, source: SecuritySource) { + const next = new URLSearchParams(params); + next.set("source", source); + next.delete("page"); + return next.toString(); +} + +function withPage(params: URLSearchParams, page: number) { + const next = new URLSearchParams(params); + next.set("page", String(page)); + return next.toString(); +} + +function withPageSize(params: URLSearchParams, pageSize: number) { + const next = new URLSearchParams(params); + next.set("page_size", String(pageSize)); + next.delete("page"); + return next.toString(); +} + +function withFilter(params: URLSearchParams, filters: Record) { + const next = new URLSearchParams(params); + for (const [key, value] of Object.entries(filters)) { + if (value) { + next.set(key, value); + } else { + next.delete(key); + } + } + next.delete("page"); + return next.toString(); +} + +function formatDateInput(value: Date) { + return [ + value.getFullYear(), + String(value.getMonth() + 1).padStart(2, "0"), + String(value.getDate()).padStart(2, "0"), + ].join("-"); +} + +function buildRouteRiskRows(items: SecurityAccessEventItem[]) { + const rows = new Map(); + for (const item of items) { + const row = rows.get(item.route_key) ?? { + route_key: item.route_key, + allow: 0, + observe: 0, + block: 0, + records_count: 0, + bytes_count: 0, + score: 0, + }; + if (item.decision === "block") row.block += 1; + else if (item.decision === "observe") row.observe += 1; + else row.allow += 1; + row.records_count += item.records_count; + row.bytes_count += item.bytes_count; + row.score = row.block * 10 + row.observe * 4 + row.records_count + Math.ceil(row.bytes_count / 1024 / 1024); + rows.set(item.route_key, row); + } + return Array.from(rows.values()).sort((a, b) => b.score - a.score).slice(0, 5); +} + +function buildSubjectEgressRows(items: DataEgressLedgerItem[]) { + const rows = new Map(); + for (const item of items) { + const key = `${item.tenant_id}:${item.user_id}`; + const row = rows.get(key) ?? { + key, + tenant_id: item.tenant_id, + user_id: item.user_id, + records_count: 0, + files_count: 0, + bytes_count: 0, + data_classes: [], + ledger_dates: [], + }; + row.records_count += item.records_count; + row.files_count += item.files_count; + row.bytes_count += item.bytes_count; + if (!row.data_classes.includes(item.data_class)) row.data_classes.push(item.data_class); + if (!row.ledger_dates.includes(item.ledger_date)) row.ledger_dates.push(item.ledger_date); + rows.set(key, row); + } + return Array.from(rows.values()).sort((a, b) => b.bytes_count - a.bytes_count).slice(0, 5); +} diff --git a/yuqei-contract-platform/apps/contract-web/src/app/ai-disclaimer/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/ai-disclaimer/page.tsx new file mode 100644 index 00000000..fcd355b6 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/app/ai-disclaimer/page.tsx @@ -0,0 +1,28 @@ +import { LegalPolicyPage, PolicyParagraph } from "@/components/legal-policy-page"; + +export default function AiDisclaimerPage() { + return ( + AI 能力用于发现可能的风险、遗漏和修改方向,不保证识别全部问题,也不保证输出适用于所有地区、行业或具体交易背景。, + }, + { + title: "依据与置信度", + content: 用户应检查引用来源、适用时间、条文效力和事实匹配程度。缺少可靠依据或置信度较低的结果必须进行人工复核。, + }, + { + title: "高风险事项", + content: 诉讼仲裁、重大投融资、担保、数据跨境、劳动争议、监管许可和其他高风险事项不应仅凭 AI 输出作出决定。, + }, + { + title: "用户责任", + content: 用户应提供必要且准确的事实背景,并由具备相应权限和专业能力的人员确认最终合同文本和处理决定。, + }, + ]} + /> + ); +} diff --git a/yuqei-contract-platform/apps/contract-web/src/app/api/ai-platform/security/access-events/route.ts b/yuqei-contract-platform/apps/contract-web/src/app/api/ai-platform/security/access-events/route.ts new file mode 100644 index 00000000..12adef1e --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/app/api/ai-platform/security/access-events/route.ts @@ -0,0 +1,12 @@ +import { buildAiPlatformApiUrl, fetchAiPlatform } from "@/lib/ai-platform-api"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const targetUrl = buildAiPlatformApiUrl("/api/v1/security/access-events"); + for (const [key, value] of searchParams.entries()) { + targetUrl.searchParams.set(key, value); + } + const response = await fetchAiPlatform(targetUrl); + const payload: unknown = await response.json().catch(() => null); + return Response.json(payload, { status: response.status }); +} diff --git a/yuqei-contract-platform/apps/contract-web/src/app/api/ai-platform/security/egress-ledger/route.ts b/yuqei-contract-platform/apps/contract-web/src/app/api/ai-platform/security/egress-ledger/route.ts new file mode 100644 index 00000000..c300d067 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/app/api/ai-platform/security/egress-ledger/route.ts @@ -0,0 +1,12 @@ +import { buildAiPlatformApiUrl, fetchAiPlatform } from "@/lib/ai-platform-api"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const targetUrl = buildAiPlatformApiUrl("/api/v1/security/egress-ledger"); + for (const [key, value] of searchParams.entries()) { + targetUrl.searchParams.set(key, value); + } + const response = await fetchAiPlatform(targetUrl); + const payload: unknown = await response.json().catch(() => null); + return Response.json(payload, { status: response.status }); +} diff --git a/yuqei-contract-platform/apps/contract-web/src/app/api/auth/agreements/current/route.ts b/yuqei-contract-platform/apps/contract-web/src/app/api/auth/agreements/current/route.ts new file mode 100644 index 00000000..1bd9b587 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/app/api/auth/agreements/current/route.ts @@ -0,0 +1,16 @@ +import { buildContractApiUrl, fetchContractApi } from "@/lib/contract-api"; + +export async function GET() { + try { + const response = await fetchContractApi(buildContractApiUrl("/api/v1/identity/agreements/current"), { + cache: "no-store", + }); + const payload: unknown = await response.json().catch(() => null); + return Response.json(payload, { status: response.status }); + } catch (error) { + return Response.json( + { message: error instanceof Error ? error.message : "协议目录暂时不可用。" }, + { status: 502 }, + ); + } +} diff --git a/yuqei-contract-platform/apps/contract-web/src/app/api/auth/email-verification/confirm/route.ts b/yuqei-contract-platform/apps/contract-web/src/app/api/auth/email-verification/confirm/route.ts new file mode 100644 index 00000000..7fc57fca --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/app/api/auth/email-verification/confirm/route.ts @@ -0,0 +1,61 @@ +import { buildContractApiUrl, buildIdentityProxyHeaders, fetchContractApi } from "@/lib/contract-api"; +import { getContractWebSession, setContractWebSession } from "@/lib/contract-session"; +import { + isIdentitySessionPayload, + toContractWebSession, + type IdentitySessionPayload, +} from "@/lib/identity-session-mapping"; + +type VerificationResultPayload = { + verified: boolean; + session?: IdentitySessionPayload | null; +}; + +export async function POST(request: Request) { + const currentSession = await getContractWebSession(); + if (!currentSession) { + return Response.json({ message: "请先登录。" }, { status: 401 }); + } + const requestPayload = await request.json().catch(() => null) as { + challenge_id?: unknown; + code?: unknown; + } | null; + if ( + !requestPayload + || typeof requestPayload.challenge_id !== "string" + || typeof requestPayload.code !== "string" + ) { + return Response.json({ message: "验证码信息不完整。" }, { status: 400 }); + } + try { + const response = await fetchContractApi(buildContractApiUrl("/api/v1/identity/email-verification/confirm"), { + method: "POST", + headers: buildIdentityProxyHeaders(request, { "Content-Type": "application/json" }), + body: JSON.stringify({ + challenge_id: requestPayload.challenge_id, + code: requestPayload.code, + }), + cache: "no-store", + }); + const payload = await response.json().catch(() => null) as VerificationResultPayload | { detail?: string; message?: string } | null; + if (!response.ok) { + return Response.json(payload ?? { message: "邮箱验证失败。" }, { status: response.status || 502 }); + } + if (!payload || !("verified" in payload) || !payload.verified || !isIdentitySessionPayload(payload.session)) { + return Response.json({ message: "邮箱已验证,但账号会话刷新失败,请重新登录。" }, { status: 502 }); + } + if ( + payload.session.user.id !== currentSession.user_id + || payload.session.tenant.id !== currentSession.tenant_id + ) { + return Response.json({ message: "邮箱验证会话与当前账号不一致。" }, { status: 403 }); + } + await setContractWebSession(toContractWebSession(payload.session)); + return Response.json(payload, { status: response.status }); + } catch (error) { + return Response.json( + { message: error instanceof Error ? error.message : "邮箱验证失败,请稍后重试。" }, + { status: 502 }, + ); + } +} diff --git a/yuqei-contract-platform/apps/contract-web/src/app/api/auth/email-verification/send/route.ts b/yuqei-contract-platform/apps/contract-web/src/app/api/auth/email-verification/send/route.ts new file mode 100644 index 00000000..dfdef9f5 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/app/api/auth/email-verification/send/route.ts @@ -0,0 +1,24 @@ +import { buildContractApiUrl, buildIdentityProxyHeaders, fetchContractApi } from "@/lib/contract-api"; +import { getContractWebSession } from "@/lib/contract-session"; + +export async function POST(request: Request) { + const session = await getContractWebSession(); + if (!session) { + return Response.json({ message: "请先登录。" }, { status: 401 }); + } + try { + const response = await fetchContractApi(buildContractApiUrl("/api/v1/identity/email-verification/send"), { + method: "POST", + headers: buildIdentityProxyHeaders(request, { "Content-Type": "application/json" }), + body: JSON.stringify({ email: session.email }), + cache: "no-store", + }); + const payload: unknown = await response.json().catch(() => null); + return Response.json(payload ?? { message: "验证码发送失败。" }, { status: response.status || 502 }); + } catch (error) { + return Response.json( + { message: error instanceof Error ? error.message : "验证码发送失败,请稍后重试。" }, + { status: 502 }, + ); + } +} diff --git a/yuqei-contract-platform/apps/contract-web/src/app/api/auth/invite/accept/route.ts b/yuqei-contract-platform/apps/contract-web/src/app/api/auth/invite/accept/route.ts index f677a5f0..d6ed1d08 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/api/auth/invite/accept/route.ts +++ b/yuqei-contract-platform/apps/contract-web/src/app/api/auth/invite/accept/route.ts @@ -1,18 +1,6 @@ import { buildContractApiUrl, fetchContractApi } from "@/lib/contract-api"; import { setContractWebSession } from "@/lib/contract-session"; - -type IdentitySessionPayload = { - tenant: { - id: string; - name: string; - }; - user: { - id: string; - email: string; - display_name: string; - role: string; - }; -}; +import { isIdentitySessionPayload, toContractWebSession } from "@/lib/identity-session-mapping"; export async function POST(request: Request) { const body = await request.text(); @@ -22,17 +10,10 @@ export async function POST(request: Request) { body, cache: "no-store", }); - const payload = await response.json().catch(() => null) as IdentitySessionPayload | { detail?: string; message?: string } | null; - if (!response.ok || !payload || !("tenant" in payload) || !("user" in payload)) { + const payload: unknown = await response.json().catch(() => null); + if (!response.ok || !isIdentitySessionPayload(payload)) { return Response.json(payload ?? { message: "接受邀请失败。" }, { status: response.status || 502 }); } - await setContractWebSession({ - tenant_id: payload.tenant.id, - tenant_name: payload.tenant.name, - user_id: payload.user.id, - email: payload.user.email, - display_name: payload.user.display_name, - role: payload.user.role, - }); + await setContractWebSession(toContractWebSession(payload)); return Response.json(payload, { status: response.status }); } diff --git a/yuqei-contract-platform/apps/contract-web/src/app/api/auth/login/route.ts b/yuqei-contract-platform/apps/contract-web/src/app/api/auth/login/route.ts index 83eb7b98..86b7a68b 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/api/auth/login/route.ts +++ b/yuqei-contract-platform/apps/contract-web/src/app/api/auth/login/route.ts @@ -1,18 +1,6 @@ import { buildContractApiUrl, buildIdentityProxyHeaders, fetchContractApi } from "@/lib/contract-api"; import { setContractWebSession } from "@/lib/contract-session"; - -type IdentitySessionPayload = { - tenant: { - id: string; - name: string; - }; - user: { - id: string; - email: string; - display_name: string; - role: string; - }; -}; +import { isIdentitySessionPayload, toContractWebSession } from "@/lib/identity-session-mapping"; export async function POST(request: Request) { const body = await request.text(); @@ -22,17 +10,10 @@ export async function POST(request: Request) { body, cache: "no-store", }); - const payload = await response.json().catch(() => null) as IdentitySessionPayload | { detail?: string; message?: string } | null; - if (!response.ok || !payload || !("tenant" in payload) || !("user" in payload)) { + const payload: unknown = await response.json().catch(() => null); + if (!response.ok || !isIdentitySessionPayload(payload)) { return Response.json(payload ?? { message: "登录失败。" }, { status: response.status || 502 }); } - await setContractWebSession({ - tenant_id: payload.tenant.id, - tenant_name: payload.tenant.name, - user_id: payload.user.id, - email: payload.user.email, - display_name: payload.user.display_name, - role: payload.user.role, - }); + await setContractWebSession(toContractWebSession(payload)); return Response.json(payload, { status: response.status }); } diff --git a/yuqei-contract-platform/apps/contract-web/src/app/api/auth/password-reset/confirm/route.ts b/yuqei-contract-platform/apps/contract-web/src/app/api/auth/password-reset/confirm/route.ts index 7aa6b0c4..e38af472 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/api/auth/password-reset/confirm/route.ts +++ b/yuqei-contract-platform/apps/contract-web/src/app/api/auth/password-reset/confirm/route.ts @@ -1,18 +1,6 @@ import { buildContractApiUrl, fetchContractApi } from "@/lib/contract-api"; import { setContractWebSession } from "@/lib/contract-session"; - -type IdentitySessionPayload = { - tenant: { - id: string; - name: string; - }; - user: { - id: string; - email: string; - display_name: string; - role: string; - }; -}; +import { isIdentitySessionPayload, toContractWebSession } from "@/lib/identity-session-mapping"; export async function POST(request: Request) { const body = await request.text(); @@ -22,17 +10,10 @@ export async function POST(request: Request) { body, cache: "no-store", }); - const payload = await response.json().catch(() => null) as IdentitySessionPayload | { detail?: string; message?: string } | null; - if (!response.ok || !payload || !("tenant" in payload) || !("user" in payload)) { + const payload: unknown = await response.json().catch(() => null); + if (!response.ok || !isIdentitySessionPayload(payload)) { return Response.json(payload ?? { message: "密码重置失败。" }, { status: response.status || 502 }); } - await setContractWebSession({ - tenant_id: payload.tenant.id, - tenant_name: payload.tenant.name, - user_id: payload.user.id, - email: payload.user.email, - display_name: payload.user.display_name, - role: payload.user.role, - }); + await setContractWebSession(toContractWebSession(payload)); return Response.json(payload, { status: response.status }); } diff --git a/yuqei-contract-platform/apps/contract-web/src/app/api/auth/register/personal/route.ts b/yuqei-contract-platform/apps/contract-web/src/app/api/auth/register/personal/route.ts new file mode 100644 index 00000000..3e81e40c --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/app/api/auth/register/personal/route.ts @@ -0,0 +1,26 @@ +import { buildContractApiUrl, buildIdentityProxyHeaders, fetchContractApi } from "@/lib/contract-api"; +import { setContractWebSession } from "@/lib/contract-session"; +import { isIdentitySessionPayload, toContractWebSession } from "@/lib/identity-session-mapping"; + +export async function POST(request: Request) { + try { + const body = await request.text(); + const response = await fetchContractApi(buildContractApiUrl("/api/v1/identity/register-personal"), { + method: "POST", + headers: buildIdentityProxyHeaders(request, { "Content-Type": "application/json" }), + body, + cache: "no-store", + }); + const payload: unknown = await response.json().catch(() => null); + if (!response.ok || !isIdentitySessionPayload(payload)) { + return Response.json(payload ?? { message: "个人工作空间创建失败。" }, { status: response.status || 502 }); + } + await setContractWebSession(toContractWebSession(payload)); + return Response.json(payload, { status: response.status }); + } catch (error) { + return Response.json( + { message: error instanceof Error ? error.message : "个人工作空间创建失败,请稍后重试。" }, + { status: 502 }, + ); + } +} diff --git a/yuqei-contract-platform/apps/contract-web/src/app/api/auth/register/route.ts b/yuqei-contract-platform/apps/contract-web/src/app/api/auth/register/route.ts index 69fa0c07..f70f8db4 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/api/auth/register/route.ts +++ b/yuqei-contract-platform/apps/contract-web/src/app/api/auth/register/route.ts @@ -1,38 +1,26 @@ -import { buildContractApiUrl, fetchContractApi } from "@/lib/contract-api"; +import { buildContractApiUrl, buildIdentityProxyHeaders, fetchContractApi } from "@/lib/contract-api"; import { setContractWebSession } from "@/lib/contract-session"; - -type IdentitySessionPayload = { - tenant: { - id: string; - name: string; - }; - user: { - id: string; - email: string; - display_name: string; - role: string; - }; -}; +import { isIdentitySessionPayload, toContractWebSession } from "@/lib/identity-session-mapping"; export async function POST(request: Request) { - const body = await request.text(); - const response = await fetchContractApi(buildContractApiUrl("/api/v1/identity/register-company"), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body, - cache: "no-store", - }); - const payload = await response.json().catch(() => null) as IdentitySessionPayload | { detail?: string; message?: string } | null; - if (!response.ok || !payload || !("tenant" in payload) || !("user" in payload)) { - return Response.json(payload ?? { message: "公司注册失败。" }, { status: response.status || 502 }); + try { + const body = await request.text(); + const response = await fetchContractApi(buildContractApiUrl("/api/v1/identity/register-company"), { + method: "POST", + headers: buildIdentityProxyHeaders(request, { "Content-Type": "application/json" }), + body, + cache: "no-store", + }); + const payload: unknown = await response.json().catch(() => null); + if (!response.ok || !isIdentitySessionPayload(payload)) { + return Response.json(payload ?? { message: "组织工作空间创建失败。" }, { status: response.status || 502 }); + } + await setContractWebSession(toContractWebSession(payload)); + return Response.json(payload, { status: response.status }); + } catch (error) { + return Response.json( + { message: error instanceof Error ? error.message : "组织工作空间创建失败,请稍后重试。" }, + { status: 502 }, + ); } - await setContractWebSession({ - tenant_id: payload.tenant.id, - tenant_name: payload.tenant.name, - user_id: payload.user.id, - email: payload.user.email, - display_name: payload.user.display_name, - role: payload.user.role, - }); - return Response.json(payload, { status: response.status }); } diff --git a/yuqei-contract-platform/apps/contract-web/src/app/api/auth/workspaces/route.ts b/yuqei-contract-platform/apps/contract-web/src/app/api/auth/workspaces/route.ts new file mode 100644 index 00000000..b3a842aa --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/app/api/auth/workspaces/route.ts @@ -0,0 +1,20 @@ +import { buildContractApiUrl, fetchContractApi } from "@/lib/contract-api"; +import { getContractWebSession } from "@/lib/contract-session"; + +export async function GET() { + try { + if (!await getContractWebSession()) { + return Response.json({ message: "请先登录。" }, { status: 401 }); + } + const response = await fetchContractApi(buildContractApiUrl("/api/v1/identity/workspaces"), { + cache: "no-store", + }); + const payload: unknown = await response.json().catch(() => null); + return Response.json(payload, { status: response.status }); + } catch (error) { + return Response.json( + { message: error instanceof Error ? error.message : "工作空间列表暂时不可用。" }, + { status: 502 }, + ); + } +} diff --git a/yuqei-contract-platform/apps/contract-web/src/app/api/auth/workspaces/switch/route.ts b/yuqei-contract-platform/apps/contract-web/src/app/api/auth/workspaces/switch/route.ts new file mode 100644 index 00000000..3ebf36cd --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/app/api/auth/workspaces/switch/route.ts @@ -0,0 +1,29 @@ +import { buildContractApiUrl, fetchContractApi } from "@/lib/contract-api"; +import { getContractWebSession, setContractWebSession } from "@/lib/contract-session"; +import { isIdentitySessionPayload, toContractWebSession } from "@/lib/identity-session-mapping"; + +export async function POST(request: Request) { + try { + if (!await getContractWebSession()) { + return Response.json({ message: "请先登录。" }, { status: 401 }); + } + const body = await request.text(); + const response = await fetchContractApi(buildContractApiUrl("/api/v1/identity/workspaces/switch"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + cache: "no-store", + }); + const payload: unknown = await response.json().catch(() => null); + if (!response.ok || !isIdentitySessionPayload(payload)) { + return Response.json(payload ?? { message: "工作空间切换失败。" }, { status: response.status || 502 }); + } + await setContractWebSession(toContractWebSession(payload)); + return Response.json(payload, { status: response.status }); + } catch (error) { + return Response.json( + { message: error instanceof Error ? error.message : "工作空间切换失败,请稍后重试。" }, + { status: 502 }, + ); + } +} diff --git a/yuqei-contract-platform/apps/contract-web/src/app/api/contract-platform/security/access-events/route.ts b/yuqei-contract-platform/apps/contract-web/src/app/api/contract-platform/security/access-events/route.ts new file mode 100644 index 00000000..1604003e --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/app/api/contract-platform/security/access-events/route.ts @@ -0,0 +1,10 @@ +import { buildContractApiUrl, proxyContractApiResponse } from "@/lib/contract-api"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const targetUrl = buildContractApiUrl("/api/v1/security/access-events"); + for (const [key, value] of searchParams.entries()) { + targetUrl.searchParams.set(key, value); + } + return proxyContractApiResponse(targetUrl); +} diff --git a/yuqei-contract-platform/apps/contract-web/src/app/api/contract-platform/security/egress-ledger/route.ts b/yuqei-contract-platform/apps/contract-web/src/app/api/contract-platform/security/egress-ledger/route.ts new file mode 100644 index 00000000..f1edc559 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/app/api/contract-platform/security/egress-ledger/route.ts @@ -0,0 +1,10 @@ +import { buildContractApiUrl, proxyContractApiResponse } from "@/lib/contract-api"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const targetUrl = buildContractApiUrl("/api/v1/security/egress-ledger"); + for (const [key, value] of searchParams.entries()) { + targetUrl.searchParams.set(key, value); + } + return proxyContractApiResponse(targetUrl); +} diff --git a/yuqei-contract-platform/apps/contract-web/src/app/contracts/[id]/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/contracts/[id]/page.tsx index ac74c7c4..f7a6c3a6 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/contracts/[id]/page.tsx +++ b/yuqei-contract-platform/apps/contract-web/src/app/contracts/[id]/page.tsx @@ -273,6 +273,9 @@ export default async function ContractDetailPage({ params }: PageProps) { {contract.files.length + contract.review_tasks.length}
+
+ AI 审查结果仅用于风险识别和修改参考,不替代律师、企业法务或管理层结合完整材料作出的最终判断。 +
{contract.permissions.can_start_review && } {contract.permissions.can_upload_file && ( @@ -331,7 +334,7 @@ export default async function ContractDetailPage({ params }: PageProps) { {text.riskLevel}: {task.risk_level} )} {typeof task.confidence === "number" && ( - {text.confidence}: {formatPercent(task.confidence)} + {text.confidence}: {formatPercent(task.confidence)} )}
@@ -461,6 +464,13 @@ export default async function ContractDetailPage({ params }: PageProps) { ); } +function reviewConfidenceTone(value: number) { + if (value < 0.6) { + return "tag-warning"; + } + return value >= 0.8 ? "tag-success" : "tag-primary"; +} + function formatTimelineComment(comment: string) { const normalized = comment.trim(); return contractTypeCommentLabels[normalized] ?? comment; diff --git a/yuqei-contract-platform/apps/contract-web/src/app/contracts/template-draft-panel.tsx b/yuqei-contract-platform/apps/contract-web/src/app/contracts/template-draft-panel.tsx index 1d447955..f8c5596b 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/contracts/template-draft-panel.tsx +++ b/yuqei-contract-platform/apps/contract-web/src/app/contracts/template-draft-panel.tsx @@ -47,7 +47,7 @@ type TemplateDraftPanelProps = { const defaultText: TemplateDraftPanelText = { title: "模板化起草", - subtitle: "吸收 V1 模板资产,填关键变量后生成 V2 合同草稿,再进入审查和版本留痕。", + subtitle: "填写关键变量后生成合同草稿,再进入审查和版本留痕。", draftTitle: "草稿名称", owner: "起草人", optional: "可选变量", diff --git a/yuqei-contract-platform/apps/contract-web/src/app/globals.css b/yuqei-contract-platform/apps/contract-web/src/app/globals.css index 91143b84..0aec308d 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/globals.css +++ b/yuqei-contract-platform/apps/contract-web/src/app/globals.css @@ -36,6 +36,172 @@ --control-shadow: 0 10px 24px rgba(49, 197, 218, 0.16); } +.auth-shell-main { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + min-height: 100vh; +} + +.auth-shell-brand { + width: min(100% - 40px, 1200px); + margin-inline: auto; + padding-block: 20px 8px; +} + +.auth-shell-brand .brand-block { + width: fit-content; + color: var(--ink); +} + +.auth-shell-content { + width: min(100% - 40px, 1200px); + margin-inline: auto; +} + +.auth-shell-footer, +.app-policy-links { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; +} + +.auth-shell-footer { + justify-content: center; + width: min(100% - 40px, 1200px); + margin-inline: auto; + padding-block: 20px 28px; + color: var(--muted); + font-size: 12px; +} + +.app-policy-links { + margin-top: 4px; + font-size: 11px; +} + +.app-policy-links a { + color: rgba(245, 250, 246, 0.68); +} + +.register-mode-switch { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px; + width: min(100%, 720px); + margin-inline: auto; + padding: 4px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(255, 255, 255, 0.72); +} + +.register-mode-switch button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 42px; + border: 0; + border-radius: 6px; + color: var(--muted); + background: transparent; + cursor: pointer; + font: inherit; + font-weight: 700; +} + +.register-mode-switch button:hover { + color: var(--ink); + background: var(--surface-hover); +} + +.register-mode-switch .register-mode-active { + color: var(--ink); + background: var(--surface-solid); + box-shadow: 0 4px 14px rgba(15, 23, 42, 0.09); +} + +.register-mode-switch button:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 1px; +} + +.registration-agreements { + display: grid; + gap: 10px; + margin: 0; + padding: 14px; + border: 1px solid var(--line); + border-radius: 8px; +} + +.registration-agreements legend { + padding-inline: 4px; + color: var(--ink); + font-size: 13px; + font-weight: 800; +} + +.registration-agreement-item { + display: flex; + align-items: flex-start; + gap: 10px; + color: var(--ink-soft); + cursor: pointer; + line-height: 1.5; +} + +.identity-form .registration-agreement-item input[type="checkbox"] { + flex: 0 0 auto; + width: 16px; + min-width: 16px; + height: 16px; + margin: 3px 0 0; + padding: 0; + accent-color: var(--primary); +} + +.registration-agreement-item span { + display: inline; +} + +.registration-agreement-item a { + margin-inline: 2px; + color: var(--accent); + font-weight: 700; + text-decoration: underline; + text-underline-offset: 2px; +} + +.registration-agreement-item small { + display: block; + color: var(--muted); + font-size: 12px; +} + +.registration-agreements-required { + margin: 0; + color: var(--warning); + font-size: 12px; +} + +.registration-agreements-state { + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + color: var(--muted); + background: var(--surface-sunken); +} + +.registration-agreements-error { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-color: rgba(207, 19, 34, 0.24); + color: var(--danger); +} + .tenant-chip { display: inline-flex; align-items: center; @@ -74,12 +240,115 @@ font-size: 11px; } +.workspace-switcher { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 38px; + max-width: 260px; + padding: 6px 10px; + border: 1px solid rgba(26, 83, 92, 0.16); + border-radius: 8px; + color: #22484e; + background: rgba(255, 255, 255, 0.82); + box-shadow: 0 8px 22px rgba(31, 67, 75, 0.08); +} + +.workspace-switcher > span { + display: grid; + min-width: 0; +} + +.workspace-switcher select { + min-width: 0; + max-width: 210px; + padding: 0 20px 0 0; + overflow: hidden; + border: 0; + outline: 0; + color: inherit; + background: transparent; + font: inherit; + font-size: 13px; + font-weight: 700; + text-overflow: ellipsis; +} + +.workspace-switcher select:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +.workspace-switcher small { + overflow: hidden; + color: #6b7b80; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-switcher-stack { + align-items: flex-start; + display: flex; + flex-direction: column; + gap: 4px; +} + +.workspace-verification-hint { + align-items: center; + color: var(--accent-strong); + display: inline-flex; + font-size: 11px; + gap: 4px; + line-height: 1.25; + text-decoration: none; +} + +.workspace-verification-hint:hover { + text-decoration: underline; +} + +.email-verification-panel { + max-width: 620px; +} + .auth-page { + width: 100%; max-width: 980px; + margin-inline: auto; } .auth-panel { + width: min(100%, 720px); max-width: 720px; + margin-inline: auto; +} + +.content:has(> .auth-page) { + display: grid; + min-height: calc(100vh - 74px); + padding-block: 36px; + place-items: center; +} + +.content:has(> .login-auth-page) { + display: block; +} + +.login-auth-page { + min-height: calc(100vh - 146px); + grid-template-rows: auto minmax(0, 1fr); +} + +.login-auth-body { + display: grid; + min-height: 0; + padding-block: 24px; + place-items: center; +} + +.login-auth-stack { + width: min(100%, 720px); } .auth-note { @@ -1897,6 +2166,43 @@ textarea { line-height: 1.55; } +.password-input { + position: relative; + width: 100%; + min-width: 0; +} + +.identity-form .password-input input { + padding-right: 44px; +} + +.password-visibility-toggle { + position: absolute; + top: 50%; + right: 6px; + display: inline-grid; + width: 34px; + height: 34px; + padding: 0; + transform: translateY(-50%); + place-items: center; + border: 0; + border-radius: 6px; + color: var(--muted); + background: transparent; + cursor: pointer; +} + +.password-visibility-toggle:hover { + color: var(--ink); + background: var(--surface-tinted); +} + +.password-visibility-toggle:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + .identity-checkbox { display: flex; align-items: center; @@ -4431,6 +4737,20 @@ details.panel summary::-webkit-details-marker { height: auto; } + .content:has(> .auth-page) { + min-height: auto; + padding-block: 28px 48px; + place-items: start center; + } + + .login-auth-page { + min-height: auto; + } + + .login-auth-body { + padding-block: 24px 0; + } + .workspace-today-grid { grid-template-columns: 1fr; } diff --git a/yuqei-contract-platform/apps/contract-web/src/app/invite/accept/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/invite/accept/page.tsx index b2a09d4d..7f9534b5 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/invite/accept/page.tsx +++ b/yuqei-contract-platform/apps/contract-web/src/app/invite/accept/page.tsx @@ -4,6 +4,7 @@ import { useMemo, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; import { KeyRound, LogIn } from "lucide-react"; +import { PasswordInput } from "@/components/password-input"; export default function AcceptInvitationPage() { const router = useRouter(); @@ -58,8 +59,8 @@ export default function AcceptInvitationPage() {

Invitation

-

接受公司邀请

-

设置登录密码后,你会自动进入所属公司的合同数据空间。

+

接受组织邀请

+

新用户请设置密码;已有账号请输入当前密码,确认后进入邀请指定的组织工作空间。

@@ -73,7 +74,7 @@ export default function AcceptInvitationPage() {

Password

-

设置账号密码

+

确认账号密码

@@ -88,22 +89,25 @@ export default function AcceptInvitationPage() { onChange={(event) => setDraft((value) => ({ ...value, phone: event.target.value }))} placeholder="手机号,可选" /> - setDraft((value) => ({ ...value, password: event.target.value }))} - placeholder="登录密码,至少 8 位" - type="password" - minLength={8} + placeholder="新密码或当前密码,至少 12 位" + minLength={12} + maxLength={128} required /> - setDraft((value) => ({ ...value, confirm_password: event.target.value }))} placeholder="再次输入密码" - type="password" - minLength={8} + minLength={12} + maxLength={128} required /> +

已有账号忘记密码时,请先前往密码重置。

+ +
+
+

+ + 使用边界 +

+
+
+

+ 回答由 AI 根据当前事实背景和检索依据生成,仅用于合同处理参考,不替代律师、企业法务或管理层的最终判断。重要事项请结合完整材料进行人工复核。 +

+
+
); @@ -216,7 +230,7 @@ function LegalQaAnswerCard({ turn }: { turn: LegalQaTurn }) {

{turn.question}

{formatDateTime(turn.createdAt)}

- {formatConfidence(turn.response.confidence)} + {formatConfidence(turn.response.confidence)}
{turn.matterContext &&
{turn.matterContext}
} @@ -271,6 +285,13 @@ function formatConfidence(value: number | null | undefined) { return `置信度 ${Math.round(value * 100)}%`; } +function confidenceTone(value: number | null | undefined) { + if (value == null || value < 0.6) { + return "tag-warning"; + } + return value >= 0.8 ? "tag-success" : "tag-primary"; +} + function formatDateTime(value: string) { return new Intl.DateTimeFormat("zh-CN", { dateStyle: "short", diff --git a/yuqei-contract-platform/apps/contract-web/src/app/login/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/login/page.tsx index a8fb5b6a..0778962a 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/login/page.tsx +++ b/yuqei-contract-platform/apps/contract-web/src/app/login/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { Building2, LogIn, UserPlus } from "lucide-react"; +import { PasswordInput } from "@/components/password-input"; import { getIdentityCapabilities, type IdentityCapabilities } from "@/lib/identity-capabilities"; export default function LoginPage() { @@ -14,7 +15,6 @@ export default function LoginPage() { const [draft, setDraft] = useState({ email: "", password: "", - tenant_id: "", }); useEffect(() => { @@ -32,7 +32,6 @@ export default function LoginPage() { body: JSON.stringify({ email: draft.email, password: draft.password, - tenant_id: draft.tenant_id || undefined, }), }); if (!response.ok) { @@ -48,67 +47,72 @@ export default function LoginPage() { } return ( -
-
+
+
-

Tenant session

-

登录公司空间

-

输入租户用户邮箱,系统会进入该用户所属公司的数据空间。

+

Workspace access

+

登录工作空间

+

使用邮箱登录,进入你的个人或组织工作空间。

{capabilities?.registration_enabled && ( - 注册公司 + 注册 )}
- {message &&
{message}
} +
+
+ {message &&
{message}
} -
-
-
-

Account

-

账号登录

-
- -
-
- setDraft((value) => ({ ...value, email: event.target.value }))} - placeholder="用户邮箱" - type="email" - required - /> - setDraft((value) => ({ ...value, password: event.target.value }))} - placeholder="登录密码" - type="password" - required - /> - {capabilities?.password_reset_enabled && ( -
- 忘记密码? +
+
+
+

Account

+

账号登录

+
+
- )} - setDraft((value) => ({ ...value, tenant_id: event.target.value }))} - placeholder="租户 ID,可选;同一邮箱加入多个公司时需要填写" - /> - - -
+
+ setDraft((value) => ({ ...value, email: event.target.value }))} + placeholder="用户邮箱" + type="email" + required + /> + setDraft((value) => ({ ...value, password: event.target.value }))} + placeholder="登录密码" + required + /> + {capabilities?.password_reset_enabled && ( +
+ 忘记密码? +
+ )} + + +
+
+
); } async function readError(response: Response) { + if (response.status === 401) { + return "邮箱或密码错误,请检查后重试。"; + } + if (response.status === 429) { + return "登录尝试过于频繁,请稍后再试。"; + } const payload = await response.json().catch(() => null) as { detail?: string; message?: string } | null; return payload?.detail ?? payload?.message ?? "请求失败。"; } diff --git a/yuqei-contract-platform/apps/contract-web/src/app/password-reset/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/password-reset/page.tsx index 596085e3..3381deda 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/password-reset/page.tsx +++ b/yuqei-contract-platform/apps/contract-web/src/app/password-reset/page.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import Link from "next/link"; import { KeyRound, LogIn, Mail } from "lucide-react"; import { getIdentityCapabilities, type IdentityCapabilities } from "@/lib/identity-capabilities"; +import { PasswordInput } from "@/components/password-input"; type ChallengeResponse = { id: string; @@ -22,7 +23,6 @@ export default function PasswordResetPage() { const [capabilities, setCapabilities] = useState(); const [draft, setDraft] = useState({ email: "", - tenant_id: "", code: "", new_password: "", confirm_password: "", @@ -62,10 +62,7 @@ export default function PasswordResetPage() { const response = await fetch("/api/auth/password-reset/request", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email: draft.email, - tenant_id: draft.tenant_id || undefined, - }), + body: JSON.stringify({ email: draft.email }), }); if (!response.ok) { throw new Error(await readError(response)); @@ -150,11 +147,6 @@ export default function PasswordResetPage() { type="email" required /> - setDraft((value) => ({ ...value, tenant_id: event.target.value }))} - placeholder="租户 ID,可选" - /> {challenge?.dev_code &&
开发验证码:{challenge.dev_code}
} + +
+ + {message &&
{message}
}
-

Company

-

公司与管理员信息

+

{personal ? "Personal" : "Organization"}

+

{personal ? "个人账号与工作空间" : "组织与管理员信息"}

- + {personal ? : }
- setDraft((value) => ({ ...value, company_name: event.target.value }))} - placeholder="公司名称" - required - /> - setDraft((value) => ({ ...value, tenant_id: event.target.value }))} - placeholder="租户 ID,可留空自动生成" - /> -
- setDraft((value) => ({ ...value, admin_display_name: event.target.value }))} - placeholder="管理员姓名" - required - /> - setDraft((value) => ({ ...value, admin_email: event.target.value }))} - placeholder="管理员邮箱" - type="email" - required - /> -
- setDraft((value) => ({ ...value, admin_password: event.target.value }))} - placeholder="管理员登录密码,至少 8 位" - type="password" - minLength={8} - required - /> -
- setDraft((value) => ({ ...value, admin_department: event.target.value }))} - placeholder="部门" - /> - setDraft((value) => ({ ...value, admin_title: event.target.value }))} - placeholder="职务" - /> -
- + {personal ? ( + <> + setPersonalDraft((value) => ({ ...value, workspace_name: event.target.value }))} + placeholder="个人工作空间名称,可留空自动生成" + /> +
+ setPersonalDraft((value) => ({ ...value, display_name: event.target.value }))} + placeholder="你的姓名" + autoComplete="name" + required + /> + setPersonalDraft((value) => ({ ...value, email: event.target.value }))} + placeholder="登录邮箱" + autoComplete="email" + type="email" + required + /> +
+ setPersonalDraft((value) => ({ ...value, password: event.target.value }))} + placeholder="登录密码,至少 12 位" + minLength={12} + maxLength={128} + required + /> + void loadAgreements()} + onToggle={toggleAgreement} + /> + + + ) : ( + <> + setOrganizationDraft((value) => ({ ...value, company_name: event.target.value }))} + placeholder="组织名称" + autoComplete="organization" + required + /> + setOrganizationDraft((value) => ({ ...value, tenant_id: event.target.value }))} + placeholder="工作空间 ID,可留空自动生成" + /> +
+ setOrganizationDraft((value) => ({ ...value, admin_display_name: event.target.value }))} + placeholder="管理员姓名" + autoComplete="name" + required + /> + setOrganizationDraft((value) => ({ ...value, admin_email: event.target.value }))} + placeholder="管理员邮箱" + autoComplete="email" + type="email" + required + /> +
+ setOrganizationDraft((value) => ({ ...value, admin_password: event.target.value }))} + placeholder="管理员登录密码,至少 12 位" + minLength={12} + maxLength={128} + required + /> +
+ setOrganizationDraft((value) => ({ ...value, admin_department: event.target.value }))} + placeholder="部门" + /> + setOrganizationDraft((value) => ({ ...value, admin_title: event.target.value }))} + placeholder="职务" + /> +
+ void loadAgreements()} + onToggle={toggleAgreement} + /> + + + )}
); } +function RegistrationAgreementFields({ + agreements, + catalog, + error, + acceptedKeys, + onRetry, + onToggle, +}: { + agreements: RegistrationAgreement[]; + catalog: RegistrationAgreementCatalog | null | undefined; + error: string | null; + acceptedKeys: Set; + onRetry: () => void; + onToggle: (agreement: RegistrationAgreement, accepted: boolean) => void; +}) { + if (catalog === undefined) { + return
正在加载必需协议...
; + } + + if (catalog === null) { + return ( +
+ {error || "协议目录加载失败,暂时无法注册。"} + +
+ ); + } + + if (agreements.length === 0) { + return
当前注册方式缺少必需协议版本。
; + } + + const hasDraft = agreements.some((agreement) => !agreement.is_effective); + const allAccepted = agreements.every((agreement) => acceptedKeys.has(agreementKey(agreement))); + return ( +
+ 协议确认(必填) + {hasDraft && ( +
+ 当前协议为产品草案,尚未正式生效,仅用于产品开发与内部验收。 +
+ )} + {agreements.map((agreement) => ( + + ))} + {!allAccepted && ( +

+ 请逐项勾选以上协议后继续。 +

+ )} +
+ ); +} + +function agreementsForMode(catalog: RegistrationAgreementCatalog, mode: RegistrationMode) { + return catalog.items.filter((agreement) => agreement.required_for_registration.includes(mode)); +} + async function readError(response: Response) { const payload = await response.json().catch(() => null) as { detail?: string; message?: string } | null; - return payload?.detail ?? payload?.message ?? "请求失败。"; + return payload?.detail ?? payload?.message ?? "工作空间创建失败,请稍后重试。"; } diff --git a/yuqei-contract-platform/apps/contract-web/src/app/service-agreement/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/service-agreement/page.tsx new file mode 100644 index 00000000..ec54fb8f --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/app/service-agreement/page.tsx @@ -0,0 +1,28 @@ +import { LegalPolicyPage, PolicyParagraph } from "@/components/legal-policy-page"; + +export default function ServiceAgreementPage() { + return ( + 服务范围可以包括模板起草、合同审查、报告下载、文件管理、协作审批、知识资产和审计能力,具体以所购套餐、订单或项目约定为准。, + }, + { + title: "试用与配额", + content: 试用服务应限定期限、成员数、审查量和存储量。达到配额时平台应明确提示,不得在未说明的情况下继续产生收费或删除用户数据。, + }, + { + title: "可用性与支持", + content: 正式版本应按套餐约定服务时间、故障响应、数据备份、恢复目标和支持渠道。管理员线下开通模式还应明确账号恢复和邀请交付责任。, + }, + { + title: "数据退出", + content: 服务终止前应提供合理的数据导出窗口。数据删除、备份保留和依法留存范围应在正式协议中明确。, + }, + ]} + /> + ); +} diff --git a/yuqei-contract-platform/apps/contract-web/src/app/terms/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/terms/page.tsx new file mode 100644 index 00000000..8be9e3aa --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/app/terms/page.tsx @@ -0,0 +1,28 @@ +import { LegalPolicyPage, PolicyParagraph } from "@/components/legal-policy-page"; + +export default function TermsPage() { + return ( + 用户应提供真实、有效的账号信息并妥善保管凭据。个人工作空间由本人管理,组织工作空间由所属组织管理员配置成员和权限。, + }, + { + title: "允许使用范围", + content: 用户可以在合法授权范围内起草、上传、审查和管理合同。不得上传无权处理的材料,不得利用平台实施侵权、违法访问、批量抓取或规避安全控制。, + }, + { + title: "智能结果边界", + content: 平台输出的风险识别、修改建议和法条参考属于辅助信息,不构成律师出具的法律意见,也不替代用户对完整事实和材料的人工判断。, + }, + { + title: "服务变更与终止", + content: 平台可以基于安全、合规和产品运营需要调整服务。涉及用户权益的重要变更应通过可追溯方式通知,并提供适当的数据导出或账号处理机制。, + }, + ]} + /> + ); +} diff --git a/yuqei-contract-platform/apps/contract-web/src/app/verify-email/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/verify-email/page.tsx new file mode 100644 index 00000000..56a1d515 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/app/verify-email/page.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { BadgeCheck, LayoutDashboard, MailCheck, Send } from "lucide-react"; +import { useContractSession } from "@/components/app-shell"; + +type ChallengeResponse = { + id: string; + expires_at: string; + dev_code?: string | null; +}; + +export default function VerifyEmailPage() { + const router = useRouter(); + const session = useContractSession(); + const [challenge, setChallenge] = useState(null); + const [code, setCode] = useState(""); + const [saving, setSaving] = useState<"send" | "confirm" | null>(null); + const [message, setMessage] = useState(null); + + if (!session) { + return null; + } + + if (session.email_verified_at) { + return ( +
+
+
+

Email verified

+

邮箱已验证

+

账号邮箱 {session.email} 已完成验证,可查看同一账号加入的其他工作空间。

+
+ + + 返回工作台 + +
+
+ + 当前账号无需重复验证。 +
+
+ ); + } + + async function sendCode() { + setSaving("send"); + setMessage(null); + try { + const response = await fetch("/api/auth/email-verification/send", { method: "POST" }); + if (!response.ok) { + throw new Error(await readError(response)); + } + const payload = await response.json() as ChallengeResponse; + setChallenge(payload); + if (payload.dev_code) { + setCode(payload.dev_code); + } + setMessage("验证码已发送到当前账号邮箱,请在有效期内完成验证。"); + } catch (error) { + setMessage(error instanceof Error ? error.message : "验证码发送失败。"); + } finally { + setSaving(null); + } + } + + async function confirmCode(event: React.FormEvent) { + event.preventDefault(); + if (!challenge) { + setMessage("请先发送验证码。"); + return; + } + setSaving("confirm"); + setMessage(null); + try { + const response = await fetch("/api/auth/email-verification/confirm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ challenge_id: challenge.id, code }), + }); + if (!response.ok) { + throw new Error(await readError(response)); + } + router.push("/workspace"); + router.refresh(); + } catch (error) { + setMessage(error instanceof Error ? error.message : "邮箱验证失败。"); + } finally { + setSaving(null); + } + } + + return ( +
+
+
+

Email verification

+

验证账号邮箱

+

验证 {session.email} 后,可查看并切换这个账号加入的其他工作空间。

+
+ + + 返回工作台 + +
+ + {message &&
{message}
} + +
+
+
+

Current account

+

{session.email}

+
+ +
+
+ + {challenge?.dev_code &&
开发验证码:{challenge.dev_code}
} + setCode(event.target.value.replace(/\D/g, "").slice(0, 6))} + placeholder="输入 6 位验证码" + required + value={code} + /> + +
+
+
+ ); +} + +async function readError(response: Response) { + const payload = await response.json().catch(() => null) as { detail?: string; message?: string } | null; + return payload?.detail ?? payload?.message ?? "请求失败。"; +} diff --git a/yuqei-contract-platform/apps/contract-web/src/components/admin-access-guard.tsx b/yuqei-contract-platform/apps/contract-web/src/components/admin-access-guard.tsx index 9fe80908..f548e1b4 100644 --- a/yuqei-contract-platform/apps/contract-web/src/components/admin-access-guard.tsx +++ b/yuqei-contract-platform/apps/contract-web/src/components/admin-access-guard.tsx @@ -3,7 +3,8 @@ import Link from "next/link"; import type { ReactNode } from "react"; import { usePathname } from "next/navigation"; -import { canAccessAdminPath, getRoleLabel, resolveCurrentRole } from "@/lib/access-control"; +import { canAccessAdminPath, getRoleLabel, normalizeUserRole } from "@/lib/access-control"; +import { useContractSession } from "@/components/app-shell"; export function AdminAccessGuard({ path, @@ -13,7 +14,8 @@ export function AdminAccessGuard({ children: ReactNode; }) { const pathname = usePathname(); - const currentRole = resolveCurrentRole(); + const session = useContractSession(); + const currentRole = normalizeUserRole(session?.role); const checkedPath = path ?? pathname; if (canAccessAdminPath(checkedPath, currentRole)) { diff --git a/yuqei-contract-platform/apps/contract-web/src/components/app-shell.tsx b/yuqei-contract-platform/apps/contract-web/src/components/app-shell.tsx index a2bbd952..cade5b46 100644 --- a/yuqei-contract-platform/apps/contract-web/src/components/app-shell.tsx +++ b/yuqei-contract-platform/apps/contract-web/src/components/app-shell.tsx @@ -1,28 +1,29 @@ "use client"; -import { useEffect, useState, type ReactNode } from "react"; +import { createContext, useContext, useEffect, type ReactNode } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { Activity, Bot, BookOpenText, - Building2, FileSearch, FileSignature, Files, Home, LayoutDashboard, LibraryBig, - LogIn, LogOut, MessageSquareText, Scale, Search, Settings, + ShieldCheck, UsersRound } from "lucide-react"; -import { canAccessAdminPath, getRoleLabel, resolveCurrentRole } from "@/lib/access-control"; +import { canAccessAdminPath, getRoleLabel, normalizeUserRole } from "@/lib/access-control"; +import type { ContractWebSession } from "@/lib/contract-session"; +import { WorkspaceSwitcher } from "@/components/workspace-switcher"; type NavItem = { href: string; @@ -53,7 +54,8 @@ const assetNav: NavItem[] = [ const adminNav: NavItem[] = [ { href: "/admin", label: "管理首页", icon: Settings }, - { href: "/admin/identity", label: "用户与租户", icon: UsersRound } + { href: "/admin/identity", label: "用户与工作空间", icon: UsersRound }, + { href: "/admin/security", label: "数据安全中心", icon: ShieldCheck } ]; const opsNav: NavItem[] = [ @@ -61,44 +63,27 @@ const opsNav: NavItem[] = [ { href: "/health", label: "系统健康", icon: Activity } ]; -type ContractSession = { - tenant_id: string; - tenant_name: string; - user_id: string; - email: string; - display_name: string; - role: string; -}; +const ContractSessionContext = createContext(null); -export function AppShell({ children }: { children: ReactNode }) { +const policyLinks = [ + { href: "/terms", label: "用户协议" }, + { href: "/privacy", label: "隐私政策" }, + { href: "/service-agreement", label: "服务协议" }, + { href: "/ai-disclaimer", label: "AI 使用说明" }, +]; + +export function AppShell({ children, session }: { children: ReactNode; session: ContractWebSession | null }) { const pathname = usePathname(); const router = useRouter(); - const currentRole = resolveCurrentRole(); - const [session, setSession] = useState(null); + const currentRole = normalizeUserRole(session?.role); const visibleAssetNav = assetNav.filter((item) => !item.restricted || canAccessAdminPath(item.href, currentRole)); const visibleAdminNav = adminNav.filter((item) => canAccessAdminPath(item.href, currentRole)); const visibleOpsNav = opsNav.filter((item) => canAccessAdminPath(item.href, currentRole)); useEffect(() => { - let active = true; - fetch("/api/auth/session", { cache: "no-store" }) - .then((response) => response.json()) - .then((payload: { session?: ContractSession | null }) => { - if (active) { - setSession(payload.session ?? null); - } - }) - .catch(() => { - if (active) { - setSession(null); - } - }); - return () => { - active = false; - }; - }, []); - - useEffect(() => { + if (!session || isPublicShellPath(pathname)) { + return; + } const prefetchTimer = window.setTimeout(() => { [ "/workspace", @@ -110,19 +95,41 @@ export function AppShell({ children }: { children: ReactNode }) { ].forEach((route) => router.prefetch(route)); }, 800); return () => window.clearTimeout(prefetchTimer); - }, [router]); + }, [pathname, router, session]); async function logout() { await fetch("/api/auth/logout", { method: "POST" }).catch(() => null); - setSession(null); - if (!pathname.startsWith("/login") && !pathname.startsWith("/register")) { - router.push("/login"); - } + router.push("/login"); router.refresh(); } + if (isPublicShellPath(pathname)) { + return ( + +
+
+ + + + 钰麒合同 + AI Contract Platform + + +
+
{children}
+ +
+
+ ); + } + + if (!session) { + return null; + } + return ( -
+ +
@@ -156,26 +164,11 @@ export function AppShell({ children }: { children: ReactNode }) {
{resolveTitle(pathname)}
- {session ? ( - <> - - - - {session.tenant_name} - {session.display_name} - - - - - ) : ( - - - 登录 - - )} + + 待我处理 @@ -188,10 +181,36 @@ export function AppShell({ children }: { children: ReactNode }) {
{children}
-
+
+ ); } +export function useContractSession() { + return useContext(ContractSessionContext); +} + +function PolicyLinks({ className }: { className: string }) { + return ( + + ); +} + +function isPublicShellPath(pathname: string) { + return [ + "/login", + "/register", + "/password-reset", + "/invite/accept", + "/terms", + "/privacy", + "/service-agreement", + "/ai-disclaimer", + ].some((path) => pathname === path || pathname.startsWith(`${path}/`)); +} + function SidebarNavGroup({ title, ariaLabel, @@ -235,6 +254,7 @@ function isNavActive(pathname: string, href: string) { function resolveTitle(pathname: string): string { if (pathname.startsWith("/login")) return "登录"; if (pathname.startsWith("/password-reset")) return "重置密码"; + if (pathname.startsWith("/verify-email")) return "验证邮箱"; if (pathname.startsWith("/register")) return "注册公司"; if (pathname.startsWith("/invite/accept")) return "接受邀请"; if (pathname.startsWith("/legal-qa")) return "法律问答"; @@ -249,7 +269,8 @@ function resolveTitle(pathname: string): string { if (pathname.startsWith("/admin/legal-fulltext")) return "法律全文库"; if (pathname.startsWith("/admin/review-results")) return "审查结果"; if (pathname.startsWith("/admin/ai-traces")) return "AI 调用 Trace"; - if (pathname.startsWith("/admin/identity")) return "用户与租户"; + if (pathname.startsWith("/admin/identity")) return "用户与工作空间"; + if (pathname.startsWith("/admin/security")) return "数据安全中心"; if (pathname.startsWith("/admin")) return "管理首页"; if (pathname.startsWith("/health")) return "系统健康"; return "工作台"; diff --git a/yuqei-contract-platform/apps/contract-web/src/components/legal-policy-page.tsx b/yuqei-contract-platform/apps/contract-web/src/components/legal-policy-page.tsx new file mode 100644 index 00000000..3dacafc3 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/components/legal-policy-page.tsx @@ -0,0 +1,49 @@ +import Link from "next/link"; +import type { ReactNode } from "react"; + +type PolicySection = { + title: string; + content: ReactNode; +}; + +export function LegalPolicyPage({ + title, + summary, + sections, +}: { + title: string; + summary: string; + sections: PolicySection[]; +}) { + return ( +
+
+
+

版本 2026-08-01 / 产品草案

+

{title}

+

{summary}

+
+ 返回登录 +
+ +
+ 本页面用于产品开发和内部验收,正式发布前必须完成具名法务审核、版本确认和生效审批。 +
+ +
+ {sections.map((section) => ( +
+
+

{section.title}

+
+
{section.content}
+
+ ))} +
+
+ ); +} + +export function PolicyParagraph({ children }: { children: ReactNode }) { + return

{children}

; +} diff --git a/yuqei-contract-platform/apps/contract-web/src/components/password-input.tsx b/yuqei-contract-platform/apps/contract-web/src/components/password-input.tsx new file mode 100644 index 00000000..994ebb7a --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/components/password-input.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useId, useState, type InputHTMLAttributes } from "react"; +import { Eye, EyeOff } from "lucide-react"; + +type PasswordInputProps = Omit, "type">; + +export function PasswordInput({ id, ...props }: PasswordInputProps) { + const generatedId = useId(); + const inputId = id ?? generatedId; + const [visible, setVisible] = useState(false); + const actionLabel = visible ? "隐藏密码" : "显示密码"; + + return ( +
+ + +
+ ); +} diff --git a/yuqei-contract-platform/apps/contract-web/src/components/workspace-switcher.tsx b/yuqei-contract-platform/apps/contract-web/src/components/workspace-switcher.tsx new file mode 100644 index 00000000..d161627e --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/components/workspace-switcher.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Building2, MailWarning } from "lucide-react"; +import type { ContractWebSession } from "@/lib/contract-session"; +import { getWorkspaceAccess, switchWorkspace, type WorkspaceAccess } from "@/lib/workspace-access"; +import { activeWorkspaceAccess, canSwitchWorkspace, workspaceDisplayName } from "@/lib/workspace-access-state"; + +export function WorkspaceSwitcher({ session }: { session: ContractWebSession }) { + const router = useRouter(); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [switching, setSwitching] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let active = true; + setLoading(true); + setError(null); + getWorkspaceAccess() + .then((payload) => { + if (active) { + setItems(activeWorkspaceAccess(payload.items)); + } + }) + .catch((reason) => { + if (active) { + setItems([]); + setError(reason instanceof Error ? reason.message : "工作空间列表加载失败。"); + } + }) + .finally(() => { + if (active) { + setLoading(false); + } + }); + return () => { + active = false; + }; + }, [session.tenant_id]); + + async function changeWorkspace(tenantId: string) { + if (!canSwitchWorkspace(session.tenant_id, tenantId, switching)) { + return; + } + setSwitching(true); + setError(null); + try { + await switchWorkspace(tenantId); + router.refresh(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "工作空间切换失败。"); + } finally { + setSwitching(false); + } + } + + if (items.length < 2) { + return ( +
+ + + + {workspaceDisplayName(session.tenant_id, session.tenant_name)} + {error ? "工作空间列表暂不可用" : session.display_name} + + + {!loading && !error && !session.email_verified_at && ( + + + 验证邮箱以查看其他工作空间 + + )} +
+ ); + } + + return ( + + ); +} diff --git a/yuqei-contract-platform/apps/contract-web/src/features/workspace/workspace-page.tsx b/yuqei-contract-platform/apps/contract-web/src/features/workspace/workspace-page.tsx index 66451ce0..2ee85f1b 100644 --- a/yuqei-contract-platform/apps/contract-web/src/features/workspace/workspace-page.tsx +++ b/yuqei-contract-platform/apps/contract-web/src/features/workspace/workspace-page.tsx @@ -101,7 +101,7 @@ export async function WorkspacePage() {

{text.quickStart}

- + {text.newContract} diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/access-control.ts b/yuqei-contract-platform/apps/contract-web/src/lib/access-control.ts index 3e470850..dd512e58 100644 --- a/yuqei-contract-platform/apps/contract-web/src/lib/access-control.ts +++ b/yuqei-contract-platform/apps/contract-web/src/lib/access-control.ts @@ -4,6 +4,7 @@ export type AdminCapability = | "enterprise_permissions" | "identity_management" | "audit_center" + | "security_center" | "legal_assets" | "ai_quality_rules" | "ai_prompts" @@ -36,6 +37,10 @@ const adminRouteAccess: Record = { capability: "audit_center", roles: ["admin", "legal"] }, + "/admin/security": { + capability: "security_center", + roles: ["admin", "legal"] + }, "/admin/legal-assets": { capability: "legal_assets", roles: ["admin", "legal"] @@ -84,16 +89,16 @@ const adminRouteAccess: Record = { const validRoles = new Set(["admin", "legal", "business", "approver", "observer"]); -export function resolveCurrentRole(): UserRole { - const rawRole = process.env.NEXT_PUBLIC_DEMO_ROLE?.trim().toLowerCase(); - return validRoles.has(rawRole as UserRole) ? (rawRole as UserRole) : "admin"; +export function normalizeUserRole(role: string | null | undefined): UserRole { + const normalizedRole = role?.trim().toLowerCase(); + return validRoles.has(normalizedRole as UserRole) ? (normalizedRole as UserRole) : "business"; } export function getRoleLabel(role: UserRole): string { return roleLabels[role]; } -export function canAccessAdminPath(pathname: string, role: UserRole = resolveCurrentRole()): boolean { +export function canAccessAdminPath(pathname: string, role: UserRole): boolean { if (pathname === "/admin") { return role === "admin" || role === "legal"; } @@ -108,7 +113,7 @@ export function canAccessAdminPath(pathname: string, role: UserRole = resolveCur export function canAccessAdminCapability( capability: AdminCapability, - role: UserRole = resolveCurrentRole(), + role: UserRole, ): boolean { if (capability === "enterprise_permissions") { return role === "admin"; @@ -119,6 +124,9 @@ export function canAccessAdminCapability( if (capability === "audit_center") { return role === "admin" || role === "legal"; } + if (capability === "security_center") { + return role === "admin" || role === "legal"; + } const routeAccess = Object.values(adminRouteAccess).find((item) => item.capability === capability); return routeAccess ? routeAccess.roles.includes(role) : role === "admin"; } diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/ai-platform-api.ts b/yuqei-contract-platform/apps/contract-web/src/lib/ai-platform-api.ts index d59e0e4c..8c5680a5 100644 --- a/yuqei-contract-platform/apps/contract-web/src/lib/ai-platform-api.ts +++ b/yuqei-contract-platform/apps/contract-web/src/lib/ai-platform-api.ts @@ -44,6 +44,7 @@ export function aiPlatformRolesForPath(pathname: string): readonly AiPlatformAdm } if ( pathname.startsWith("/api/v1/audit/") || + pathname.startsWith("/api/v1/security/") || pathname.startsWith("/api/v1/knowledge-") || pathname.startsWith("/api/v1/knowledge-bases/") || pathname.startsWith("/api/v1/quality") diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/auth-entry-routing.test.mjs b/yuqei-contract-platform/apps/contract-web/src/lib/auth-entry-routing.test.mjs new file mode 100644 index 00000000..6ff8395f --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/lib/auth-entry-routing.test.mjs @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { authenticatedEntryRedirect } from "./auth-entry-routing.ts"; + +test("an authenticated login visit redirects to the workspace", () => { + assert.equal(authenticatedEntryRedirect("/login", true), "/workspace"); +}); + +test("an unauthenticated login visit remains available", () => { + assert.equal(authenticatedEntryRedirect("/login", false), null); +}); + +test("other public account and policy pages keep their existing behavior", () => { + for (const pathname of [ + "/register", + "/password-reset", + "/invite/accept", + "/terms", + "/privacy", + "/service-agreement", + "/ai-disclaimer", + ]) { + assert.equal(authenticatedEntryRedirect(pathname, true), null, pathname); + assert.equal(authenticatedEntryRedirect(pathname, false), null, pathname); + } +}); + +test("the redirect applies only to the exact login route", () => { + assert.equal(authenticatedEntryRedirect("/login/help", true), null); + assert.equal(authenticatedEntryRedirect("/workspace", true), null); +}); diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/auth-entry-routing.ts b/yuqei-contract-platform/apps/contract-web/src/lib/auth-entry-routing.ts new file mode 100644 index 00000000..d63945ca --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/lib/auth-entry-routing.ts @@ -0,0 +1,3 @@ +export function authenticatedEntryRedirect(pathname: string, authenticated: boolean): string | null { + return authenticated && pathname === "/login" ? "/workspace" : null; +} diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/contract-api.ts b/yuqei-contract-platform/apps/contract-web/src/lib/contract-api.ts index d101d0cb..73f27ff3 100644 --- a/yuqei-contract-platform/apps/contract-web/src/lib/contract-api.ts +++ b/yuqei-contract-platform/apps/contract-web/src/lib/contract-api.ts @@ -37,6 +37,10 @@ export function buildIdentityProxyHeaders(request: Request, initial?: HeadersIni if (clientIp && clientIp.length <= 64) { headers.set("X-Identity-Client-IP", clientIp); } + const userAgent = request.headers.get("user-agent")?.trim(); + if (userAgent) { + headers.set("User-Agent", userAgent.slice(0, 512)); + } return headers; } diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/contract-session.ts b/yuqei-contract-platform/apps/contract-web/src/lib/contract-session.ts index a2136dad..66f6ea31 100644 --- a/yuqei-contract-platform/apps/contract-web/src/lib/contract-session.ts +++ b/yuqei-contract-platform/apps/contract-web/src/lib/contract-session.ts @@ -11,11 +11,16 @@ export type ContractWebSession = { email: string; display_name: string; role: string; + email_verified_at?: string | null; }; export async function getContractWebSession(): Promise { const cookieStore = await cookies(); const rawValue = cookieStore.get(CONTRACT_SESSION_COOKIE)?.value; + return readContractWebSession(rawValue); +} + +export function readContractWebSession(rawValue: string | null | undefined): ContractWebSession | null { if (!rawValue) { return null; } @@ -41,6 +46,7 @@ export async function getContractWebSession(): Promise { + const payload = { + ...basePayload, + global_account: { email_verified_at: "2026-08-02T03:00:00Z" }, + }; + + assert.equal(isIdentitySessionPayload(payload), true); + assert.deepEqual(toContractWebSession(payload), { + tenant_id: "workspace-personal", + tenant_name: "我的工作空间", + user_id: "user-current", + email: "user@example.com", + display_name: "Current User", + role: "business", + email_verified_at: "2026-08-02T03:00:00Z", + }); +}); + +test("legacy identity sessions remain valid and explicitly unverified", () => { + assert.equal(isIdentitySessionPayload(basePayload), true); + assert.equal(toContractWebSession(basePayload).email_verified_at, null); + assert.equal(isIdentitySessionPayload({ tenant: {}, user: {} }), false); +}); diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/identity-session-mapping.ts b/yuqei-contract-platform/apps/contract-web/src/lib/identity-session-mapping.ts new file mode 100644 index 00000000..711bac03 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/lib/identity-session-mapping.ts @@ -0,0 +1,47 @@ +import type { ContractWebSession } from "@/lib/contract-session"; + +export type IdentitySessionPayload = { + tenant: { + id: string; + name: string; + }; + user: { + id: string; + email: string; + display_name: string; + role: string; + }; + global_account?: { + email_verified_at?: string | null; + } | null; +}; + +export function isIdentitySessionPayload(value: unknown): value is IdentitySessionPayload { + if (!value || typeof value !== "object") { + return false; + } + const payload = value as Partial; + return Boolean( + payload.tenant + && typeof payload.tenant.id === "string" + && typeof payload.tenant.name === "string" + && payload.user + && typeof payload.user.id === "string" + && typeof payload.user.email === "string" + && typeof payload.user.display_name === "string" + && typeof payload.user.role === "string", + ); +} + +export function toContractWebSession(payload: IdentitySessionPayload): ContractWebSession { + const emailVerifiedAt = payload.global_account?.email_verified_at; + return { + tenant_id: payload.tenant.id, + tenant_name: payload.tenant.name, + user_id: payload.user.id, + email: payload.user.email, + display_name: payload.user.display_name, + role: payload.user.role, + email_verified_at: typeof emailVerifiedAt === "string" ? emailVerifiedAt : null, + }; +} diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/registration-agreements.ts b/yuqei-contract-platform/apps/contract-web/src/lib/registration-agreements.ts new file mode 100644 index 00000000..6fce0f99 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/lib/registration-agreements.ts @@ -0,0 +1,55 @@ +export type RegistrationMode = "personal" | "organization"; + +export type RegistrationAgreement = { + agreement_type: string; + version: string; + title: string; + document_path: string; + lifecycle_status: "draft" | "effective" | "retired"; + is_effective: boolean; + legal_review_required: boolean; + required_for_registration: RegistrationMode[]; + effective_at: string | null; +}; + +export type RegistrationAgreementCatalog = { + items: RegistrationAgreement[]; + total: number; +}; + +export type RegistrationAgreementAcceptance = { + agreement_type: string; + version: string; + accepted: boolean; +}; + +export async function getCurrentRegistrationAgreements(): Promise { + const response = await fetch("/api/auth/agreements/current", { cache: "no-store" }); + if (!response.ok) { + const payload = await response.json().catch(() => null) as { detail?: string; message?: string } | null; + throw new Error(payload?.detail || payload?.message || "协议目录加载失败。"); + } + const payload = await response.json() as RegistrationAgreementCatalog; + if (!Array.isArray(payload.items) || !payload.items.every(isRegistrationAgreement)) { + throw new Error("协议目录响应格式无效。"); + } + return payload; +} + +export function agreementKey(agreement: Pick) { + return `${agreement.agreement_type}\u0000${agreement.version}`; +} + +function isRegistrationAgreement(value: unknown): value is RegistrationAgreement { + if (!value || typeof value !== "object") { + return false; + } + const agreement = value as Partial; + return typeof agreement.agreement_type === "string" + && typeof agreement.version === "string" + && typeof agreement.title === "string" + && typeof agreement.document_path === "string" + && typeof agreement.is_effective === "boolean" + && Array.isArray(agreement.required_for_registration) + && agreement.required_for_registration.every((mode) => mode === "personal" || mode === "organization"); +} diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/workspace-access-state.test.mjs b/yuqei-contract-platform/apps/contract-web/src/lib/workspace-access-state.test.mjs new file mode 100644 index 00000000..819890a0 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/lib/workspace-access-state.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { activeWorkspaceAccess, canSwitchWorkspace, workspaceDisplayName } from "./workspace-access-state.ts"; + +test("activeWorkspaceAccess excludes invited and disabled memberships", () => { + const active = activeWorkspaceAccess([ + { workspace: { id: "personal" }, membership: { status: "active" } }, + { workspace: { id: "invited" }, membership: { status: "invited" } }, + { workspace: { id: "disabled" }, membership: { status: "disabled" } }, + ]); + + assert.deepEqual(active.map((item) => item.workspace.id), ["personal"]); +}); + +test("canSwitchWorkspace rejects empty, current, and in-flight targets", () => { + assert.equal(canSwitchWorkspace("current", "", false), false); + assert.equal(canSwitchWorkspace("current", "current", false), false); + assert.equal(canSwitchWorkspace("current", "other", true), false); + assert.equal(canSwitchWorkspace("current", "other", false), true); +}); + +test("workspaceDisplayName maps the legacy default workspace without changing identifiers", () => { + assert.equal(workspaceDisplayName("default", "默认租户"), "系统工作空间"); + assert.equal(workspaceDisplayName("default", "Legacy platform tenant"), "系统工作空间"); + assert.equal(workspaceDisplayName("tenant-acme", "Acme 法务"), "Acme 法务"); + assert.equal(workspaceDisplayName("tenant-fallback", " "), "tenant-fallback"); +}); diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/workspace-access-state.ts b/yuqei-contract-platform/apps/contract-web/src/lib/workspace-access-state.ts new file mode 100644 index 00000000..903e8956 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/lib/workspace-access-state.ts @@ -0,0 +1,19 @@ +export type WorkspaceAccessStateItem = { + workspace: { id: string }; + membership: { status: "active" | "invited" | "disabled" }; +}; + +export function activeWorkspaceAccess(items: T[]): T[] { + return items.filter((item) => item.membership.status === "active"); +} + +export function canSwitchWorkspace(currentTenantId: string, targetTenantId: string, switching: boolean) { + return Boolean(targetTenantId) && targetTenantId !== currentTenantId && !switching; +} + +export function workspaceDisplayName(workspaceId: string, workspaceName: string) { + if (workspaceId === "default") { + return "系统工作空间"; + } + return workspaceName.trim() || workspaceId; +} diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/workspace-access.ts b/yuqei-contract-platform/apps/contract-web/src/lib/workspace-access.ts new file mode 100644 index 00000000..dcb53e78 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/lib/workspace-access.ts @@ -0,0 +1,49 @@ +import type { UserRole } from "@/lib/access-control"; + +export type WorkspaceAccess = { + workspace: { + id: string; + name: string; + workspace_type?: "system" | "personal" | "organization"; + status?: string; + }; + membership: { + id: string; + tenant_id: string; + role: UserRole; + status: "active" | "invited" | "disabled"; + is_owner: boolean; + }; +}; + +export type WorkspaceAccessPage = { + items: WorkspaceAccess[]; + total: number; + current_tenant_id: string; +}; + +export async function getWorkspaceAccess(): Promise { + const response = await fetch("/api/auth/workspaces", { cache: "no-store" }); + if (!response.ok) { + const payload = await response.json().catch(() => null) as { detail?: string; message?: string } | null; + throw new Error(payload?.detail || payload?.message || "工作空间列表加载失败。"); + } + const payload = await response.json() as WorkspaceAccessPage; + if (!Array.isArray(payload.items)) { + throw new Error("工作空间列表响应格式无效。"); + } + return payload; +} + +export async function switchWorkspace(tenantId: string) { + const response = await fetch("/api/auth/workspaces/switch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tenant_id: tenantId }), + }); + if (!response.ok) { + const payload = await response.json().catch(() => null) as { detail?: string; message?: string } | null; + throw new Error(payload?.detail || payload?.message || "工作空间切换失败。"); + } + return response.json(); +} diff --git a/yuqei-contract-platform/apps/contract-web/src/proxy.ts b/yuqei-contract-platform/apps/contract-web/src/proxy.ts new file mode 100644 index 00000000..f4998fb9 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/proxy.ts @@ -0,0 +1,42 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { canAccessAdminPath, normalizeUserRole } from "@/lib/access-control"; +import { authenticatedEntryRedirect } from "@/lib/auth-entry-routing"; +import { CONTRACT_SESSION_COOKIE, readContractWebSession } from "@/lib/contract-session"; + +const publicPaths = [ + "/login", + "/register", + "/password-reset", + "/invite/accept", + "/terms", + "/privacy", + "/service-agreement", + "/ai-disclaimer", +]; + +export function proxy(request: NextRequest) { + const pathname = request.nextUrl.pathname; + const session = readContractWebSession(request.cookies.get(CONTRACT_SESSION_COOKIE)?.value); + const entryRedirect = authenticatedEntryRedirect(pathname, Boolean(session)); + if (entryRedirect) { + return NextResponse.redirect(new URL(entryRedirect, request.url)); + } + if (publicPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) { + return NextResponse.next(); + } + + if (!session) { + return NextResponse.redirect(new URL("/login", request.url)); + } + + if ((pathname === "/admin" || pathname.startsWith("/admin/") || pathname === "/health") + && !canAccessAdminPath(pathname, normalizeUserRole(session.role))) { + return NextResponse.redirect(new URL("/workspace", request.url)); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/((?!api|_next/static|_next/image|favicon.ico|icon.svg).*)"], +}; diff --git a/yuqei-contract-platform/apps/contract-web/src/services/contracts-client.ts b/yuqei-contract-platform/apps/contract-web/src/services/contracts-client.ts index bac209bd..bf860e88 100644 --- a/yuqei-contract-platform/apps/contract-web/src/services/contracts-client.ts +++ b/yuqei-contract-platform/apps/contract-web/src/services/contracts-client.ts @@ -2,6 +2,7 @@ import type { AccountSettings, LegalQaRequest, LegalQaResponse, + PlatformAdministratorPage, RolePermissionsPage, TenantPage, TenantUserPage, @@ -36,6 +37,12 @@ export async function getIdentityUsers(tenantId?: string): Promise { + return fetchClientJson( + "/api/contract-platform/identity/platform-administrators" + ); +} + export async function getIdentityRoles(): Promise { const payload = await fetchClientJson("/api/contract-platform/identity/roles"); return payload ?? { roles: [] }; diff --git a/yuqei-contract-platform/apps/contract-web/src/services/contracts.ts b/yuqei-contract-platform/apps/contract-web/src/services/contracts.ts index 88a64855..7fa0cfa7 100644 --- a/yuqei-contract-platform/apps/contract-web/src/services/contracts.ts +++ b/yuqei-contract-platform/apps/contract-web/src/services/contracts.ts @@ -1,7 +1,7 @@ -import { contracts as fallbackContracts, tasks as fallbackTasks } from "@/lib/mock-data"; -import { resolveCurrentRole, type UserRole } from "@/lib/access-control"; import { fetchContractApi } from "@/lib/contract-api"; +import { buildAiPlatformApiUrl, fetchAiPlatform } from "@/lib/ai-platform-api"; import { getContractWebSession } from "@/lib/contract-session"; +import type { UserRole } from "@/lib/access-control"; export type ContractListItem = { id: string; @@ -65,6 +65,70 @@ export type GlobalAuditEventQuery = { created_to?: string; }; +export type SecuritySource = "contract" | "ai"; + +export type SecurityAccessEventItem = { + id: string; + tenant_id: string; + user_id?: string | null; + session_id_hash?: string | null; + ip_hash?: string | null; + ua_hash?: string | null; + route_key: string; + http_method: string; + path: string; + resource_type?: string | null; + resource_id?: string | null; + data_class: string; + records_count: number; + bytes_count: number; + decision: string; + reason_code?: string | null; + status_code: number; + trace_id?: string | null; + created_at: string; +}; + +export type SecurityAccessEventPage = { + items: SecurityAccessEventItem[]; + total: number; + page: number; + page_size: number; +}; + +export type DataEgressLedgerItem = { + id: string; + tenant_id: string; + user_id: string; + ledger_date: string; + data_class: string; + records_count: number; + files_count: number; + bytes_count: number; + reports_count: number; + updated_at: string; +}; + +export type DataEgressLedgerPage = { + items: DataEgressLedgerItem[]; + total: number; + page: number; + page_size: number; +}; + +export type SecurityQuery = { + page?: number; + page_size?: number; + tenant_id?: string; + user_id?: string; + route_key?: string; + decision?: string; + data_class?: string; + created_from?: string; + created_to?: string; + ledger_date?: string; +}; + export type LegalQaCitation = { source_type: string; title: string; @@ -336,6 +400,130 @@ export type TenantUserPage = { total: number; }; +export type PlatformAdministratorItem = { + user_id: string; + tenant_id: string; + tenant_name: string; + email: string; + display_name: string; + role: UserRole; + status: TenantUserItem["status"]; + last_login_at?: string | null; + created_at: string; + updated_at: string; +}; + +export type PlatformAdministratorPage = { + items: PlatformAdministratorItem[]; + total: number; + active_total: number; +}; + +export type AccountConflictSeverity = "critical" | "warning"; + +export type AccountConflictCode = + | "duplicate_global_accounts" + | "missing_global_account" + | "missing_membership" + | "orphan_membership" + | "membership_identity_mismatch" + | "duplicate_membership_mapping" + | "membership_state_drift" + | "owner_flag_drift" + | "disabled_account_with_active_membership" + | "credential_identity_mismatch" + | "unsupported_credential_scheme" + | "active_identity_without_credential" + | "legacy_multi_credential_unified_missing"; + +export type AccountConflictCredentialCoverage = { + global_active: boolean; + legacy_present: number; + active_users_without_credential: number; +}; + +export type AccountConflictItem = { + diagnostic_id: string; + normalized_email?: string | null; + severity: AccountConflictSeverity; + conflict_codes: AccountConflictCode[]; + account_count: number; + user_count: number; + membership_count: number; + workspace_count: number; + credential_coverage: AccountConflictCredentialCoverage; + last_observed_at: string; +}; + +export type AccountConflictIssue = { + conflict_code: AccountConflictCode; + severity: AccountConflictSeverity; + entity_type: string; + entity_ids: string[]; +}; + +export type AccountConflictDetail = AccountConflictItem & { + issues: AccountConflictIssue[]; + accounts: Array<{ + id: string; + email: string; + display_name: string; + status: string; + email_verified_at?: string | null; + updated_at: string; + }>; + users: Array<{ + id: string; + tenant_id: string; + email: string; + display_name: string; + role: UserRole; + status: TenantUserItem["status"]; + deleted: boolean; + updated_at: string; + }>; + memberships: Array<{ + id: string; + account_id: string; + tenant_id: string; + tenant_user_id: string; + role: UserRole; + status: TenantUserItem["status"]; + is_owner: boolean; + updated_at: string; + }>; + credentials: Array<{ + id: string; + scope: "global" | "legacy"; + account_id?: string | null; + tenant_id?: string | null; + user_id?: string | null; + email?: string | null; + status: string; + scheme: string; + supported: boolean; + password_updated_at?: string | null; + password_reset_required: boolean; + }>; +}; + +export type AccountConflictPage = { + items: AccountConflictItem[]; + total: number; + page: number; + page_size: number; + critical_total: number; + warning_total: number; +}; + +export type AccountConflictQuery = { + page?: number; + page_size?: number; + severity?: AccountConflictSeverity; + conflict_code?: AccountConflictCode; + email?: string; +}; + export type AccountSettings = { id: string; tenant_id: string; @@ -767,17 +955,13 @@ export async function getContractPage(query: ContractQuery = {}): Promise( `/api/v1/contracts${suffix}`, `/api/contract-platform/contracts${suffix}`, - { cacheTtlMs: 5000 } + { cacheTtlMs: 5000, throwOnError: true } ); - if (payload) { - return payload; - } - const fallbackItems = searchParams.toString() ? [] : fallbackContracts.map(mapFallbackContract); - return { - items: fallbackItems, + return payload ?? { + items: [], page: query.page ?? 1, - page_size: query.page_size ?? (fallbackItems.length || 20), - total: fallbackItems.length + page_size: query.page_size ?? 20, + total: 0 }; } @@ -1088,16 +1272,10 @@ export async function runLegalAssetLifecycleAction( export async function getContractDetail(id: string): Promise { const encodedId = encodeURIComponent(id); - const roleSuffix = buildRoleSuffix(); - const payload = await fetchJson( - `/api/v1/contracts/${encodedId}${roleSuffix}`, - `/api/contract-platform/contracts/${encodedId}${roleSuffix}` + return fetchJson( + `/api/v1/contracts/${encodedId}`, + `/api/contract-platform/contracts/${encodedId}` ); - if (payload) { - return payload; - } - const fallback = fallbackContracts.find((item) => item.id === id); - return fallback ? mapFallbackContractDetail(fallback) : null; } export async function getStructuredReviewRisks( @@ -1162,21 +1340,12 @@ export async function getStructuredReviewCitations( } export async function getWorkspaceTasks(): Promise { - const roleSuffix = buildRoleSuffix(); const payload = await fetchJson( - `/api/v1/workspace${roleSuffix}`, - `/api/contract-platform/workspace${roleSuffix}`, - { timeoutMs: 1800, cacheTtlMs: 5000 } + "/api/v1/workspace", + "/api/contract-platform/workspace", + { timeoutMs: 1800, cacheTtlMs: 5000, throwOnError: true } ); - return payload?.pending_tasks?.length ? payload.pending_tasks : fallbackTasks.map((task) => ({ - id: task.id, - title: task.title, - type: task.type, - tone: task.tone, - summary: task.summary, - target_url: task.href, - bucket: inferFallbackTaskBucket(task.type) - })); + return payload?.pending_tasks ?? []; } export async function getContractFiles(query: FileQuery = {}): Promise { @@ -1219,6 +1388,42 @@ export async function getGlobalAuditEvents(query: GlobalAuditEventQuery = {}): P return payload ?? { items: [], total: 0, page: query.page ?? 1, page_size: query.page_size ?? 20 }; } +export async function getSecurityAccessEvents( + source: SecuritySource, + query: SecurityQuery = {}, +): Promise { + const searchParams = buildSecuritySearchParams(query, "events"); + const suffix = searchParams.toString() ? `?${searchParams.toString()}` : ""; + const payload = source === "ai" + ? await fetchAiJson( + `/api/v1/security/access-events${suffix}`, + `/api/ai-platform/security/access-events${suffix}` + ) + : await fetchJson( + `/api/v1/security/access-events${suffix}`, + `/api/contract-platform/security/access-events${suffix}` + ); + return payload ?? { items: [], total: 0, page: query.page ?? 1, page_size: query.page_size ?? 20 }; +} + +export async function getDataEgressLedger( + source: SecuritySource, + query: SecurityQuery = {}, +): Promise { + const searchParams = buildSecuritySearchParams(query, "ledger"); + const suffix = searchParams.toString() ? `?${searchParams.toString()}` : ""; + const payload = source === "ai" + ? await fetchAiJson( + `/api/v1/security/egress-ledger${suffix}`, + `/api/ai-platform/security/egress-ledger${suffix}` + ) + : await fetchJson( + `/api/v1/security/egress-ledger${suffix}`, + `/api/contract-platform/security/egress-ledger${suffix}` + ); + return payload ?? { items: [], total: 0, page: query.page ?? 1, page_size: query.page_size ?? 20 }; +} + export async function runLegalQa(request: LegalQaRequest): Promise { const response = await fetch(resolveUrl("/api/v1/legal/qa", "/api/contract-platform/legal/qa"), { method: "POST", @@ -1254,6 +1459,49 @@ export async function getIdentityUsers(tenantId?: string): Promise { + return fetchJson( + "/api/v1/identity/platform-administrators", + "/api/contract-platform/identity/platform-administrators" + ); +} + +export async function getAccountConflicts( + query: AccountConflictQuery = {} +): Promise { + const searchParams = new URLSearchParams(); + if (query.page && query.page > 1) { + searchParams.set("page", String(query.page)); + } + if (query.page_size) { + searchParams.set("page_size", String(query.page_size)); + } + if (query.severity) { + searchParams.set("severity", query.severity); + } + if (query.conflict_code) { + searchParams.set("conflict_code", query.conflict_code); + } + if (query.email?.trim()) { + searchParams.set("email", query.email.trim()); + } + const suffix = searchParams.toString() ? `?${searchParams.toString()}` : ""; + return fetchJson( + `/api/v1/identity/account-conflicts${suffix}`, + `/api/contract-platform/identity/account-conflicts${suffix}` + ); +} + +export async function getAccountConflict( + diagnosticId: string +): Promise { + const encodedId = encodeURIComponent(diagnosticId); + return fetchJson( + `/api/v1/identity/account-conflicts/${encodedId}`, + `/api/contract-platform/identity/account-conflicts/${encodedId}` + ); +} + export async function getIdentityRoles(): Promise { const payload = await fetchJson( "/api/v1/identity/roles", @@ -1272,7 +1520,7 @@ export async function getAccountSettings(): Promise { async function fetchJson( apiPathname: string, proxyPathname: string, - options: { timeoutMs?: number; cacheTtlMs?: number } = {} + options: { timeoutMs?: number; cacheTtlMs?: number; throwOnError?: boolean } = {} ): Promise { const controller = options.timeoutMs ? new AbortController() : null; const timeout = controller @@ -1302,6 +1550,10 @@ async function fetchJson( ? await fetchContractApi(new URL(url), fetchInit) : await fetch(url, fetchInit); if (!response.ok) { + if (options.throwOnError) { + const errorPayload = await response.json().catch(() => null) as { detail?: string; message?: string } | null; + throw new Error(errorPayload?.detail || errorPayload?.message || `Contract API request failed (${response.status}).`); + } return null; } const payload = await response.json() as T; @@ -1312,7 +1564,10 @@ async function fetchJson( }); } return payload; - } catch { + } catch (error) { + if (options.throwOnError) { + throw error instanceof Error ? error : new Error("Contract API request failed."); + } return null; } finally { if (timeout) { @@ -1321,6 +1576,24 @@ async function fetchJson( } } +async function fetchAiJson( + apiPathname: string, + proxyPathname: string, +): Promise { + try { + const isServerRequest = typeof window === "undefined"; + const response = isServerRequest + ? await fetchAiPlatform(buildAiPlatformApiUrl(apiPathname)) + : await fetch(proxyPathname, { cache: "no-store" }); + if (!response.ok) { + return null; + } + return await response.json() as T; + } catch { + return null; + } +} + function resolveUrl(apiPathname: string, proxyPathname: string) { if (typeof window === "undefined") { return new URL(apiPathname, contractApiBaseUrl).toString(); @@ -1411,6 +1684,31 @@ function buildGlobalAuditSearchParams(query: GlobalAuditEventQuery) { return searchParams; } +function buildSecuritySearchParams(query: SecurityQuery, kind: "events" | "ledger") { + const searchParams = new URLSearchParams(); + if (query.page && query.page > 1) { + searchParams.set("page", String(query.page)); + } + if (query.page_size) { + searchParams.set("page_size", String(query.page_size)); + } + for (const key of ["tenant_id", "user_id", "data_class"] as const) { + if (query[key]?.trim()) { + searchParams.set(key, query[key]!.trim()); + } + } + if (kind === "events") { + for (const key of ["route_key", "decision", "created_from", "created_to"] as const) { + if (query[key]?.trim()) { + searchParams.set(key, query[key]!.trim()); + } + } + } else if (query.ledger_date?.trim()) { + searchParams.set("ledger_date", query.ledger_date.trim()); + } + return searchParams; +} + function buildStandardClauseSearchParams(query: StandardClauseQuery) { const searchParams = new URLSearchParams(); if (query.keyword?.trim()) { @@ -1462,69 +1760,6 @@ function buildLegalArticleSearchParams(query: LegalArticleQuery) { return searchParams; } -function buildRoleSuffix() { - const searchParams = new URLSearchParams(); - searchParams.set("role", resolveCurrentRole()); - return `?${searchParams.toString()}`; -} - -function mapFallbackContract(contract: typeof fallbackContracts[number]): ContractListItem { - return { - id: contract.id, - title: contract.title, - code: contract.code, - counterparty: contract.counterparty, - status: contract.status, - status_label: contract.status, - status_tone: contract.statusTone, - next_action: contract.nextAction, - updated_at: contract.updatedAt, - updated_label: contract.updatedAt, - amount: contract.amount, - owner: contract.owner - }; -} - -function mapFallbackContractDetail(contract: typeof fallbackContracts[number]): ContractDetail { - const fallbackBody = "\u7532\u65b9\u4e0e\u4e59\u65b9\u57fa\u4e8e\u771f\u5b9e\u4ea4\u6613\u80cc\u666f\uff0c\u7ecf\u5e73\u7b49\u534f\u5546\uff0c\u5f62\u6210\u672c\u5408\u540c\u3002\u53cc\u65b9\u5e94\u6309\u7ea6\u5b9a\u5c65\u884c\u4ea4\u4ed8\u3001\u9a8c\u6536\u3001\u4ed8\u6b3e\u3001\u4fdd\u5bc6\u548c\u8fdd\u7ea6\u8d23\u4efb\u3002"; - return { - ...mapFallbackContract(contract), - body_preview: fallbackBody, - body_content: fallbackBody, - next_actions: [contract.nextAction], - approval_timeline: [], - audit_events: [], - files: [], - review_tasks: [], - permissions: buildFallbackPermissions(resolveCurrentRole(), contract.status) - }; -} - -function buildFallbackPermissions(role: UserRole, status: string): ContractPermissions { - return { - role, - can_view: true, - can_edit: role === "admin" || role === "legal" || (role === "business" && ["draft", "rejected", "reviewing"].includes(status)), - can_submit_approval: role === "admin" || role === "legal" || (role === "business" && ["draft", "reviewing"].includes(status)), - can_resubmit_approval: role === "admin" || role === "legal" || (role === "business" && status === "rejected"), - can_process_approval: role === "admin" || role === "approver", - can_start_review: role === "admin" || role === "legal" || role === "business", - can_upload_file: role === "admin" || role === "legal" || role === "business", - can_download: role !== "observer" || ["approved", "signed", "archived"].includes(status), - can_view_audit: role === "admin" || role === "legal" - }; -} - -function inferFallbackTaskBucket(type: string): WorkspaceTask["bucket"] { - if (type.includes("\u5ba1\u6279")) { - return "approval"; - } - if (type.includes("\u5c65\u7ea6")) { - return "obligation"; - } - return "draft"; -} - export type NotificationType = | "approval_pending" | "approval_approved" diff --git a/yuqei-contract-platform/services/contract-api/.env.example b/yuqei-contract-platform/services/contract-api/.env.example index 1414bb07..e7d5c07b 100644 --- a/yuqei-contract-platform/services/contract-api/.env.example +++ b/yuqei-contract-platform/services/contract-api/.env.example @@ -24,6 +24,14 @@ CONTRACT_API_IDENTITY_LOGIN_EMAIL_LIMIT=10 CONTRACT_API_IDENTITY_LOGIN_IP_LIMIT=50 CONTRACT_API_IDENTITY_ANTI_ENUMERATION_FLOOR_MS=120 CONTRACT_API_IDENTITY_ANTI_ENUMERATION_JITTER_MS=30 +CONTRACT_API_ANTI_ABUSE_MODE=disabled +CONTRACT_API_ANTI_ABUSE_HASH_SECRET= +CONTRACT_API_ANTI_ABUSE_S3_RECORDS_DAILY_LIMIT=1000 +CONTRACT_API_ANTI_ABUSE_S4_RECORDS_DAILY_LIMIT=1000 +CONTRACT_API_ANTI_ABUSE_FILES_DAILY_LIMIT=50 +CONTRACT_API_ANTI_ABUSE_BYTES_DAILY_LIMIT=524288000 +CONTRACT_API_BATCH_DOWNLOAD_MAX_FILES=20 +CONTRACT_API_BATCH_DOWNLOAD_MAX_BYTES=104857600 # Production baseline: # CONTRACT_API_ENVIRONMENT=production @@ -33,6 +41,14 @@ CONTRACT_API_IDENTITY_ANTI_ENUMERATION_JITTER_MS=30 # CONTRACT_API_FILE_STORAGE_DIR=/data/contract-files # CONTRACT_API_UVICORN_WORKERS=1 # CONTRACT_API_READ_CACHE_TTL_SECONDS=2 +# CONTRACT_API_ANTI_ABUSE_MODE=observe +# CONTRACT_API_ANTI_ABUSE_HASH_SECRET=CHANGE_ME_USE_A_DIFFERENT_RANDOM_32_BYTE_SECRET +# CONTRACT_API_ANTI_ABUSE_S3_RECORDS_DAILY_LIMIT=1000 +# CONTRACT_API_ANTI_ABUSE_S4_RECORDS_DAILY_LIMIT=1000 +# CONTRACT_API_ANTI_ABUSE_FILES_DAILY_LIMIT=50 +# CONTRACT_API_ANTI_ABUSE_BYTES_DAILY_LIMIT=524288000 +# CONTRACT_API_BATCH_DOWNLOAD_MAX_FILES=20 +# CONTRACT_API_BATCH_DOWNLOAD_MAX_BYTES=104857600 # Without enterprise email, use disabled/false/true and deliver one-time invitation links offline. # CONTRACT_API_IDENTITY_EMAIL_DELIVERY_MODE=disabled # CONTRACT_API_IDENTITY_SELF_SERVICE_ENABLED=false diff --git a/yuqei-contract-platform/services/contract-api/migrations/versions/20260722_0032_add_security_access_events.py b/yuqei-contract-platform/services/contract-api/migrations/versions/20260722_0032_add_security_access_events.py new file mode 100644 index 00000000..ee4979bd --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/migrations/versions/20260722_0032_add_security_access_events.py @@ -0,0 +1,109 @@ +"""add security access events and egress ledger + +Revision ID: 20260722_0032 +Revises: 20260713_0031 +Create Date: 2026-07-22 +""" + +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260722_0032" +down_revision: Union[str, None] = "20260713_0031" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "security_access_events", + sa.Column("id", sa.String(length=64), nullable=False), + sa.Column("tenant_id", sa.String(length=64), nullable=False), + sa.Column("user_id", sa.String(length=128), nullable=True), + sa.Column("session_id_hash", sa.String(length=64), nullable=True), + sa.Column("ip_hash", sa.String(length=64), nullable=True), + sa.Column("ua_hash", sa.String(length=64), nullable=True), + sa.Column("route_key", sa.String(length=128), nullable=False), + sa.Column("http_method", sa.String(length=16), nullable=False), + sa.Column("path", sa.String(length=512), nullable=False), + sa.Column("resource_type", sa.String(length=64), nullable=True), + sa.Column("resource_id", sa.String(length=128), nullable=True), + sa.Column("data_class", sa.String(length=8), nullable=False), + sa.Column("records_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("bytes_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("decision", sa.String(length=32), nullable=False), + sa.Column("reason_code", sa.String(length=255), nullable=True), + sa.Column("status_code", sa.Integer(), nullable=False, server_default="0"), + sa.Column("trace_id", sa.String(length=128), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_security_access_events_tenant_id"), "security_access_events", ["tenant_id"]) + op.create_index(op.f("ix_security_access_events_user_id"), "security_access_events", ["user_id"]) + op.create_index(op.f("ix_security_access_events_session_id_hash"), "security_access_events", ["session_id_hash"]) + op.create_index(op.f("ix_security_access_events_ip_hash"), "security_access_events", ["ip_hash"]) + op.create_index(op.f("ix_security_access_events_route_key"), "security_access_events", ["route_key"]) + op.create_index(op.f("ix_security_access_events_data_class"), "security_access_events", ["data_class"]) + op.create_index(op.f("ix_security_access_events_decision"), "security_access_events", ["decision"]) + op.create_index(op.f("ix_security_access_events_trace_id"), "security_access_events", ["trace_id"]) + op.create_index(op.f("ix_security_access_events_created_at"), "security_access_events", ["created_at"]) + op.create_index("ix_security_access_events_tenant_created", "security_access_events", ["tenant_id", "created_at"]) + op.create_index("ix_security_access_events_user_created", "security_access_events", ["user_id", "created_at"]) + op.create_index("ix_security_access_events_route_created", "security_access_events", ["route_key", "created_at"]) + op.create_index("ix_security_access_events_decision_created", "security_access_events", ["decision", "created_at"]) + op.create_index("ix_security_access_events_ip_created", "security_access_events", ["ip_hash", "created_at"]) + + op.create_table( + "data_egress_ledger", + sa.Column("id", sa.String(length=64), nullable=False), + sa.Column("tenant_id", sa.String(length=64), nullable=False), + sa.Column("user_id", sa.String(length=128), nullable=False, server_default=""), + sa.Column("ledger_date", sa.Date(), nullable=False), + sa.Column("data_class", sa.String(length=8), nullable=False), + sa.Column("records_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("files_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("bytes_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("reports_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("tenant_id", "user_id", "ledger_date", "data_class", name="ux_data_egress_ledger_subject_day"), + ) + op.create_index(op.f("ix_data_egress_ledger_tenant_id"), "data_egress_ledger", ["tenant_id"]) + op.create_index(op.f("ix_data_egress_ledger_user_id"), "data_egress_ledger", ["user_id"]) + op.create_index(op.f("ix_data_egress_ledger_ledger_date"), "data_egress_ledger", ["ledger_date"]) + op.create_index(op.f("ix_data_egress_ledger_data_class"), "data_egress_ledger", ["data_class"]) + op.create_index(op.f("ix_data_egress_ledger_updated_at"), "data_egress_ledger", ["updated_at"]) + op.create_index("ix_data_egress_ledger_tenant_date", "data_egress_ledger", ["tenant_id", "ledger_date"]) + op.create_index("ix_data_egress_ledger_user_date", "data_egress_ledger", ["user_id", "ledger_date"]) + + +def downgrade() -> None: + op.drop_index("ix_data_egress_ledger_user_date", table_name="data_egress_ledger") + op.drop_index("ix_data_egress_ledger_tenant_date", table_name="data_egress_ledger") + op.drop_index(op.f("ix_data_egress_ledger_updated_at"), table_name="data_egress_ledger") + op.drop_index(op.f("ix_data_egress_ledger_data_class"), table_name="data_egress_ledger") + op.drop_index(op.f("ix_data_egress_ledger_ledger_date"), table_name="data_egress_ledger") + op.drop_index(op.f("ix_data_egress_ledger_user_id"), table_name="data_egress_ledger") + op.drop_index(op.f("ix_data_egress_ledger_tenant_id"), table_name="data_egress_ledger") + op.drop_table("data_egress_ledger") + + op.drop_index("ix_security_access_events_ip_created", table_name="security_access_events") + op.drop_index("ix_security_access_events_decision_created", table_name="security_access_events") + op.drop_index("ix_security_access_events_route_created", table_name="security_access_events") + op.drop_index("ix_security_access_events_user_created", table_name="security_access_events") + op.drop_index("ix_security_access_events_tenant_created", table_name="security_access_events") + op.drop_index(op.f("ix_security_access_events_created_at"), table_name="security_access_events") + op.drop_index(op.f("ix_security_access_events_trace_id"), table_name="security_access_events") + op.drop_index(op.f("ix_security_access_events_decision"), table_name="security_access_events") + op.drop_index(op.f("ix_security_access_events_data_class"), table_name="security_access_events") + op.drop_index(op.f("ix_security_access_events_route_key"), table_name="security_access_events") + op.drop_index(op.f("ix_security_access_events_ip_hash"), table_name="security_access_events") + op.drop_index(op.f("ix_security_access_events_session_id_hash"), table_name="security_access_events") + op.drop_index(op.f("ix_security_access_events_user_id"), table_name="security_access_events") + op.drop_index(op.f("ix_security_access_events_tenant_id"), table_name="security_access_events") + op.drop_table("security_access_events") diff --git a/yuqei-contract-platform/services/contract-api/migrations/versions/20260801_0033_add_workspace_identity_fields.py b/yuqei-contract-platform/services/contract-api/migrations/versions/20260801_0033_add_workspace_identity_fields.py new file mode 100644 index 00000000..623caf1d --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/migrations/versions/20260801_0033_add_workspace_identity_fields.py @@ -0,0 +1,110 @@ +"""add workspace identity fields + +Revision ID: 20260801_0033 +Revises: 20260722_0032 +Create Date: 2026-08-01 +""" + +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260801_0033" +down_revision: Union[str, None] = "20260722_0032" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + dialect_name = op.get_bind().dialect.name + op.add_column( + "tenants", + sa.Column("workspace_type", sa.String(length=32), nullable=False, server_default="organization"), + ) + op.add_column("tenants", sa.Column("owner_user_id", sa.String(length=64), nullable=True)) + op.add_column("tenants", sa.Column("legal_name", sa.String(length=255), nullable=True)) + op.create_index(op.f("ix_tenants_workspace_type"), "tenants", ["workspace_type"]) + op.create_index(op.f("ix_tenants_owner_user_id"), "tenants", ["owner_user_id"]) + if dialect_name == "sqlite": + op.execute( + sa.text( + """ + create trigger ck_tenants_workspace_type_insert + before insert on tenants + when new.workspace_type not in ('system', 'personal', 'organization') + begin + select raise(abort, 'invalid tenants.workspace_type'); + end + """ + ) + ) + op.execute( + sa.text( + """ + create trigger ck_tenants_workspace_type_update + before update of workspace_type on tenants + when new.workspace_type not in ('system', 'personal', 'organization') + begin + select raise(abort, 'invalid tenants.workspace_type'); + end + """ + ) + ) + else: + op.create_check_constraint( + "ck_tenants_workspace_type", + "tenants", + "workspace_type in ('system', 'personal', 'organization')", + ) + + op.add_column( + "tenant_users", + sa.Column("platform_permissions", sa.JSON(), nullable=False, server_default=sa.text("'[]'")), + ) + + op.execute( + sa.text( + """ + update tenants + set workspace_type = 'system', owner_user_id = 'user-current-admin' + where id = 'default' + """ + ) + ) + op.execute( + sa.text( + """ + update tenants + set legal_name = name + where workspace_type = 'organization' and legal_name is null + """ + ) + ) + op.execute( + sa.text( + """ + update tenant_users + set platform_permissions = '["platform_admin"]' + where tenant_id = 'default' and id = 'user-current-admin' + """ + ) + ) + + +def downgrade() -> None: + dialect_name = op.get_bind().dialect.name + op.drop_column("tenant_users", "platform_permissions") + if dialect_name == "sqlite": + op.execute(sa.text("drop trigger if exists ck_tenants_workspace_type_update")) + op.execute(sa.text("drop trigger if exists ck_tenants_workspace_type_insert")) + else: + op.drop_constraint("ck_tenants_workspace_type", "tenants", type_="check") + op.drop_index(op.f("ix_tenants_owner_user_id"), table_name="tenants") + op.drop_index(op.f("ix_tenants_workspace_type"), table_name="tenants") + op.drop_column("tenants", "legal_name") + op.drop_column("tenants", "owner_user_id") + op.drop_column("tenants", "workspace_type") diff --git a/yuqei-contract-platform/services/contract-api/migrations/versions/20260801_0034_add_identity_agreement_consents.py b/yuqei-contract-platform/services/contract-api/migrations/versions/20260801_0034_add_identity_agreement_consents.py new file mode 100644 index 00000000..47f3a261 --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/migrations/versions/20260801_0034_add_identity_agreement_consents.py @@ -0,0 +1,105 @@ +"""add identity agreement consents + +Revision ID: 20260801_0034 +Revises: 20260801_0033 +Create Date: 2026-08-01 +""" + +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260801_0034" +down_revision: Union[str, None] = "20260801_0033" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "identity_agreement_consents", + sa.Column("id", sa.String(length=64), nullable=False), + sa.Column("tenant_id", sa.String(length=64), nullable=False), + sa.Column("user_id", sa.String(length=64), nullable=False), + sa.Column("agreement_type", sa.String(length=64), nullable=False), + sa.Column("agreement_version", sa.String(length=128), nullable=False), + sa.Column("agreed_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("source", sa.String(length=64), nullable=False), + sa.Column("client_ip", sa.String(length=64), nullable=True), + sa.Column("user_agent", sa.String(length=512), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint( + "agreement_type in ('user_terms', 'privacy_policy', 'service_agreement', 'ai_use_notice')", + name="ck_identity_agreement_consents_type", + ), + sa.CheckConstraint( + "source in ('personal_registration', 'organization_registration')", + name="ck_identity_agreement_consents_source", + ), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"]), + sa.ForeignKeyConstraint(["user_id"], ["tenant_users.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "tenant_id", + "user_id", + "agreement_type", + "agreement_version", + name="ux_identity_agreement_consents_subject_version", + ), + ) + op.create_index( + op.f("ix_identity_agreement_consents_tenant_id"), + "identity_agreement_consents", + ["tenant_id"], + ) + op.create_index( + op.f("ix_identity_agreement_consents_user_id"), + "identity_agreement_consents", + ["user_id"], + ) + op.create_index( + op.f("ix_identity_agreement_consents_agreement_type"), + "identity_agreement_consents", + ["agreement_type"], + ) + op.create_index( + op.f("ix_identity_agreement_consents_agreement_version"), + "identity_agreement_consents", + ["agreement_version"], + ) + op.create_index( + op.f("ix_identity_agreement_consents_agreed_at"), + "identity_agreement_consents", + ["agreed_at"], + ) + op.create_index( + op.f("ix_identity_agreement_consents_source"), + "identity_agreement_consents", + ["source"], + ) + op.create_index( + "ix_identity_agreement_consents_tenant_agreed", + "identity_agreement_consents", + ["tenant_id", "agreed_at"], + ) + op.create_index( + "ix_identity_agreement_consents_user_agreed", + "identity_agreement_consents", + ["user_id", "agreed_at"], + ) + + +def downgrade() -> None: + op.drop_index("ix_identity_agreement_consents_user_agreed", table_name="identity_agreement_consents") + op.drop_index("ix_identity_agreement_consents_tenant_agreed", table_name="identity_agreement_consents") + op.drop_index(op.f("ix_identity_agreement_consents_source"), table_name="identity_agreement_consents") + op.drop_index(op.f("ix_identity_agreement_consents_agreed_at"), table_name="identity_agreement_consents") + op.drop_index(op.f("ix_identity_agreement_consents_agreement_version"), table_name="identity_agreement_consents") + op.drop_index(op.f("ix_identity_agreement_consents_agreement_type"), table_name="identity_agreement_consents") + op.drop_index(op.f("ix_identity_agreement_consents_user_id"), table_name="identity_agreement_consents") + op.drop_index(op.f("ix_identity_agreement_consents_tenant_id"), table_name="identity_agreement_consents") + op.drop_table("identity_agreement_consents") diff --git a/yuqei-contract-platform/services/contract-api/migrations/versions/20260801_0035_add_global_accounts_and_workspace_memberships.py b/yuqei-contract-platform/services/contract-api/migrations/versions/20260801_0035_add_global_accounts_and_workspace_memberships.py new file mode 100644 index 00000000..ac88ed5e --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/migrations/versions/20260801_0035_add_global_accounts_and_workspace_memberships.py @@ -0,0 +1,176 @@ +"""add global accounts and workspace memberships + +Revision ID: 20260801_0035 +Revises: 20260801_0034 +Create Date: 2026-08-01 +""" + +from __future__ import annotations + +from datetime import datetime +from hashlib import sha256 +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260801_0035" +down_revision: Union[str, None] = "20260801_0034" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _stable_id(prefix: str, value: str) -> str: + return f"{prefix}-{sha256(value.encode('utf-8')).hexdigest()[:24]}" + + +def _as_datetime(value: object) -> datetime: + if isinstance(value, datetime): + return value + return datetime.fromisoformat(str(value)) + + +def upgrade() -> None: + op.create_table( + "identity_accounts", + sa.Column("id", sa.String(length=64), nullable=False), + sa.Column("email", sa.String(length=255), nullable=False), + sa.Column("display_name", sa.String(length=128), nullable=False), + sa.Column("status", sa.String(length=32), nullable=False, server_default="active"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("email", name="ux_identity_accounts_email"), + ) + op.create_index(op.f("ix_identity_accounts_email"), "identity_accounts", ["email"]) + op.create_index(op.f("ix_identity_accounts_status"), "identity_accounts", ["status"]) + op.create_index(op.f("ix_identity_accounts_updated_at"), "identity_accounts", ["updated_at"]) + + op.create_table( + "workspace_memberships", + sa.Column("id", sa.String(length=64), nullable=False), + sa.Column("account_id", sa.String(length=64), nullable=False), + sa.Column("tenant_id", sa.String(length=64), nullable=False), + sa.Column("tenant_user_id", sa.String(length=64), nullable=False), + sa.Column("role", sa.String(length=32), nullable=False, server_default="business"), + sa.Column("status", sa.String(length=32), nullable=False, server_default="active"), + sa.Column("is_owner", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("joined_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["account_id"], ["identity_accounts.id"]), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"]), + sa.ForeignKeyConstraint(["tenant_user_id"], ["tenant_users.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("account_id", "tenant_id", name="ux_workspace_memberships_account_tenant"), + sa.UniqueConstraint("tenant_user_id", name="ux_workspace_memberships_tenant_user"), + ) + op.create_index(op.f("ix_workspace_memberships_account_id"), "workspace_memberships", ["account_id"]) + op.create_index(op.f("ix_workspace_memberships_tenant_id"), "workspace_memberships", ["tenant_id"]) + op.create_index(op.f("ix_workspace_memberships_tenant_user_id"), "workspace_memberships", ["tenant_user_id"]) + op.create_index(op.f("ix_workspace_memberships_status"), "workspace_memberships", ["status"]) + op.create_index(op.f("ix_workspace_memberships_updated_at"), "workspace_memberships", ["updated_at"]) + op.create_index( + "ix_workspace_memberships_account_status", + "workspace_memberships", + ["account_id", "status"], + ) + op.create_index( + "ix_workspace_memberships_tenant_status", + "workspace_memberships", + ["tenant_id", "status"], + ) + + bind = op.get_bind() + users = bind.execute( + sa.text( + """ + select u.id, u.tenant_id, u.email, u.display_name, u.role, u.status, + u.created_at, u.updated_at, t.owner_user_id + from tenant_users u + join tenants t on t.id = u.tenant_id + where u.deleted_at is null and t.deleted_at is null + order by u.created_at, u.id + """ + ) + ).mappings().all() + + account_by_email: dict[str, dict[str, object]] = {} + memberships: list[dict[str, object]] = [] + seen_account_tenants: set[tuple[str, str]] = set() + for user in users: + email = str(user["email"]).strip().lower() + account = account_by_email.get(email) + if account is None: + account = { + "id": _stable_id("identity-account", email), + "email": email, + "display_name": str(user["display_name"]), + "status": "active", + "created_at": _as_datetime(user["created_at"]), + "updated_at": _as_datetime(user["updated_at"]), + } + account_by_email[email] = account + account_tenant = (str(account["id"]), str(user["tenant_id"])) + if account_tenant in seen_account_tenants: + continue + seen_account_tenants.add(account_tenant) + user_status = str(user["status"]) + membership_status = user_status if user_status in {"active", "invited", "disabled"} else "disabled" + memberships.append( + { + "id": _stable_id("workspace-membership", str(user["id"])), + "account_id": account["id"], + "tenant_id": user["tenant_id"], + "tenant_user_id": user["id"], + "role": user["role"], + "status": membership_status, + "is_owner": user["owner_user_id"] == user["id"], + "joined_at": _as_datetime(user["created_at"]), + "created_at": _as_datetime(user["created_at"]), + "updated_at": _as_datetime(user["updated_at"]), + } + ) + + identity_accounts = sa.table( + "identity_accounts", + sa.column("id", sa.String()), + sa.column("email", sa.String()), + sa.column("display_name", sa.String()), + sa.column("status", sa.String()), + sa.column("created_at", sa.DateTime(timezone=True)), + sa.column("updated_at", sa.DateTime(timezone=True)), + ) + workspace_memberships = sa.table( + "workspace_memberships", + sa.column("id", sa.String()), + sa.column("account_id", sa.String()), + sa.column("tenant_id", sa.String()), + sa.column("tenant_user_id", sa.String()), + sa.column("role", sa.String()), + sa.column("status", sa.String()), + sa.column("is_owner", sa.Boolean()), + sa.column("joined_at", sa.DateTime(timezone=True)), + sa.column("created_at", sa.DateTime(timezone=True)), + sa.column("updated_at", sa.DateTime(timezone=True)), + ) + if account_by_email: + op.bulk_insert(identity_accounts, list(account_by_email.values())) + if memberships: + op.bulk_insert(workspace_memberships, memberships) + + +def downgrade() -> None: + op.drop_index("ix_workspace_memberships_tenant_status", table_name="workspace_memberships") + op.drop_index("ix_workspace_memberships_account_status", table_name="workspace_memberships") + op.drop_index(op.f("ix_workspace_memberships_updated_at"), table_name="workspace_memberships") + op.drop_index(op.f("ix_workspace_memberships_status"), table_name="workspace_memberships") + op.drop_index(op.f("ix_workspace_memberships_tenant_user_id"), table_name="workspace_memberships") + op.drop_index(op.f("ix_workspace_memberships_tenant_id"), table_name="workspace_memberships") + op.drop_index(op.f("ix_workspace_memberships_account_id"), table_name="workspace_memberships") + op.drop_table("workspace_memberships") + op.drop_index(op.f("ix_identity_accounts_updated_at"), table_name="identity_accounts") + op.drop_index(op.f("ix_identity_accounts_status"), table_name="identity_accounts") + op.drop_index(op.f("ix_identity_accounts_email"), table_name="identity_accounts") + op.drop_table("identity_accounts") diff --git a/yuqei-contract-platform/services/contract-api/migrations/versions/20260802_0036_add_global_credentials_and_email_verification.py b/yuqei-contract-platform/services/contract-api/migrations/versions/20260802_0036_add_global_credentials_and_email_verification.py new file mode 100644 index 00000000..814092b5 --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/migrations/versions/20260802_0036_add_global_credentials_and_email_verification.py @@ -0,0 +1,143 @@ +"""add global credentials and email verification + +Revision ID: 20260802_0036 +Revises: 20260801_0035 +Create Date: 2026-08-02 +""" + +from __future__ import annotations + +from datetime import datetime +from hashlib import sha256 +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260802_0036" +down_revision: Union[str, None] = "20260801_0035" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _stable_id(account_id: str) -> str: + return f"identity-credential-{sha256(account_id.encode('utf-8')).hexdigest()[:24]}" + + +def _as_datetime(value: object) -> datetime: + if isinstance(value, datetime): + return value + return datetime.fromisoformat(str(value)) + + +def upgrade() -> None: + op.add_column( + "identity_accounts", + sa.Column("email_verified_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index( + op.f("ix_identity_accounts_email_verified_at"), + "identity_accounts", + ["email_verified_at"], + ) + op.create_table( + "identity_account_credentials", + sa.Column("id", sa.String(length=64), nullable=False), + sa.Column("account_id", sa.String(length=64), nullable=False), + sa.Column("password_hash", sa.String(length=512), nullable=False), + sa.Column("password_scheme", sa.String(length=32), nullable=False), + sa.Column("password_updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("status", sa.String(length=32), nullable=False, server_default="active"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["account_id"], ["identity_accounts.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("account_id", name="ux_identity_account_credentials_account"), + ) + op.create_index( + op.f("ix_identity_account_credentials_account_id"), + "identity_account_credentials", + ["account_id"], + ) + op.create_index( + "ix_identity_account_credentials_status", + "identity_account_credentials", + ["status"], + ) + op.create_index( + op.f("ix_identity_account_credentials_updated_at"), + "identity_account_credentials", + ["updated_at"], + ) + + bind = op.get_bind() + # Historical organization registration marked tenant credentials verified + # without an email challenge, so global verification must start empty. A + # single-member legacy credential can still be copied without merging accounts. + credential_rows = bind.execute( + sa.text( + """ + select m.account_id, min(c.password_hash) as password_hash, + min(c.password_scheme) as password_scheme, + max(c.password_updated_at) as password_updated_at, + min(c.created_at) as created_at, max(c.updated_at) as updated_at + from workspace_memberships m + join tenant_user_credentials c on c.user_id = m.tenant_user_id + where c.deleted_at is null + group by m.account_id + having count(c.id) = 1 + and (select count(*) from workspace_memberships all_m + where all_m.account_id = m.account_id) = 1 + """ + ) + ).mappings().all() + credentials = sa.table( + "identity_account_credentials", + sa.column("id", sa.String()), + sa.column("account_id", sa.String()), + sa.column("password_hash", sa.String()), + sa.column("password_scheme", sa.String()), + sa.column("password_updated_at", sa.DateTime(timezone=True)), + sa.column("status", sa.String()), + sa.column("created_at", sa.DateTime(timezone=True)), + sa.column("updated_at", sa.DateTime(timezone=True)), + ) + if credential_rows: + op.bulk_insert( + credentials, + [ + { + "id": _stable_id(str(row["account_id"])), + "account_id": row["account_id"], + "password_hash": row["password_hash"], + "password_scheme": row["password_scheme"], + "password_updated_at": _as_datetime(row["password_updated_at"]), + "status": "active", + "created_at": _as_datetime(row["created_at"]), + "updated_at": _as_datetime(row["updated_at"]), + } + for row in credential_rows + ], + ) + + +def downgrade() -> None: + op.drop_index( + op.f("ix_identity_account_credentials_updated_at"), + table_name="identity_account_credentials", + ) + op.drop_index( + "ix_identity_account_credentials_status", + table_name="identity_account_credentials", + ) + op.drop_index( + op.f("ix_identity_account_credentials_account_id"), + table_name="identity_account_credentials", + ) + op.drop_table("identity_account_credentials") + op.drop_index( + op.f("ix_identity_accounts_email_verified_at"), + table_name="identity_accounts", + ) + op.drop_column("identity_accounts", "email_verified_at") diff --git a/yuqei-contract-platform/services/contract-api/pyproject.toml b/yuqei-contract-platform/services/contract-api/pyproject.toml index 838ff627..f10966df 100644 --- a/yuqei-contract-platform/services/contract-api/pyproject.toml +++ b/yuqei-contract-platform/services/contract-api/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "psycopg[binary]>=3.2", "python-docx>=1.1", "reportlab>=4.2", + "redis>=5.0", "sqlalchemy>=2.0", "uvicorn>=0.34", ] diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/anti_abuse.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/anti_abuse.py new file mode 100644 index 00000000..2caef524 --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/anti_abuse.py @@ -0,0 +1,634 @@ +from __future__ import annotations + +import hashlib +import hmac +import time +from dataclasses import dataclass +from datetime import UTC, date, datetime +from typing import Literal +from uuid import uuid4 + +from fastapi import Request +from fastapi.responses import JSONResponse +from sqlalchemy.engine import Engine +from sqlalchemy.exc import SQLAlchemyError + + +AntiAbuseMode = Literal["disabled", "observe", "enforce"] +DataClass = Literal["S0", "S1", "S2", "S3", "S4"] +Decision = Literal["allow", "observe", "block"] + + +@dataclass(frozen=True) +class EndpointRiskPolicy: + route_key: str + method: str + path_prefix: str + data_class: DataClass + user_limit_per_minute: int + tenant_limit_per_minute: int + ip_limit_per_minute: int + budget_units: Literal["none", "records", "files", "bytes"] = "none" + sensitive: bool = False + path_contains: str | None = None + + +@dataclass(frozen=True) +class AntiAbuseDecision: + decision: Decision + policy: EndpointRiskPolicy | None + retry_after_seconds: int | None = None + reason: str | None = None + + +@dataclass(frozen=True) +class EgressBudgetLimits: + s3_records_per_day: int = 1000 + s4_records_per_day: int = 1000 + files_per_day: int = 50 + bytes_per_day: int = 500 * 1024 * 1024 + + +class InMemorySlidingWindowLimiter: + def __init__(self, *, window_seconds: int = 60, max_keys: int = 10000) -> None: + self._window_seconds = window_seconds + self._max_keys = max_keys + self._events: dict[str, list[float]] = {} + + def hit(self, key: str, *, limit: int, now: float | None = None) -> tuple[bool, int]: + if limit < 1: + return False, self._window_seconds + current_time = now if now is not None else time.time() + cutoff = current_time - self._window_seconds + events = [item for item in self._events.get(key, []) if item > cutoff] + allowed = len(events) < limit + if allowed: + events.append(current_time) + self._events[key] = events + if len(self._events) > self._max_keys: + self._prune(cutoff) + if allowed: + return True, 0 + oldest = min(events) if events else current_time + retry_after = max(1, int(self._window_seconds - (current_time - oldest))) + return False, retry_after + + def _prune(self, cutoff: float) -> None: + empty_keys = [key for key, events in self._events.items() if not any(item > cutoff for item in events)] + for key in empty_keys: + self._events.pop(key, None) + + +class RedisSlidingWindowLimiter: + def __init__( + self, + redis_url: str, + *, + window_seconds: int = 60, + key_prefix: str = "abuse:rate", + fallback: InMemorySlidingWindowLimiter | None = None, + ) -> None: + self._redis_url = redis_url + self._window_seconds = window_seconds + self._key_prefix = key_prefix + self._fallback = fallback or InMemorySlidingWindowLimiter(window_seconds=window_seconds) + self._client = None + self._redis_error_count = 0 + + @property + def fallback_active(self) -> bool: + return self._redis_error_count > 0 + + def hit(self, key: str, *, limit: int, now: float | None = None) -> tuple[bool, int]: + if not self._redis_url.strip(): + return self._fallback.hit(key, limit=limit, now=now) + try: + client = self._get_client() + current_time = now if now is not None else time.time() + redis_key = f"{self._key_prefix}:{key}" + member = f"{current_time:.6f}:{uuid4().hex}" + cutoff = current_time - self._window_seconds + pipe = client.pipeline() + pipe.zremrangebyscore(redis_key, 0, cutoff) + pipe.zcard(redis_key) + pipe.zadd(redis_key, {member: current_time}) + pipe.expire(redis_key, self._window_seconds * 2) + _, count_before, _, _ = pipe.execute() + allowed = int(count_before) < limit + if allowed: + return True, 0 + client.zrem(redis_key, member) + oldest_items = client.zrange(redis_key, 0, 0, withscores=True) + oldest = float(oldest_items[0][1]) if oldest_items else current_time + retry_after = max(1, int(self._window_seconds - (current_time - oldest))) + return False, retry_after + except Exception: + self._redis_error_count += 1 + return self._fallback.hit(key, limit=limit, now=now) + + def _get_client(self): + if self._client is None: + from redis import Redis + + self._client = Redis.from_url(self._redis_url, socket_connect_timeout=0.2, socket_timeout=0.2) + return self._client + + +class AntiAbuseGuard: + def __init__( + self, + *, + mode: AntiAbuseMode, + service_name: str, + policies: tuple[EndpointRiskPolicy, ...], + hash_secret: str = "", + limiter: InMemorySlidingWindowLimiter | None = None, + redis_url: str = "", + ) -> None: + self.mode = mode + self.service_name = service_name + self.policies = policies + self.hash_secret = hash_secret or f"{service_name}-development-anti-abuse-secret" + self.limiter = limiter or RedisSlidingWindowLimiter(redis_url, key_prefix=f"abuse:rate:{service_name}") + self.events: list[dict[str, object]] = [] + + @property + def enabled(self) -> bool: + return self.mode in {"observe", "enforce"} + + async def inspect(self, request: Request, *, tenant_id: str, user_id: str | None) -> AntiAbuseDecision: + if not self.enabled: + return AntiAbuseDecision(decision="allow", policy=None) + policy = match_policy(request.method, request.url.path, self.policies) + if policy is None: + return AntiAbuseDecision(decision="allow", policy=None) + + dimensions = ( + ("user", _normalize_subject(user_id) or "anonymous", policy.user_limit_per_minute), + ("tenant", _normalize_subject(tenant_id) or "default", policy.tenant_limit_per_minute), + ("ip", self._subject_hash(_client_ip(request)), policy.ip_limit_per_minute), + ) + retry_after = 0 + blocked_dimensions: list[str] = [] + for dimension, subject, limit in dimensions: + key = f"{self.service_name}:{policy.route_key}:{dimension}:{subject}" + allowed, retry = self.limiter.hit(key, limit=limit) + if not allowed: + blocked_dimensions.append(dimension) + retry_after = max(retry_after, retry) + + redis_fallback_active = bool(getattr(self.limiter, "fallback_active", False)) + if not blocked_dimensions: + decision: Decision = "allow" + reason = "redis_fallback_active" if redis_fallback_active else None + elif self.mode == "observe": + decision = "observe" + reason = f"rate_limit_observed:{','.join(blocked_dimensions)}" + else: + decision = "block" + reason = f"rate_limit_exceeded:{','.join(blocked_dimensions)}" + + return AntiAbuseDecision( + decision=decision, + policy=policy, + retry_after_seconds=retry_after or None, + reason=reason, + ) + + def record( + self, + request: Request, + *, + decision: AntiAbuseDecision, + tenant_id: str, + user_id: str | None, + status_code: int, + trace_id: str | None = None, + ) -> None: + if not self.enabled or decision.policy is None: + return + event = { + "service": self.service_name, + "route_key": decision.policy.route_key, + "method": request.method, + "path": request.url.path, + "data_class": decision.policy.data_class, + "tenant_id": tenant_id, + "user_id": user_id, + "user_id_present": bool(user_id), + "ip_hash": self._subject_hash(_client_ip(request)), + "ua_hash": self._subject_hash(request.headers.get("user-agent", "")), + "decision": decision.decision, + "reason": decision.reason, + "budget_units": decision.policy.budget_units, + "status_code": status_code, + "trace_id": trace_id, + "created_at": time.time(), + } + self.events.append(event) + if len(self.events) > 1000: + del self.events[: len(self.events) - 1000] + + def _subject_hash(self, value: str | None) -> str: + normalized = value or "unknown" + digest = hmac.new( + self.hash_secret.encode("utf-8"), + normalized.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return digest[:24] + + +def match_policy(method: str, path: str, policies: tuple[EndpointRiskPolicy, ...]) -> EndpointRiskPolicy | None: + normalized_method = method.upper() + matches = [ + policy + for policy in policies + if policy.method == normalized_method + and (path == policy.path_prefix or path.startswith(policy.path_prefix)) + and (policy.path_contains is None or policy.path_contains in path) + ] + if not matches: + return None + return max(matches, key=lambda item: len(item.path_prefix)) + + +def rate_limit_response(decision: AntiAbuseDecision) -> JSONResponse: + retry_after = decision.retry_after_seconds or 60 + headers = { + "Retry-After": str(retry_after), + "X-Anti-Abuse-Decision": "block", + "X-Anti-Abuse-Policy": decision.policy.route_key if decision.policy else "unknown", + } + return JSONResponse( + status_code=429, + content={ + "detail": "Too many requests for this data access path.", + "reason": decision.reason or "rate_limit_exceeded", + "retry_after_seconds": retry_after, + }, + headers=headers, + ) + + +def egress_budget_response(decision: AntiAbuseDecision) -> JSONResponse: + headers = { + "X-Anti-Abuse-Decision": "block", + "X-Anti-Abuse-Policy": decision.policy.route_key if decision.policy else "unknown", + "X-Anti-Abuse-Reason": decision.reason or "daily_egress_budget_exceeded", + } + return JSONResponse( + status_code=403, + content={ + "detail": "Daily data egress budget exceeded for this data access path.", + "reason": decision.reason or "daily_egress_budget_exceeded", + }, + headers=headers, + ) + + +def apply_anti_abuse_headers(response, decision: AntiAbuseDecision, *, mode: AntiAbuseMode) -> None: + if decision.policy is None: + return + response.headers["X-Anti-Abuse-Mode"] = mode + response.headers["X-Anti-Abuse-Decision"] = decision.decision + response.headers["X-Anti-Abuse-Policy"] = decision.policy.route_key + response.headers["X-Data-Class"] = decision.policy.data_class + + +def check_egress_budget( + engine: Engine | None, + decision: AntiAbuseDecision, + *, + tenant_id: str, + user_id: str | None, + limits: EgressBudgetLimits, + estimated_records_count: int = 0, + estimated_files_count: int = 0, + estimated_bytes_count: int = 0, +) -> AntiAbuseDecision: + if engine is None or decision.policy is None or decision.policy.data_class not in {"S3", "S4"}: + return decision + try: + from yuqei_contract_api.persistence.models import DataEgressLedgerModel + + ledger_table = DataEgressLedgerModel.__table__ + with engine.connect() as connection: + row = connection.execute( + ledger_table.select().where( + ledger_table.c.tenant_id == tenant_id, + ledger_table.c.user_id == (user_id or ""), + ledger_table.c.ledger_date == date.today(), + ledger_table.c.data_class == decision.policy.data_class, + ) + ).mappings().first() + if row is None: + projected = _egress_budget_exceeded_decision( + decision.policy, + limits=limits, + next_records=max(estimated_records_count, 0), + next_files=max(estimated_files_count, 0), + next_bytes=max(estimated_bytes_count, 0), + ) + if projected is not None: + return projected + return decision + + reason: str | None = None + next_records = int(row["records_count"] or 0) + max(estimated_records_count, 0) + next_files = int(row["files_count"] or 0) + max(estimated_files_count, 0) + next_bytes = int(row["bytes_count"] or 0) + max(estimated_bytes_count, 0) + if decision.policy.budget_units == "bytes" and next_bytes > limits.bytes_per_day: + reason = "daily_bytes_budget_exceeded" + elif decision.policy.budget_units == "files" and next_files > limits.files_per_day: + reason = "daily_files_budget_exceeded" + elif decision.policy.budget_units == "records": + record_limit = limits.s4_records_per_day if decision.policy.data_class == "S4" else limits.s3_records_per_day + if next_records > record_limit: + reason = "daily_records_budget_exceeded" + if reason is None: + return decision + return AntiAbuseDecision( + decision="block", + policy=decision.policy, + retry_after_seconds=None, + reason=reason, + ) + except (SQLAlchemyError, ValueError, TypeError): + return decision + + +def reserve_egress_budget( + engine: Engine | None, + policy: EndpointRiskPolicy | None, + *, + tenant_id: str, + user_id: str | None, + limits: EgressBudgetLimits, + records_count: int = 0, + files_count: int = 0, + bytes_count: int = 0, + reports_count: int = 0, +) -> AntiAbuseDecision | None: + if engine is None or policy is None or policy.data_class not in {"S3", "S4"}: + return None + try: + from yuqei_contract_api.persistence.models import DataEgressLedgerModel + + ledger_date = date.today() + normalized_user_id = user_id or "" + ledger_table = DataEgressLedgerModel.__table__ + current_records = max(records_count, 0) + current_files = max(files_count, 0) + current_bytes = max(bytes_count, 0) + current_reports = max(reports_count, 0) + with engine.begin() as connection: + row = connection.execute( + ledger_table.select().where( + ledger_table.c.tenant_id == tenant_id, + ledger_table.c.user_id == normalized_user_id, + ledger_table.c.ledger_date == ledger_date, + ledger_table.c.data_class == policy.data_class, + ) + ).mappings().first() + existing_records = int(row["records_count"] or 0) if row else 0 + existing_files = int(row["files_count"] or 0) if row else 0 + existing_bytes = int(row["bytes_count"] or 0) if row else 0 + existing_reports = int(row["reports_count"] or 0) if row else 0 + decision = _egress_budget_exceeded_decision( + policy, + limits=limits, + next_records=existing_records + current_records, + next_files=existing_files + current_files, + next_bytes=existing_bytes + current_bytes, + ) + if decision is not None: + return decision + + updated_at = datetime.now(UTC) + if row is None: + connection.execute( + ledger_table.insert().values( + id=f"egress-ledger-{uuid4().hex[:16]}", + tenant_id=tenant_id, + user_id=normalized_user_id, + ledger_date=ledger_date, + data_class=policy.data_class, + records_count=current_records, + files_count=current_files, + bytes_count=current_bytes, + reports_count=current_reports, + updated_at=updated_at, + ) + ) + else: + connection.execute( + ledger_table.update() + .where(ledger_table.c.id == row["id"]) + .values( + records_count=existing_records + current_records, + files_count=existing_files + current_files, + bytes_count=existing_bytes + current_bytes, + reports_count=existing_reports + current_reports, + updated_at=updated_at, + ) + ) + except (SQLAlchemyError, ValueError, TypeError): + return None + return None + + +def policy_by_route_key(policies: tuple[EndpointRiskPolicy, ...], route_key: str) -> EndpointRiskPolicy | None: + return next((policy for policy in policies if policy.route_key == route_key), None) + + +def estimate_records_count(request: Request, decision: AntiAbuseDecision) -> int: + if decision.policy is None or decision.policy.budget_units != "records": + return 1 + for key in ("page_size", "limit"): + raw_value = request.query_params.get(key) + if raw_value is None: + continue + try: + return min(max(int(raw_value), 1), 1000) + except ValueError: + return 1 + return 1 + + +def _egress_budget_exceeded_decision( + policy: EndpointRiskPolicy, + *, + limits: EgressBudgetLimits, + next_records: int, + next_files: int, + next_bytes: int, +) -> AntiAbuseDecision | None: + if next_bytes > limits.bytes_per_day: + reason = "daily_bytes_budget_exceeded" + elif next_files > limits.files_per_day: + reason = "daily_files_budget_exceeded" + else: + record_limit = limits.s4_records_per_day if policy.data_class == "S4" else limits.s3_records_per_day + reason = "daily_records_budget_exceeded" if next_records > record_limit else None + if reason is None: + return None + return AntiAbuseDecision( + decision="block", + policy=policy, + retry_after_seconds=None, + reason=reason, + ) + + +def persist_security_event(engine: Engine | None, event: dict[str, object], *, records_count: int = 1, bytes_count: int = 0) -> None: + if engine is None: + return + try: + from yuqei_contract_api.persistence.models import DataEgressLedgerModel, SecurityAccessEventModel + + created_at = datetime.now(UTC) + tenant_id = str(event.get("tenant_id") or "default") + user_id = str(event.get("user_id") or "") or None + data_class = str(event.get("data_class") or "S1") + with engine.begin() as connection: + connection.execute( + SecurityAccessEventModel.__table__.insert().values( + id=f"security-access-{uuid4().hex[:16]}", + tenant_id=tenant_id, + user_id=user_id, + session_id_hash=str(event.get("session_id_hash") or "") or None, + ip_hash=str(event.get("ip_hash") or "") or None, + ua_hash=str(event.get("ua_hash") or "") or None, + route_key=str(event.get("route_key") or ""), + http_method=str(event.get("method") or ""), + path=str(event.get("path") or ""), + resource_type=str(event.get("resource_type") or "") or None, + resource_id=str(event.get("resource_id") or "") or None, + data_class=data_class, + records_count=max(records_count, 0), + bytes_count=max(bytes_count, 0), + decision=str(event.get("decision") or "allow"), + reason_code=str(event.get("reason") or "") or None, + status_code=int(event.get("status_code") or 0), + trace_id=str(event.get("trace_id") or "") or None, + created_at=created_at, + ) + ) + if data_class in {"S3", "S4"}: + ledger_date = date.today() + ledger_table = DataEgressLedgerModel.__table__ + existing = connection.execute( + ledger_table.select().where( + ledger_table.c.tenant_id == tenant_id, + ledger_table.c.user_id == (user_id or ""), + ledger_table.c.ledger_date == ledger_date, + ledger_table.c.data_class == data_class, + ) + ).mappings().first() + if existing is None: + connection.execute( + ledger_table.insert().values( + id=f"egress-ledger-{uuid4().hex[:16]}", + tenant_id=tenant_id, + user_id=user_id or "", + ledger_date=ledger_date, + data_class=data_class, + records_count=max(records_count, 0), + files_count=1 if event.get("budget_units") == "files" else 0, + bytes_count=max(bytes_count, 0), + reports_count=1 if event.get("route_key") == "review.report" else 0, + updated_at=created_at, + ) + ) + else: + connection.execute( + ledger_table.update() + .where(ledger_table.c.id == existing["id"]) + .values( + records_count=int(existing["records_count"] or 0) + max(records_count, 0), + files_count=int(existing["files_count"] or 0) + + (1 if event.get("budget_units") == "files" else 0), + bytes_count=int(existing["bytes_count"] or 0) + max(bytes_count, 0), + reports_count=int(existing["reports_count"] or 0) + + (1 if event.get("route_key") == "review.report" else 0), + updated_at=created_at, + ) + ) + except (SQLAlchemyError, ValueError, TypeError): + return + + +def contract_endpoint_risk_policies(api_prefix: str) -> tuple[EndpointRiskPolicy, ...]: + p = api_prefix.rstrip("/") + return ( + EndpointRiskPolicy("files.batch_download", "POST", f"{p}/files/batch-download", "S4", 2, 20, 10, "bytes", True), + EndpointRiskPolicy("files.download", "GET", f"{p}/files/", "S4", 10, 100, 30, "bytes", True, "/download"), + EndpointRiskPolicy("files.preview", "GET", f"{p}/files/", "S4", 20, 200, 60, "records", True, "/preview"), + EndpointRiskPolicy("contracts.list", "GET", f"{p}/contracts", "S1", 60, 600, 120), + EndpointRiskPolicy("review.report", "GET", f"{p}/contracts/", "S4", 10, 100, 30, "bytes", True, "/review-report"), + EndpointRiskPolicy("contracts.download", "GET", f"{p}/contracts/", "S4", 10, 100, 30, "bytes", True, "/download"), + EndpointRiskPolicy("contracts.detail", "GET", f"{p}/contracts/", "S4", 30, 300, 80, "records", True), + EndpointRiskPolicy("files.list", "GET", f"{p}/files", "S1", 60, 600, 120), + EndpointRiskPolicy("files.detail", "GET", f"{p}/files/", "S4", 30, 300, 80, "records", True), + EndpointRiskPolicy("search.unified", "GET", f"{p}/search", "S2", 20, 200, 80, "records", True), + EndpointRiskPolicy("templates.tree", "GET", f"{p}/templates/tree", "S1", 60, 600, 120), + EndpointRiskPolicy("templates.detail", "GET", f"{p}/templates/", "S3", 30, 300, 80, "records", True), + EndpointRiskPolicy("legal_assets", "GET", f"{p}/legal-assets", "S3", 30, 300, 80, "records", True), + EndpointRiskPolicy("legal_fulltext", "GET", f"{p}/legal-fulltext", "S2", 60, 600, 120, "records"), + EndpointRiskPolicy("legal_qa", "POST", f"{p}/legal/qa", "S2", 10, 100, 40, "records", True), + EndpointRiskPolicy( + "identity.account_conflicts", + "GET", + f"{p}/identity/account-conflicts", + "S3", + 20, + 100, + 40, + "records", + True, + ), + EndpointRiskPolicy("audit", "GET", f"{p}/audit", "S2", 20, 200, 60, "records", True), + ) + + +def anti_abuse_config_status( + *, + mode: AntiAbuseMode, + environment: str, + redis_url: str, + hash_secret: str, + policy_count: int, +) -> tuple[str, str, dict[str, str | bool | int]]: + strict = environment in {"staging", "production"} + details = { + "mode": mode, + "redis_configured": bool(redis_url.strip()), + "hash_secret_configured": len(hash_secret.strip()) >= 32, + "policy_count": policy_count, + } + if mode == "disabled" and strict: + return "error", "Staging and production must enable anti-abuse controls in observe or enforce mode.", details + if mode == "enforce" and not redis_url.strip() and strict: + return "error", "Enforced anti-abuse controls require Redis in staging and production.", details + if mode in {"observe", "enforce"} and len(hash_secret.strip()) < 32 and strict: + return "error", "Anti-abuse controls require a dedicated hash secret of at least 32 characters.", details + if mode == "disabled": + return "warning", "Anti-abuse controls are disabled.", details + if mode == "observe": + return "warning", "Anti-abuse controls are observing sensitive access without blocking.", details + return "ok", "Anti-abuse controls are enabled.", details + + +def _client_ip(request: Request) -> str: + forwarded_for = request.headers.get("x-forwarded-for") + if forwarded_for: + return forwarded_for.split(",", 1)[0].strip() + return request.client.host if request.client else "unknown" + + +def _normalize_subject(value: str | None) -> str | None: + if value is None: + return None + stripped = value.strip() + return stripped or None diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/contracts_core.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/contracts_core.py index dc5dc885..c33183f9 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/contracts_core.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/contracts_core.py @@ -1,7 +1,16 @@ +import base64 +import binascii + from fastapi import FastAPI, HTTPException, Query from pydantic import BaseModel from yuqei_sdk import AiPlatformClient, LegalQaRequest, LegalQaResponse, RequestContext +from yuqei_contract_api.anti_abuse import ( + EgressBudgetLimits, + contract_endpoint_risk_policies, + policy_by_route_key, + reserve_egress_budget, +) from yuqei_contract_api.config import ContractApiSettings from yuqei_contract_api.deprecated_alias_observability import ( build_contract_update_action_alias_rejection_detail, @@ -19,14 +28,15 @@ from yuqei_contract_api.domain import ( ContractUpdateRequest, ContractWorkflowRequest, WorkspaceSummary, - normalize_role, ) from yuqei_contract_api.contract_download import build_contract_download +from yuqei_contract_api.authorization import RequestAuthorization from yuqei_contract_api.logging import get_logger, get_request_tenant_id, get_trace_id from yuqei_contract_api.persistence.audit import ContractAuditEvent from yuqei_contract_api.persistence.contract_core import ContractVersion, ContractVersionPage from yuqei_contract_api.persistence.files import FileDownload from yuqei_contract_api.repository_base import ContractRepository, ContractWorkflowError +from yuqei_contract_api.tenant_context import get_tenant_context, get_user_context class AuditIntegrityResponse(BaseModel): @@ -43,6 +53,7 @@ def register_contract_core_routes( repository: ContractRepository, ) -> None: deprecated_alias_logger = get_logger("yuqei_contract_api.deprecated_aliases") + authorization = RequestAuthorization(repository, settings) @app.get( f"{settings.api_prefix}/workspace", @@ -50,7 +61,8 @@ def register_contract_core_routes( tags=["workspace"], ) def get_workspace(role: str | None = None) -> WorkspaceSummary: - return repository.get_workspace(role=normalize_role(role)) + resolved_role = authorization.require("view_contract", compatibility_role=role) + return repository.get_workspace(role=resolved_role) @app.get( f"{settings.api_prefix}/contracts", @@ -64,6 +76,7 @@ def register_contract_core_routes( keyword: str | None = None, lifecycle: str | None = Query(None), ) -> ContractPage: + authorization.require("view_contract") return repository.list_contracts( page=page, page_size=page_size, @@ -78,6 +91,7 @@ def register_contract_core_routes( tags=["contracts"], ) def create_contract(request: ContractCreateRequest) -> ContractDetail: + authorization.require("create_contract") return repository.create_contract(request) @app.get( @@ -86,6 +100,7 @@ def register_contract_core_routes( tags=["contracts"], ) def get_contracts_dashboard() -> ContractDashboard: + authorization.require("view_contract") return repository.get_contracts_dashboard() @app.get( @@ -94,7 +109,8 @@ def register_contract_core_routes( tags=["contracts"], ) def get_contract_detail(contract_id: str, role: str | None = None) -> ContractDetail: - contract = repository.get_contract(contract_id, role=normalize_role(role)) + resolved_role = authorization.require("view_contract", compatibility_role=role) + contract = repository.get_contract(contract_id, role=resolved_role) if contract is None: raise HTTPException(status_code=404, detail="Contract not found.") return contract @@ -105,13 +121,20 @@ def register_contract_core_routes( tags=["contracts"], ) def download_contract(contract_id: str, actor_name: str | None = None, role: str | None = None) -> FileDownload: - contract = repository.get_contract(contract_id, role=normalize_role(role)) + resolved_role = authorization.require("view_contract", compatibility_role=role) + contract = repository.get_contract(contract_id, role=resolved_role) if contract is None: raise HTTPException(status_code=404, detail="Contract not found.") if not contract.permissions.can_download: raise HTTPException(status_code=403, detail="Current role cannot download this contract.") download = build_contract_download(contract) + _enforce_download_egress_budget( + settings=settings, + repository=repository, + route_key="contracts.download", + bytes_count=_decoded_base64_size(download.content_base64), + ) repository.record_audit_event( contract_id, event_type="contract_downloaded", @@ -132,6 +155,7 @@ def register_contract_core_routes( tags=["contracts"], ) def update_contract(contract_id: str, request: ContractUpdateRequest) -> ContractDetail: + authorization.require("edit_contract") deprecated_alias_fields = detect_contract_update_action_alias_fields(request) if deprecated_alias_fields: record_contract_update_action_alias_usage( @@ -161,6 +185,7 @@ def register_contract_core_routes( tags=["contracts"], ) def apply_contract_clause_repair(contract_id: str, request: ClauseRepairRequest) -> ContractDetail: + authorization.require("edit_contract") try: contract = repository.apply_clause_repair(contract_id, request) except ContractWorkflowError as error: @@ -175,6 +200,7 @@ def register_contract_core_routes( tags=["contracts"], ) def record_contract_download(contract_id: str, request: ContractAuditEventRequest | None = None) -> ContractAuditEvent: + authorization.require("download_contract") payload = request or ContractAuditEventRequest() event = repository.record_audit_event( contract_id, @@ -194,6 +220,7 @@ def register_contract_core_routes( tags=["contracts"], ) def verify_audit_integrity(contract_id: str) -> AuditIntegrityResponse: + authorization.require("view_audit") valid, errors = repository.verify_audit_integrity(contract_id) detail = repository.get_contract(contract_id) event_count = len(detail.audit_events) if detail else 0 @@ -210,6 +237,7 @@ def register_contract_core_routes( tags=["contracts"], ) def submit_approval(contract_id: str, request: ContractWorkflowRequest | None = None) -> ContractDetail: + authorization.require("submit_approval") return _run_contract_workflow( repository.submit_approval, contract_id, @@ -222,6 +250,7 @@ def register_contract_core_routes( tags=["contracts"], ) def resubmit_approval(contract_id: str, request: ContractWorkflowRequest | None = None) -> ContractDetail: + authorization.require("submit_approval") return _run_contract_workflow( repository.resubmit_approval, contract_id, @@ -234,6 +263,7 @@ def register_contract_core_routes( tags=["contracts"], ) def process_approval(contract_id: str, request: ApprovalDecisionRequest) -> ContractDetail: + authorization.require("process_approval") return _run_contract_workflow(repository.process_approval, contract_id, request) @app.get( @@ -246,6 +276,7 @@ def register_contract_core_routes( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), ) -> ContractVersionPage: + authorization.require("view_contract") if repository.get_contract(contract_id) is None: raise HTTPException(status_code=404, detail="Contract not found.") return repository.list_versions(contract_id, page=page, page_size=page_size) @@ -256,6 +287,7 @@ def register_contract_core_routes( tags=["contracts"], ) def get_contract_version(contract_id: str, version_id: str) -> ContractVersion: + authorization.require("view_contract") if repository.get_contract(contract_id) is None: raise HTTPException(status_code=404, detail="Contract not found.") version = repository.get_version(contract_id, version_id) @@ -269,6 +301,7 @@ def register_contract_core_routes( tags=["legal"], ) def proxy_legal_qa(request: LegalQaRequest) -> LegalQaResponse: + authorization.require("start_review") client_options = {"service_key": settings.ai_platform_service_key} if settings.ai_platform_service_key else {} with AiPlatformClient(settings.ai_platform_base_url, **client_options) as client: return client.run_legal_qa( @@ -285,3 +318,48 @@ def _run_contract_workflow(operation, contract_id: str, request) -> ContractDeta if contract is None: raise HTTPException(status_code=404, detail="Contract not found.") return contract + + +def _enforce_download_egress_budget( + *, + settings: ContractApiSettings, + repository: ContractRepository, + route_key: str, + bytes_count: int, +) -> None: + policy = policy_by_route_key(contract_endpoint_risk_policies(settings.api_prefix), route_key) + decision = reserve_egress_budget( + getattr(repository, "engine", None), + policy, + tenant_id=get_tenant_context() or "default", + user_id=get_user_context(), + limits=EgressBudgetLimits( + s3_records_per_day=settings.anti_abuse_s3_records_daily_limit, + s4_records_per_day=settings.anti_abuse_s4_records_daily_limit, + files_per_day=settings.anti_abuse_files_daily_limit, + bytes_per_day=settings.anti_abuse_bytes_daily_limit, + ), + files_count=1, + bytes_count=bytes_count, + ) + if decision is None: + return + raise HTTPException( + status_code=403, + detail={ + "detail": "Daily data egress budget exceeded for this data access path.", + "reason": decision.reason or "daily_egress_budget_exceeded", + }, + headers={ + "X-Anti-Abuse-Decision": "block", + "X-Anti-Abuse-Policy": route_key, + "X-Anti-Abuse-Reason": decision.reason or "daily_egress_budget_exceeded", + }, + ) + + +def _decoded_base64_size(value: str) -> int: + try: + return len(base64.b64decode(value or "", validate=True)) + except (binascii.Error, ValueError): + return 0 diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/files_search_backup.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/files_search_backup.py index a1e43856..bc14a91f 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/files_search_backup.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/files_search_backup.py @@ -1,5 +1,15 @@ +import base64 +import binascii + from fastapi import FastAPI, HTTPException, Query +from yuqei_contract_api.authorization import RequestAuthorization +from yuqei_contract_api.anti_abuse import ( + EgressBudgetLimits, + contract_endpoint_risk_policies, + policy_by_route_key, + reserve_egress_budget, +) from yuqei_contract_api.config import ContractApiSettings from yuqei_contract_api.domain import ( BackupListPage, @@ -23,6 +33,7 @@ from yuqei_contract_api.persistence.search import ( SearchSort, ) from yuqei_contract_api.repository_base import ContractRepository, ContractWorkflowError +from yuqei_contract_api.tenant_context import get_tenant_context, get_user_context def register_files_search_backup_routes( @@ -31,12 +42,15 @@ def register_files_search_backup_routes( settings: ContractApiSettings, repository: ContractRepository, ) -> None: + authorization = RequestAuthorization(repository, settings) + @app.post( f"{settings.api_prefix}/backup", response_model=BackupStatus, tags=["backup"], ) def create_backup(request: BackupRequest) -> BackupStatus: + authorization.require("manage_backup") result = repository.create_backup(request) if result is None: raise HTTPException(status_code=500, detail="Backup failed") @@ -48,6 +62,7 @@ def register_files_search_backup_routes( tags=["backup"], ) def list_backups(page: int = 1, page_size: int = 20) -> BackupListPage: + authorization.require("manage_backup") return repository.get_backups(page=page, page_size=page_size) @app.get( @@ -56,6 +71,7 @@ def register_files_search_backup_routes( tags=["backup"], ) def get_backup(backup_id: str) -> BackupStatus: + authorization.require("manage_backup") backup = getattr(repository, "get_backup", None) if callable(backup): result = backup(backup_id) @@ -73,6 +89,7 @@ def register_files_search_backup_routes( tags=["backup"], ) def validate_backup(backup_id: str) -> dict: + authorization.require("manage_backup") valid = repository.validate_backup(backup_id) return {"backup_id": backup_id, "valid": valid} @@ -82,6 +99,7 @@ def register_files_search_backup_routes( tags=["backup"], ) def restore_backup(request: RestoreRequest) -> RestoreStatus: + authorization.require("manage_backup") result = repository.restore_backup(request) if result is None: raise HTTPException(status_code=404, detail="Backup not found or restore failed") @@ -100,6 +118,7 @@ def register_files_search_backup_routes( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), ) -> SearchResultPage: + authorization.require("view_contract") return repository.search( keyword=keyword, item_type=item_type, @@ -120,6 +139,7 @@ def register_files_search_backup_routes( keyword: str | None = None, contract_id: str | None = None, ) -> FilePage: + authorization.require("view_file") return repository.list_files( page=page, page_size=page_size, @@ -133,6 +153,7 @@ def register_files_search_backup_routes( tags=["files"], ) def create_file(request: FileCreateRequest) -> ContractFileItem: + authorization.require("upload_file") try: return repository.create_file(request) except ContractWorkflowError as error: @@ -144,12 +165,20 @@ def register_files_search_backup_routes( tags=["files"], ) def batch_download_files(request: BatchDownloadRequest) -> BatchDownload: + authorization.require("view_file") try: payload = repository.batch_download(request) except ContractWorkflowError as error: raise HTTPException(status_code=409, detail=str(error)) from error if payload is None: raise HTTPException(status_code=404, detail="No files found for download.") + _enforce_download_egress_budget( + settings=settings, + repository=repository, + route_key="files.batch_download", + files_count=payload.file_count, + bytes_count=_decoded_base64_size(payload.content_base64), + ) return payload @app.get( @@ -158,6 +187,7 @@ def register_files_search_backup_routes( tags=["files"], ) def get_file_detail(file_id: str) -> ContractFileItem: + authorization.require("view_file") file_item = repository.get_file(file_id) if file_item is None: raise HTTPException(status_code=404, detail="File not found.") @@ -169,6 +199,7 @@ def register_files_search_backup_routes( tags=["files"], ) def get_file_preview(file_id: str) -> FilePreview: + authorization.require("view_file") try: preview = repository.get_file_preview(file_id) except ContractWorkflowError as error: @@ -183,10 +214,77 @@ def register_files_search_backup_routes( tags=["files"], ) def download_file(file_id: str, actor_name: str | None = None) -> FileDownload: + authorization.require("view_file") + file_item = repository.get_file(file_id) + if file_item is None: + raise HTTPException(status_code=404, detail="File not found.") + _enforce_download_egress_budget( + settings=settings, + repository=repository, + route_key="files.download", + files_count=1, + bytes_count=max(file_item.file_size or 0, 0), + ) try: payload = repository.get_file_download(file_id, actor_name=actor_name) except ContractWorkflowError as error: raise HTTPException(status_code=409, detail=str(error)) from error if payload is None: raise HTTPException(status_code=404, detail="File not found.") + actual_size = _decoded_base64_size(payload.content_base64) + declared_size = max(file_item.file_size or 0, 0) + if actual_size > declared_size: + _enforce_download_egress_budget( + settings=settings, + repository=repository, + route_key="files.download", + files_count=0, + bytes_count=actual_size - declared_size, + ) return payload + + +def _enforce_download_egress_budget( + *, + settings: ContractApiSettings, + repository: ContractRepository, + route_key: str, + files_count: int, + bytes_count: int, +) -> None: + policy = policy_by_route_key(contract_endpoint_risk_policies(settings.api_prefix), route_key) + decision = reserve_egress_budget( + getattr(repository, "engine", None), + policy, + tenant_id=get_tenant_context() or "default", + user_id=get_user_context(), + limits=EgressBudgetLimits( + s3_records_per_day=settings.anti_abuse_s3_records_daily_limit, + s4_records_per_day=settings.anti_abuse_s4_records_daily_limit, + files_per_day=settings.anti_abuse_files_daily_limit, + bytes_per_day=settings.anti_abuse_bytes_daily_limit, + ), + files_count=files_count, + bytes_count=bytes_count, + ) + if decision is None: + return + raise HTTPException( + status_code=403, + detail={ + "detail": "Daily data egress budget exceeded for this data access path.", + "reason": decision.reason or "daily_egress_budget_exceeded", + }, + headers={ + "X-Anti-Abuse-Decision": "block", + "X-Anti-Abuse-Policy": route_key, + "X-Anti-Abuse-Reason": decision.reason or "daily_egress_budget_exceeded", + }, + ) + + +def _decoded_base64_size(value: str) -> int: + try: + return len(base64.b64decode(value or "", validate=True)) + except (binascii.Error, ValueError): + return 0 diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/identity.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/identity.py index 87da1204..37b5c3fe 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/identity.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/identity.py @@ -6,7 +6,7 @@ from datetime import UTC, datetime, timedelta from ipaddress import ip_address from uuid import uuid4 -from fastapi import BackgroundTasks, FastAPI, HTTPException, Request +from fastapi import BackgroundTasks, FastAPI, HTTPException, Query, Request from yuqei_contract_api.config import ContractApiSettings from yuqei_contract_api.domain import RolePermissionsPage @@ -17,8 +17,14 @@ from yuqei_contract_api.identity_email_delivery import ( ) from yuqei_contract_api.identity_rate_limit import IdentityRateLimitExceeded from yuqei_contract_api.identity import ( + AccountConflictCode, + AccountConflictDetail, + AccountConflictPage, + AccountConflictSeverity, AccountSettings, AccountSettingsUpdateRequest, + AgreementConsentPage, + AgreementVersionPage, EmailChallengeItem, EmailVerificationConfirmRequest, EmailVerificationResult, @@ -27,8 +33,11 @@ from yuqei_contract_api.identity import ( IdentityConflictError, IdentitySession, InMemoryIdentityRepository, + LastPlatformAdministratorError, + PersonalRegistrationRequest, PasswordResetConfirmRequest, PasswordResetRequest, + PlatformAdministratorPage, SsoProvider, TenantCreateRequest, TenantInfo, @@ -47,6 +56,9 @@ from yuqei_contract_api.identity import ( TenantUserPage, TenantUserStatus, TenantUserUpdateRequest, + WorkspaceAccessPage, + WorkspaceSwitchRequest, + current_registration_agreements, ) from yuqei_contract_api.persistence.models import DEFAULT_TENANT_ID from yuqei_contract_api.logging import get_logger @@ -81,13 +93,21 @@ def register_identity_routes( manual_invitation_enabled=settings.identity_manual_invitation_enabled, ) + @app.get( + f"{settings.api_prefix}/identity/agreements/current", + response_model=AgreementVersionPage, + tags=["identity"], + ) + def get_current_registration_agreements() -> AgreementVersionPage: + return current_registration_agreements() + @app.get( f"{settings.api_prefix}/identity/tenants", response_model=TenantPage, tags=["identity"], ) def list_tenants(status: TenantStatus | None = None) -> TenantPage: - if settings.require_web_proxy_auth and not _is_platform_admin(): + if settings.require_web_proxy_auth and not _is_platform_admin(identity_repository): tenant = identity_repository.get_current_tenant(_current_tenant_id()) items = [tenant] if status is None or tenant.status == status else [] return TenantPage(items=items, total=len(items)) @@ -101,13 +121,55 @@ def register_identity_routes( def get_current_tenant() -> TenantInfo: return identity_repository.get_current_tenant(_current_tenant_id()) + @app.get( + f"{settings.api_prefix}/identity/platform-administrators", + response_model=PlatformAdministratorPage, + tags=["identity"], + ) + def list_platform_administrators() -> PlatformAdministratorPage: + _require_platform_admin(settings, identity_repository) + return identity_repository.list_platform_administrators() + + @app.get( + f"{settings.api_prefix}/identity/account-conflicts", + response_model=AccountConflictPage, + tags=["identity"], + ) + def list_account_conflicts( + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + severity: AccountConflictSeverity | None = None, + conflict_code: AccountConflictCode | None = None, + email: str | None = None, + ) -> AccountConflictPage: + _require_platform_admin(settings, identity_repository) + return identity_repository.list_account_conflicts( + page=page, + page_size=page_size, + severity=severity, + conflict_code=conflict_code, + email=email, + ) + + @app.get( + f"{settings.api_prefix}/identity/account-conflicts/{{diagnostic_id}}", + response_model=AccountConflictDetail, + tags=["identity"], + ) + def get_account_conflict(diagnostic_id: str) -> AccountConflictDetail: + _require_platform_admin(settings, identity_repository) + item = identity_repository.get_account_conflict(diagnostic_id) + if item is None: + raise HTTPException(status_code=404, detail="Account conflict diagnostic not found.") + return item + @app.post( f"{settings.api_prefix}/identity/tenants", response_model=TenantInfo, tags=["identity"], ) def create_tenant(request: TenantCreateRequest) -> TenantInfo: - _require_platform_admin(settings) + _require_platform_admin(settings, identity_repository) try: return identity_repository.create_tenant(request) except IdentityConflictError as exc: @@ -120,15 +182,62 @@ def register_identity_routes( response_model=IdentitySession, tags=["identity"], ) - def register_company(request: TenantRegistrationRequest) -> IdentitySession: - _require_self_service_or_platform_admin(settings) + def register_company(request: TenantRegistrationRequest, http_request: Request) -> IdentitySession: + _require_self_service_or_platform_admin(settings, identity_repository) try: - return identity_repository.register_tenant_admin(request) + return identity_repository.register_tenant_admin( + request, + client_ip=_identity_client_ip(http_request, settings), + user_agent=_registration_user_agent(http_request), + ) except IdentityConflictError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc + @app.post( + f"{settings.api_prefix}/identity/register-personal", + response_model=IdentitySession, + tags=["identity"], + ) + def register_personal(request: PersonalRegistrationRequest, http_request: Request) -> IdentitySession: + _require_self_service_or_platform_admin(settings, identity_repository) + try: + return identity_repository.register_personal_workspace( + request, + client_ip=_identity_client_ip(http_request, settings), + user_agent=_registration_user_agent(http_request), + ) + except IdentityConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get( + f"{settings.api_prefix}/identity/agreements/consents", + response_model=AgreementConsentPage, + tags=["identity"], + ) + def list_agreement_consents( + tenant_id: str | None = None, + user_id: str | None = None, + ) -> AgreementConsentPage: + resolved_tenant_id = _resolve_tenant_id( + tenant_id, + settings=settings, + identity_repository=identity_repository, + ) + if settings.require_web_proxy_auth: + if user_id is None: + _require_tenant_admin(identity_repository, settings) + elif user_id != _current_user_id(): + _require_tenant_admin(identity_repository, settings) + _require_user_in_tenant(identity_repository, user_id, settings) + return identity_repository.list_agreement_consents( + tenant_id=resolved_tenant_id, + user_id=user_id, + ) + @app.post( f"{settings.api_prefix}/identity/login", response_model=IdentitySession, @@ -159,6 +268,35 @@ def register_identity_routes( except (IdentityConflictError, ValueError) as exc: raise HTTPException(status_code=401, detail="Unable to sign in with the supplied credentials.") from exc + @app.get( + f"{settings.api_prefix}/identity/workspaces", + response_model=WorkspaceAccessPage, + tags=["identity"], + ) + def list_account_workspaces() -> WorkspaceAccessPage: + try: + return identity_repository.list_account_workspaces( + tenant_id=_current_tenant_id(), + user_id=_current_user_id(), + ) + except ValueError as exc: + raise HTTPException(status_code=403, detail="Workspace access is not allowed.") from exc + + @app.post( + f"{settings.api_prefix}/identity/workspaces/switch", + response_model=IdentitySession, + tags=["identity"], + ) + def switch_workspace(request: WorkspaceSwitchRequest) -> IdentitySession: + try: + return identity_repository.switch_workspace( + tenant_id=_current_tenant_id(), + user_id=_current_user_id(), + target_tenant_id=request.tenant_id, + ) + except (PermissionError, ValueError) as exc: + raise HTTPException(status_code=403, detail="Workspace access is not allowed.") from exc + @app.post( f"{settings.api_prefix}/identity/invitations", response_model=TenantInvitationItem, @@ -171,7 +309,13 @@ def register_identity_routes( _require_tenant_admin(identity_repository, settings) _require_invitation_delivery(settings) request = request.model_copy( - update={"tenant_id": _resolve_tenant_id(request.tenant_id, settings=settings)} + update={ + "tenant_id": _resolve_tenant_id( + request.tenant_id, + settings=settings, + identity_repository=identity_repository, + ) + } ) try: invitation = identity_repository.create_invitation(request, tenant_id=_current_tenant_id()) @@ -197,6 +341,8 @@ def register_identity_routes( _require_invitation_delivery(settings) try: return identity_repository.accept_invitation(request) + except IdentityConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -210,12 +356,25 @@ def register_identity_routes( http_request: Request, background_tasks: BackgroundTasks, ) -> EmailChallengeItem: - _require_tenant_admin(identity_repository, settings) _require_email_delivery(settings) + current_user = _email_verification_actor(identity_repository, settings) + resolved_tenant_id = _resolve_tenant_id( + request.tenant_id, + settings=settings, + identity_repository=identity_repository, + ) + if ( + current_user is not None + and current_user.role != "admin" + and request.email.strip().lower() != current_user.email + ): + raise HTTPException(status_code=403, detail="Email verification is limited to the signed-in account.") request = request.model_copy( update={ - "tenant_id": _resolve_tenant_id(request.tenant_id, settings=settings), + "tenant_id": resolved_tenant_id, + "email": current_user.email if current_user is not None and current_user.role != "admin" else request.email, "purpose": "email_verification", + "created_by": current_user.id if current_user is not None else request.created_by, } ) try: @@ -256,8 +415,38 @@ def register_identity_routes( ) def confirm_email_verification(request: EmailVerificationConfirmRequest) -> EmailVerificationResult: _require_email_delivery(settings) + current_user = _email_verification_actor(identity_repository, settings) + is_platform_admin = _is_platform_admin(identity_repository) + scope_tenant_id = ( + _current_tenant_id() + if settings.require_web_proxy_auth and not is_platform_admin + else None + ) + scope_user_id = ( + current_user.id + if current_user is not None and current_user.role != "admin" and not is_platform_admin + else None + ) try: - return _redact_email_result(identity_repository.confirm_email_challenge(request), settings) + result = identity_repository.confirm_email_challenge( + request, + tenant_id=scope_tenant_id, + user_id=scope_user_id, + ) + if ( + settings.require_web_proxy_auth + and current_user is not None + and result.challenge.user_id == current_user.id + ): + refreshed_session = identity_repository.switch_workspace( + tenant_id=_current_tenant_id(), + user_id=current_user.id, + target_tenant_id=_current_tenant_id(), + ) + result = result.model_copy(update={"session": refreshed_session}) + return _redact_email_result(result, settings) + except PermissionError as exc: + raise HTTPException(status_code=403, detail="Email verification is not available for this account.") from exc except ValueError as exc: raise HTTPException(status_code=400, detail="Verification code is invalid or expired.") from exc @@ -343,7 +532,11 @@ def register_identity_routes( def get_sso_config(provider: SsoProvider = "saml", tenant_id: str | None = None) -> TenantSsoConfig: _require_tenant_admin(identity_repository, settings) return identity_repository.get_sso_config( - tenant_id=_resolve_tenant_id(tenant_id, settings=settings), + tenant_id=_resolve_tenant_id( + tenant_id, + settings=settings, + identity_repository=identity_repository, + ), provider=provider, ) @@ -357,7 +550,11 @@ def register_identity_routes( try: return identity_repository.update_sso_config( request, - tenant_id=_resolve_tenant_id(tenant_id, settings=settings), + tenant_id=_resolve_tenant_id( + tenant_id, + settings=settings, + identity_repository=identity_repository, + ), ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -370,7 +567,11 @@ def register_identity_routes( def update_tenant(tenant_id: str, request: TenantUpdateRequest) -> TenantInfo: _require_tenant_admin(identity_repository, settings) item = identity_repository.update_tenant( - _resolve_tenant_id(tenant_id, settings=settings), + _resolve_tenant_id( + tenant_id, + settings=settings, + identity_repository=identity_repository, + ), request, ) if item is None: @@ -388,7 +589,11 @@ def register_identity_routes( ) -> TenantUserPage: _require_tenant_admin(identity_repository, settings) return identity_repository.list_users( - tenant_id=_resolve_tenant_id(tenant_id, settings=settings), + tenant_id=_resolve_tenant_id( + tenant_id, + settings=settings, + identity_repository=identity_repository, + ), status=status, ) @@ -400,7 +605,13 @@ def register_identity_routes( def create_user(request: TenantUserCreateRequest) -> TenantUserItem: _require_tenant_admin(identity_repository, settings) request = request.model_copy( - update={"tenant_id": _resolve_tenant_id(request.tenant_id, settings=settings)} + update={ + "tenant_id": _resolve_tenant_id( + request.tenant_id, + settings=settings, + identity_repository=identity_repository, + ) + } ) try: return identity_repository.create_user(request, tenant_id=_current_tenant_id()) @@ -417,7 +628,10 @@ def register_identity_routes( def update_user(user_id: str, request: TenantUserUpdateRequest) -> TenantUserItem: _require_tenant_admin(identity_repository, settings) _require_user_in_tenant(identity_repository, user_id, settings) - item = identity_repository.update_user(user_id, request) + try: + item = identity_repository.update_user(user_id, request) + except LastPlatformAdministratorError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc if item is None: raise HTTPException(status_code=404, detail="User not found") return item @@ -431,9 +645,13 @@ def register_identity_routes( tenant_id: str | None = None, user_id: str | None = None, ) -> AccountSettings: - resolved_tenant_id = _resolve_tenant_id(tenant_id, settings=settings) + resolved_tenant_id = _resolve_tenant_id( + tenant_id, + settings=settings, + identity_repository=identity_repository, + ) resolved_user_id = user_id or _current_user_id() - _require_self_or_platform_admin(resolved_user_id, settings) + _require_self_or_platform_admin(resolved_user_id, settings, identity_repository) return identity_repository.get_account_settings( tenant_id=resolved_tenant_id, user_id=resolved_user_id, @@ -445,13 +663,15 @@ def register_identity_routes( tags=["identity"], ) def update_account_settings(request: AccountSettingsUpdateRequest) -> AccountSettings: - resolved_user_id = request.user_id or _current_user_id() - _require_self_or_platform_admin(resolved_user_id, settings) + resolved_user_id = _current_user_id() + _require_self_or_platform_admin(resolved_user_id, settings, identity_repository) + _require_user_in_tenant(identity_repository, resolved_user_id, settings) try: return identity_repository.update_account_settings( request, tenant_id=_current_tenant_id(), user_id=resolved_user_id, + actor=resolved_user_id, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -473,24 +693,25 @@ def _current_user_id() -> str: return get_user_context() or "current-admin" -def _is_platform_admin() -> bool: - return _current_tenant_id() == DEFAULT_TENANT_ID and _current_user_id() in { - "current-admin", - "user-current-admin", - } +def _is_platform_admin(identity_repository) -> bool: + return identity_repository.has_platform_permission( + tenant_id=_current_tenant_id(), + user_id=_current_user_id(), + permission="platform_admin", + ) -def _require_platform_admin(settings: ContractApiSettings) -> None: - if settings.require_web_proxy_auth and not _is_platform_admin(): +def _require_platform_admin(settings: ContractApiSettings, identity_repository) -> None: + if settings.require_web_proxy_auth and not _is_platform_admin(identity_repository): raise HTTPException(status_code=403, detail="Platform administrator access is required.") -def _require_self_service_or_platform_admin(settings: ContractApiSettings) -> None: +def _require_self_service_or_platform_admin(settings: ContractApiSettings, identity_repository) -> None: if settings.identity_self_service_enabled: return if get_user_context() is None: - raise HTTPException(status_code=403, detail="Company self-service registration is disabled.") - _require_platform_admin(settings) + raise HTTPException(status_code=403, detail="Self-service registration is disabled.") + _require_platform_admin(settings, identity_repository) def _require_self_service(settings: ContractApiSettings) -> None: @@ -511,16 +732,25 @@ def _require_invitation_delivery(settings: ContractApiSettings) -> None: raise HTTPException(status_code=403, detail="User invitation delivery is disabled.") -def _resolve_tenant_id(requested_tenant_id: str | None, *, settings: ContractApiSettings) -> str: +def _resolve_tenant_id( + requested_tenant_id: str | None, + *, + settings: ContractApiSettings, + identity_repository, +) -> str: current_tenant_id = _current_tenant_id() resolved_tenant_id = requested_tenant_id or current_tenant_id - if settings.require_web_proxy_auth and resolved_tenant_id != current_tenant_id and not _is_platform_admin(): + if ( + settings.require_web_proxy_auth + and resolved_tenant_id != current_tenant_id + and not _is_platform_admin(identity_repository) + ): raise HTTPException(status_code=403, detail="Cross-tenant identity access is not allowed.") return resolved_tenant_id def _require_tenant_admin(identity_repository, settings: ContractApiSettings) -> None: - if not settings.require_web_proxy_auth or _is_platform_admin(): + if not settings.require_web_proxy_auth or _is_platform_admin(identity_repository): return user_id = _current_user_id() users = identity_repository.list_users(tenant_id=_current_tenant_id()).items @@ -529,16 +759,30 @@ def _require_tenant_admin(identity_repository, settings: ContractApiSettings) -> raise HTTPException(status_code=403, detail="Tenant administrator access is required.") +def _email_verification_actor(identity_repository, settings: ContractApiSettings) -> TenantUserItem | None: + if not settings.require_web_proxy_auth or _is_platform_admin(identity_repository): + return None + users = identity_repository.list_users(tenant_id=_current_tenant_id()).items + current_user = next((item for item in users if item.id == _current_user_id()), None) + if current_user is None or current_user.status != "active": + raise HTTPException(status_code=403, detail="An active workspace account is required.") + return current_user + + def _require_user_in_tenant(identity_repository, user_id: str, settings: ContractApiSettings) -> None: - if not settings.require_web_proxy_auth or _is_platform_admin(): + if not settings.require_web_proxy_auth or _is_platform_admin(identity_repository): return users = identity_repository.list_users(tenant_id=_current_tenant_id()).items if not any(item.id == user_id for item in users): raise HTTPException(status_code=403, detail="Cross-tenant user access is not allowed.") -def _require_self_or_platform_admin(user_id: str, settings: ContractApiSettings) -> None: - if settings.require_web_proxy_auth and user_id != _current_user_id() and not _is_platform_admin(): +def _require_self_or_platform_admin(user_id: str, settings: ContractApiSettings, identity_repository) -> None: + if ( + settings.require_web_proxy_auth + and user_id != _current_user_id() + and not _is_platform_admin(identity_repository) + ): raise HTTPException(status_code=403, detail="Account access is limited to the signed-in user.") @@ -718,6 +962,11 @@ def _identity_client_ip(request: Request, settings: ContractApiSettings) -> str return None +def _registration_user_agent(request: Request) -> str | None: + user_agent = request.headers.get("user-agent", "").strip() + return user_agent[:512] or None + + def _identity_rate_limit_secret(settings: ContractApiSettings) -> str: return ( settings.identity_rate_limit_secret.strip() diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/legal_assets.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/legal_assets.py index e4e0ea78..360770d3 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/legal_assets.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/legal_assets.py @@ -1,5 +1,6 @@ from fastapi import FastAPI, HTTPException, Query +from yuqei_contract_api.authorization import RequestAuthorization from yuqei_contract_api.config import ContractApiSettings from yuqei_contract_api.contract_templates import TemplateNotFoundError from yuqei_contract_api.legal_assets import ( @@ -38,6 +39,8 @@ def register_legal_asset_routes( settings: ContractApiSettings, repository: ContractRepository, ) -> None: + authorization = RequestAuthorization(repository, settings) + @app.get( f"{settings.api_prefix}/standard-clauses", response_model=StandardClausePage, @@ -48,6 +51,7 @@ def register_legal_asset_routes( contract_type: str | None = Query(None), category: str | None = Query(None), ) -> StandardClausePage: + authorization.require("view_contract") return repository.list_standard_clauses(keyword=keyword, contract_type=contract_type, category=category) @app.get( @@ -56,6 +60,7 @@ def register_legal_asset_routes( tags=["legal-assets"], ) def get_standard_clause_asset(clause_id: str) -> StandardClauseItem: + authorization.require("view_contract") try: return repository.get_standard_clause(clause_id) except LegalAssetNotFoundError as error: @@ -67,6 +72,7 @@ def register_legal_asset_routes( tags=["legal-assets"], ) def get_standard_clause_edit_draft(clause_id: str) -> LegalAssetEditDraft: + authorization.require("manage_legal_asset") try: return repository.get_legal_asset_edit_draft("standard_clause", clause_id) except LegalAssetNotFoundError as error: @@ -81,6 +87,7 @@ def register_legal_asset_routes( clause_id: str, request: LegalAssetEditRequest, ) -> LegalAssetEditDraft: + authorization.require("manage_legal_asset") try: return repository.save_legal_asset_edit_draft("standard_clause", clause_id, request) except LegalAssetNotFoundError as error: @@ -92,6 +99,7 @@ def register_legal_asset_routes( tags=["legal-assets"], ) def get_standard_clause_diff(clause_id: str) -> LegalAssetDiff: + authorization.require("manage_legal_asset") try: return repository.get_legal_asset_diff("standard_clause", clause_id) except LegalAssetNotFoundError as error: @@ -103,6 +111,7 @@ def register_legal_asset_routes( tags=["legal-assets"], ) def validate_standard_clause_before_publish(clause_id: str) -> LegalAssetValidationResult: + authorization.require("manage_legal_asset") try: return repository.validate_legal_asset_before_publish("standard_clause", clause_id) except LegalAssetNotFoundError as error: @@ -114,6 +123,7 @@ def register_legal_asset_routes( tags=["legal-assets"], ) def list_standard_clause_history(clause_id: str) -> LegalAssetLifecycleEventPage: + authorization.require("manage_legal_asset") try: return repository.list_legal_asset_lifecycle_events("standard_clause", clause_id) except LegalAssetNotFoundError as error: @@ -128,6 +138,7 @@ def register_legal_asset_routes( clause_id: str, request: LegalAssetLifecycleRequest | None = None, ) -> LegalAssetLifecycleEvent: + authorization.require("manage_legal_asset") try: return repository.publish_legal_asset("standard_clause", clause_id, request or LegalAssetLifecycleRequest()) except LegalAssetNotFoundError as error: @@ -144,6 +155,7 @@ def register_legal_asset_routes( clause_id: str, request: LegalAssetLifecycleRequest | None = None, ) -> LegalAssetLifecycleEvent: + authorization.require("manage_legal_asset") try: return repository.offline_legal_asset("standard_clause", clause_id, request or LegalAssetLifecycleRequest()) except LegalAssetNotFoundError as error: @@ -158,6 +170,7 @@ def register_legal_asset_routes( clause_id: str, request: LegalAssetRollbackRequest | None = None, ) -> LegalAssetLifecycleEvent: + authorization.require("manage_legal_asset") try: return repository.rollback_legal_asset("standard_clause", clause_id, request or LegalAssetRollbackRequest()) except LegalAssetNotFoundError as error: @@ -176,6 +189,7 @@ def register_legal_asset_routes( risk_level: str | None = Query(None), linked_clause_id: str | None = Query(None), ) -> ReviewRulePage: + authorization.require("view_contract") return repository.list_review_rules( keyword=keyword, category=category, @@ -189,6 +203,7 @@ def register_legal_asset_routes( tags=["legal-assets"], ) def get_review_rule_asset(rule_id: str) -> ReviewRuleItem: + authorization.require("view_contract") try: return repository.get_review_rule(rule_id) except LegalAssetNotFoundError as error: @@ -200,6 +215,7 @@ def register_legal_asset_routes( tags=["legal-assets"], ) def get_review_rule_edit_draft(rule_id: str) -> LegalAssetEditDraft: + authorization.require("manage_legal_asset") try: return repository.get_legal_asset_edit_draft("review_rule", rule_id) except LegalAssetNotFoundError as error: @@ -214,6 +230,7 @@ def register_legal_asset_routes( rule_id: str, request: LegalAssetEditRequest, ) -> LegalAssetEditDraft: + authorization.require("manage_legal_asset") try: return repository.save_legal_asset_edit_draft("review_rule", rule_id, request) except LegalAssetNotFoundError as error: @@ -225,6 +242,7 @@ def register_legal_asset_routes( tags=["legal-assets"], ) def get_review_rule_diff(rule_id: str) -> LegalAssetDiff: + authorization.require("manage_legal_asset") try: return repository.get_legal_asset_diff("review_rule", rule_id) except LegalAssetNotFoundError as error: @@ -236,6 +254,7 @@ def register_legal_asset_routes( tags=["legal-assets"], ) def validate_review_rule_before_publish(rule_id: str) -> LegalAssetValidationResult: + authorization.require("manage_legal_asset") try: return repository.validate_legal_asset_before_publish("review_rule", rule_id) except LegalAssetNotFoundError as error: @@ -247,6 +266,7 @@ def register_legal_asset_routes( tags=["legal-assets"], ) def list_review_rule_history(rule_id: str) -> LegalAssetLifecycleEventPage: + authorization.require("manage_legal_asset") try: return repository.list_legal_asset_lifecycle_events("review_rule", rule_id) except LegalAssetNotFoundError as error: @@ -261,6 +281,7 @@ def register_legal_asset_routes( rule_id: str, request: LegalAssetLifecycleRequest | None = None, ) -> LegalAssetLifecycleEvent: + authorization.require("manage_legal_asset") try: return repository.publish_legal_asset("review_rule", rule_id, request or LegalAssetLifecycleRequest()) except LegalAssetNotFoundError as error: @@ -277,6 +298,7 @@ def register_legal_asset_routes( rule_id: str, request: LegalAssetLifecycleRequest | None = None, ) -> LegalAssetLifecycleEvent: + authorization.require("manage_legal_asset") try: return repository.offline_legal_asset("review_rule", rule_id, request or LegalAssetLifecycleRequest()) except LegalAssetNotFoundError as error: @@ -291,6 +313,7 @@ def register_legal_asset_routes( rule_id: str, request: LegalAssetRollbackRequest | None = None, ) -> LegalAssetLifecycleEvent: + authorization.require("manage_legal_asset") try: return repository.rollback_legal_asset("review_rule", rule_id, request or LegalAssetRollbackRequest()) except LegalAssetNotFoundError as error: @@ -304,6 +327,7 @@ def register_legal_asset_routes( tags=["legal-assets"], ) def get_template_related_legal_assets(template_id: str) -> TemplateLegalAssets: + authorization.require("view_contract") try: return get_template_legal_assets(template_id) except TemplateNotFoundError as error: @@ -321,6 +345,7 @@ def register_legal_asset_routes( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), ) -> LegalArticlePage: + authorization.require("view_contract") return repository.list_legal_articles( keyword=keyword, source_id=source_id, @@ -335,6 +360,7 @@ def register_legal_asset_routes( tags=["legal-fulltext"], ) def get_selected_legal_article(article_id: str) -> LegalArticleItem: + authorization.require("view_contract") try: return repository.get_legal_article(article_id) except LegalArticleNotFoundError as error: @@ -349,6 +375,7 @@ def register_legal_asset_routes( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), ) -> dict[str, object]: + authorization.require("manage_legal_asset") return repository.list_legal_change_impact_scans(page=page, page_size=page_size) @app.post( @@ -357,6 +384,7 @@ def register_legal_asset_routes( tags=["legal-fulltext"], ) def scan_selected_legal_change_impacts(request: LegalChangeImpactRequest) -> LegalChangeImpactResponse: + authorization.require("manage_legal_asset") try: response = scan_legal_change_impacts(repository, request) repository.record_legal_change_impact_scan( @@ -373,6 +401,7 @@ def register_legal_asset_routes( tags=["legal-fulltext"], ) def get_legal_change_impact_history_detail(event_id: str) -> dict[str, object]: + authorization.require("manage_legal_asset") response = repository.get_legal_change_impact_scan(event_id) if response is None: raise HTTPException(status_code=404, detail="Legal change impact scan not found.") diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/review.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/review.py index 7bc4a44a..ed58d5e2 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/review.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/review.py @@ -1,5 +1,15 @@ +import base64 +import binascii + from fastapi import FastAPI, HTTPException +from yuqei_contract_api.authorization import RequestAuthorization +from yuqei_contract_api.anti_abuse import ( + EgressBudgetLimits, + contract_endpoint_risk_policies, + policy_by_route_key, + reserve_egress_budget, +) from yuqei_contract_api.config import ContractApiSettings from yuqei_contract_api.persistence.files import FileDownload from yuqei_contract_api.persistence.review_results import StructuredReviewCitationPage, StructuredReviewRiskPage @@ -13,6 +23,7 @@ from yuqei_contract_api.persistence.review_tasks import ( from yuqei_contract_api.repository_base import ContractRepository, ContractWorkflowError from yuqei_contract_api.review_report import ReviewReportGenerateRequest, ReviewReportStatus from yuqei_contract_api.review_worker import ReviewWorkerOptions, run_review_worker_once +from yuqei_contract_api.tenant_context import get_tenant_context, get_user_context def register_review_routes( @@ -21,12 +32,15 @@ def register_review_routes( settings: ContractApiSettings, repository: ContractRepository, ) -> None: + authorization = RequestAuthorization(repository, settings) + @app.post( f"{settings.api_prefix}/contracts/{{contract_id}}/review", response_model=ReviewTask, tags=["contracts"], ) def start_contract_review(contract_id: str, request: ReviewTaskRequest | None = None) -> ReviewTask: + authorization.require("start_review") try: review_task = repository.start_review(contract_id, request or ReviewTaskRequest()) except ContractWorkflowError as error: @@ -41,6 +55,7 @@ def register_review_routes( tags=["contracts"], ) def list_contract_review_tasks(contract_id: str) -> list[ReviewTask]: + authorization.require("view_contract") if repository.get_contract(contract_id) is None: raise HTTPException(status_code=404, detail="Contract not found.") return repository.list_review_tasks(contract_id) @@ -57,6 +72,7 @@ def register_review_routes( clause_id: str | None = None, limit: int = 100, ) -> StructuredReviewRiskPage: + authorization.require("view_contract") if repository.get_contract(contract_id) is None: raise HTTPException(status_code=404, detail="Contract not found.") page = repository.list_structured_review_risks( @@ -83,6 +99,7 @@ def register_review_routes( article_id: str | None = None, limit: int = 100, ) -> StructuredReviewCitationPage: + authorization.require("view_contract") if repository.get_contract(contract_id) is None: raise HTTPException(status_code=404, detail="Contract not found.") page = repository.list_structured_review_citations( @@ -106,6 +123,7 @@ def register_review_routes( contract_id: str, review_task_id: str | None = None, ) -> ReviewReportStatus: + authorization.require("view_contract") report = repository.get_review_report(contract_id, review_task_id=review_task_id) if report is None: raise HTTPException(status_code=404, detail="Contract not found.") @@ -120,6 +138,7 @@ def register_review_routes( contract_id: str, request: ReviewReportGenerateRequest | None = None, ) -> ReviewReportStatus: + authorization.require("start_review") try: report = repository.generate_review_report(contract_id, request or ReviewReportGenerateRequest()) except ContractWorkflowError as error: @@ -138,6 +157,7 @@ def register_review_routes( review_task_id: str | None = None, actor_name: str | None = None, ) -> FileDownload: + authorization.require("download_contract") if repository.get_contract(contract_id) is None: raise HTTPException(status_code=404, detail="Contract not found.") try: @@ -150,6 +170,11 @@ def register_review_routes( raise HTTPException(status_code=409, detail=str(error)) from error if download is None: raise HTTPException(status_code=404, detail="Review report not generated.") + _enforce_review_report_egress_budget( + settings=settings, + repository=repository, + bytes_count=_decoded_base64_size(download.content_base64), + ) return download @app.post( @@ -162,6 +187,7 @@ def register_review_routes( review_task_id: str, request: ReviewTaskRunRequest | None = None, ) -> ReviewTask: + authorization.require("start_review") payload = request or ReviewTaskRunRequest() contract = repository.get_contract(contract_id) if contract is None: @@ -186,6 +212,7 @@ def register_review_routes( tags=["review-worker"], ) def run_contract_review_worker_once(request: ReviewWorkerRunRequest | None = None) -> ReviewWorkerRun: + authorization.require("run_review_worker") payload = request or ReviewWorkerRunRequest() return run_review_worker_once( repository=repository, @@ -198,3 +225,49 @@ def register_review_routes( knowledge_base_ids=tuple(payload.knowledge_base_ids), ), ) + + +def _enforce_review_report_egress_budget( + *, + settings: ContractApiSettings, + repository: ContractRepository, + bytes_count: int, +) -> None: + route_key = "review.report" + policy = policy_by_route_key(contract_endpoint_risk_policies(settings.api_prefix), route_key) + decision = reserve_egress_budget( + getattr(repository, "engine", None), + policy, + tenant_id=get_tenant_context() or "default", + user_id=get_user_context(), + limits=EgressBudgetLimits( + s3_records_per_day=settings.anti_abuse_s3_records_daily_limit, + s4_records_per_day=settings.anti_abuse_s4_records_daily_limit, + files_per_day=settings.anti_abuse_files_daily_limit, + bytes_per_day=settings.anti_abuse_bytes_daily_limit, + ), + files_count=1, + bytes_count=bytes_count, + reports_count=1, + ) + if decision is None: + return + raise HTTPException( + status_code=403, + detail={ + "detail": "Daily data egress budget exceeded for this data access path.", + "reason": decision.reason or "daily_egress_budget_exceeded", + }, + headers={ + "X-Anti-Abuse-Decision": "block", + "X-Anti-Abuse-Policy": route_key, + "X-Anti-Abuse-Reason": decision.reason or "daily_egress_budget_exceeded", + }, + ) + + +def _decoded_base64_size(value: str) -> int: + try: + return len(base64.b64decode(value or "", validate=True)) + except (binascii.Error, ValueError): + return 0 diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/security.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/security.py new file mode 100644 index 00000000..962f6313 --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/security.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +from datetime import date, datetime + +from fastapi import FastAPI, Query +from pydantic import BaseModel +from sqlalchemy import func, select + +from yuqei_contract_api.config import ContractApiSettings +from yuqei_contract_api.persistence.models import DataEgressLedgerModel, SecurityAccessEventModel +from yuqei_contract_api.repository_base import ContractRepository +from yuqei_contract_api.tenant_context import get_tenant_context + + +class SecurityAccessEventItem(BaseModel): + id: str + tenant_id: str + user_id: str | None = None + session_id_hash: str | None = None + ip_hash: str | None = None + ua_hash: str | None = None + route_key: str + http_method: str + path: str + resource_type: str | None = None + resource_id: str | None = None + data_class: str + records_count: int + bytes_count: int + decision: str + reason_code: str | None = None + status_code: int + trace_id: str | None = None + created_at: datetime + + +class SecurityAccessEventPage(BaseModel): + items: list[SecurityAccessEventItem] + total: int + page: int + page_size: int + + +class DataEgressLedgerItem(BaseModel): + id: str + tenant_id: str + user_id: str + ledger_date: date + data_class: str + records_count: int + files_count: int + bytes_count: int + reports_count: int + updated_at: datetime + + +class DataEgressLedgerPage(BaseModel): + items: list[DataEgressLedgerItem] + total: int + page: int + page_size: int + + +def register_security_routes( + app: FastAPI, + *, + settings: ContractApiSettings, + repository: ContractRepository, +) -> None: + @app.get( + f"{settings.api_prefix}/security/access-events", + response_model=SecurityAccessEventPage, + tags=["security"], + ) + def list_security_access_events( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + tenant_id: str | None = Query(None), + user_id: str | None = Query(None), + route_key: str | None = Query(None), + decision: str | None = Query(None), + data_class: str | None = Query(None), + created_from: datetime | None = Query(None), + created_to: datetime | None = Query(None), + ) -> SecurityAccessEventPage: + session_factory = getattr(repository, "_session_factory", None) + if not callable(session_factory): + return SecurityAccessEventPage(items=[], total=0, page=page, page_size=page_size) + filters = _security_event_filters( + tenant_id=tenant_id or get_tenant_context(), + user_id=user_id, + route_key=route_key, + decision=decision, + data_class=data_class, + created_from=created_from, + created_to=created_to, + ) + with session_factory() as session: + total = session.scalar(select(func.count()).select_from(SecurityAccessEventModel).where(*filters)) or 0 + rows = session.scalars( + select(SecurityAccessEventModel) + .where(*filters) + .order_by(SecurityAccessEventModel.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ).all() + return SecurityAccessEventPage( + items=[_security_event_item(row) for row in rows], + total=total, + page=page, + page_size=page_size, + ) + + @app.get( + f"{settings.api_prefix}/security/egress-ledger", + response_model=DataEgressLedgerPage, + tags=["security"], + ) + def list_data_egress_ledger( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + tenant_id: str | None = Query(None), + user_id: str | None = Query(None), + data_class: str | None = Query(None), + ledger_date: date | None = Query(None), + ) -> DataEgressLedgerPage: + session_factory = getattr(repository, "_session_factory", None) + if not callable(session_factory): + return DataEgressLedgerPage(items=[], total=0, page=page, page_size=page_size) + filters = _egress_ledger_filters( + tenant_id=tenant_id or get_tenant_context(), + user_id=user_id, + data_class=data_class, + ledger_date=ledger_date, + ) + with session_factory() as session: + total = session.scalar(select(func.count()).select_from(DataEgressLedgerModel).where(*filters)) or 0 + rows = session.scalars( + select(DataEgressLedgerModel) + .where(*filters) + .order_by(DataEgressLedgerModel.ledger_date.desc(), DataEgressLedgerModel.updated_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ).all() + return DataEgressLedgerPage( + items=[_egress_ledger_item(row) for row in rows], + total=total, + page=page, + page_size=page_size, + ) + + +def _security_event_filters( + *, + tenant_id: str | None, + user_id: str | None, + route_key: str | None, + decision: str | None, + data_class: str | None, + created_from: datetime | None, + created_to: datetime | None, +): + filters = [] + if tenant_id: + filters.append(SecurityAccessEventModel.tenant_id == tenant_id.strip()) + if user_id: + filters.append(SecurityAccessEventModel.user_id == user_id.strip()) + if route_key: + filters.append(SecurityAccessEventModel.route_key == route_key.strip()) + if decision: + filters.append(SecurityAccessEventModel.decision == decision.strip()) + if data_class: + filters.append(SecurityAccessEventModel.data_class == data_class.strip()) + if created_from: + filters.append(SecurityAccessEventModel.created_at >= created_from) + if created_to: + filters.append(SecurityAccessEventModel.created_at <= created_to) + return filters + + +def _egress_ledger_filters( + *, + tenant_id: str | None, + user_id: str | None, + data_class: str | None, + ledger_date: date | None, +): + filters = [] + if tenant_id: + filters.append(DataEgressLedgerModel.tenant_id == tenant_id.strip()) + if user_id: + filters.append(DataEgressLedgerModel.user_id == user_id.strip()) + if data_class: + filters.append(DataEgressLedgerModel.data_class == data_class.strip()) + if ledger_date: + filters.append(DataEgressLedgerModel.ledger_date == ledger_date) + return filters + + +def _security_event_item(row: SecurityAccessEventModel) -> SecurityAccessEventItem: + return SecurityAccessEventItem( + id=row.id, + tenant_id=row.tenant_id, + user_id=row.user_id, + session_id_hash=row.session_id_hash, + ip_hash=row.ip_hash, + ua_hash=row.ua_hash, + route_key=row.route_key, + http_method=row.http_method, + path=row.path, + resource_type=row.resource_type, + resource_id=row.resource_id, + data_class=row.data_class, + records_count=row.records_count, + bytes_count=row.bytes_count, + decision=row.decision, + reason_code=row.reason_code, + status_code=row.status_code, + trace_id=row.trace_id, + created_at=row.created_at, + ) + + +def _egress_ledger_item(row: DataEgressLedgerModel) -> DataEgressLedgerItem: + return DataEgressLedgerItem( + id=row.id, + tenant_id=row.tenant_id, + user_id=row.user_id, + ledger_date=row.ledger_date, + data_class=row.data_class, + records_count=row.records_count, + files_count=row.files_count, + bytes_count=row.bytes_count, + reports_count=row.reports_count, + updated_at=row.updated_at, + ) diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/support.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/support.py index 802c0e65..5b4ac65a 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/support.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/support.py @@ -2,6 +2,7 @@ from datetime import datetime from fastapi import FastAPI, HTTPException, Query +from yuqei_contract_api.authorization import RequestAuthorization from yuqei_contract_api.config import ContractApiSettings from yuqei_contract_api.domain import ( ApprovalRule, @@ -30,6 +31,8 @@ def register_support_routes( settings: ContractApiSettings, repository: ContractRepository, ) -> None: + authorization = RequestAuthorization(repository, settings) + @app.get( f"{settings.api_prefix}/contracts/export", response_model=list[ContractExportRow], @@ -39,6 +42,7 @@ def register_support_routes( status: str | None = Query(None), lifecycle: str | None = Query(None), ) -> list[ContractExportRow]: + authorization.require("download_contract") return repository.export_contracts(status=status, lifecycle=lifecycle) @app.get( @@ -50,6 +54,7 @@ def register_support_routes( contract_id: str | None = Query(None), include_completed: bool = Query(False), ) -> list[ObligationTask]: + authorization.require("view_contract") return repository.list_obligations(contract_id, include_completed=include_completed) @app.post( @@ -58,6 +63,7 @@ def register_support_routes( tags=["obligations"], ) def create_obligation(contract_id: str, request: ObligationCreateRequest) -> ObligationTask: + authorization.require("edit_contract") task = repository.create_obligation(contract_id, request) if task is None: raise HTTPException(status_code=404, detail="Contract not found.") @@ -69,6 +75,7 @@ def register_support_routes( tags=["obligations"], ) def complete_obligation(obligation_id: str) -> ObligationTask: + authorization.require("edit_contract") task = repository.complete_obligation(obligation_id) if task is None: raise HTTPException(status_code=404, detail="Obligation not found.") @@ -80,6 +87,7 @@ def register_support_routes( tags=["contracts"], ) def list_signature_evidence(contract_id: str) -> SignatureEvidencePage: + authorization.require("view_contract") items = repository.list_signature_evidence(contract_id) return SignatureEvidencePage(items=items, total=len(items)) @@ -94,6 +102,7 @@ def register_support_routes( signer_role: str = Query("approver"), summary: str | None = Query(None), ) -> SignatureEvidence: + authorization.require("process_approval") evidence = repository.create_signature_evidence( contract_id, signer_name=signer_name, @@ -110,6 +119,7 @@ def register_support_routes( tags=["contracts"], ) def archive_contract(contract_id: str, request: ArchiveRequest) -> ContractDetail: + authorization.require("archive_contract") try: contract = repository.archive_contract(contract_id, request) except ContractWorkflowError as error: @@ -124,6 +134,7 @@ def register_support_routes( tags=["contracts"], ) def list_archive_evidence(contract_id: str) -> list[ArchiveEvidence]: + authorization.require("view_contract") return repository.list_archive_evidence(contract_id) @app.get( @@ -141,6 +152,7 @@ def register_support_routes( created_from: datetime | None = Query(None), created_to: datetime | None = Query(None), ) -> GlobalAuditEventPage: + authorization.require("view_audit") return repository.list_global_audit_events( page=page, page_size=page_size, @@ -163,6 +175,7 @@ def register_support_routes( notification_type: NotificationType | None = Query(None, alias="type"), is_read: bool | None = Query(None), ) -> NotificationPage: + authorization.role() return repository.list_notifications( page=page, page_size=page_size, @@ -176,6 +189,7 @@ def register_support_routes( tags=["notifications"], ) def mark_notification_read(notification_id: str) -> Notification: + authorization.role() notification = repository.mark_notification_read(notification_id) if notification is None: raise HTTPException(status_code=404, detail="Notification not found.") @@ -186,6 +200,7 @@ def register_support_routes( tags=["notifications"], ) def mark_all_notifications_read() -> dict[str, int]: + authorization.role() count = repository.mark_all_notifications_read() return {"marked_count": count} @@ -199,6 +214,7 @@ def register_support_routes( page_size: int = Query(20, ge=1, le=100), enabled_only: bool = Query(False), ) -> ApprovalRulePage: + authorization.require("manage_approval_rule") return repository.list_approval_rules( page=page, page_size=page_size, @@ -211,6 +227,7 @@ def register_support_routes( tags=["approval-rules"], ) def create_approval_rule(request: ApprovalRuleCreateRequest) -> ApprovalRule: + authorization.require("manage_approval_rule") return repository.create_approval_rule(request) @app.patch( @@ -219,6 +236,7 @@ def register_support_routes( tags=["approval-rules"], ) def update_approval_rule(rule_id: str, request: ApprovalRuleUpdateRequest) -> ApprovalRule: + authorization.require("manage_approval_rule") rule = repository.update_approval_rule(rule_id, request) if rule is None: raise HTTPException(status_code=404, detail="Approval rule not found.") @@ -229,5 +247,6 @@ def register_support_routes( tags=["approval-rules"], ) def delete_approval_rule(rule_id: str) -> dict[str, bool]: + authorization.require("manage_approval_rule") deleted = repository.delete_approval_rule(rule_id) return {"deleted": deleted} diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/system.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/system.py index 9d326bfd..6fa995ae 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/system.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/system.py @@ -234,5 +234,8 @@ def register_system_routes( return check_contract_schema( settings.database_url, environment=settings.environment, + anti_abuse_mode=settings.anti_abuse_mode, + redis_url=settings.redis_url, + anti_abuse_hash_secret=settings.anti_abuse_hash_secret, engine=getattr(repository, "engine", None), ) diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/templates.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/templates.py index e4faac9d..7e357c25 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/templates.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/templates.py @@ -1,5 +1,6 @@ from fastapi import FastAPI, HTTPException, Query +from yuqei_contract_api.authorization import RequestAuthorization from yuqei_contract_api.config import ContractApiSettings from yuqei_contract_api.contract_templates import ( ContractTemplateDiff, @@ -27,6 +28,8 @@ def register_template_routes( settings: ContractApiSettings, repository: ContractRepository, ) -> None: + authorization = RequestAuthorization(repository, settings) + @app.get( f"{settings.api_prefix}/templates", response_model=ContractTemplatePage, @@ -36,6 +39,7 @@ def register_template_routes( keyword: str | None = Query(None), contract_type: str | None = Query(None), ) -> ContractTemplatePage: + authorization.require("view_contract") return repository.list_templates(keyword=keyword, contract_type=contract_type) @app.get( @@ -44,6 +48,7 @@ def register_template_routes( tags=["templates"], ) def list_template_tree(keyword: str | None = Query(None)) -> ContractTemplateTree: + authorization.require("view_contract") return repository.list_template_tree(keyword=keyword) @app.get( @@ -52,6 +57,7 @@ def register_template_routes( tags=["templates"], ) def get_template(template_id: str) -> ContractTemplateItem: + authorization.require("view_contract") try: return repository.get_template(template_id) except TemplateNotFoundError as error: @@ -63,6 +69,7 @@ def register_template_routes( tags=["templates"], ) def get_template_edit_draft(template_id: str) -> ContractTemplateEditDraft: + authorization.require("manage_template") try: return repository.get_template_edit_draft(template_id) except TemplateNotFoundError as error: @@ -77,6 +84,7 @@ def register_template_routes( template_id: str, request: ContractTemplateEditRequest, ) -> ContractTemplateEditDraft: + authorization.require("manage_template") try: return repository.save_template_edit_draft(template_id, request) except TemplateNotFoundError as error: @@ -88,6 +96,7 @@ def register_template_routes( tags=["templates"], ) def get_template_diff(template_id: str) -> ContractTemplateDiff: + authorization.require("manage_template") try: return repository.get_template_diff(template_id) except TemplateNotFoundError as error: @@ -99,6 +108,7 @@ def register_template_routes( tags=["templates"], ) def validate_template_before_publish(template_id: str) -> ContractTemplateValidationResult: + authorization.require("manage_template") try: return repository.validate_template_before_publish(template_id) except TemplateNotFoundError as error: @@ -110,6 +120,7 @@ def register_template_routes( tags=["templates"], ) def list_template_history(template_id: str) -> ContractTemplateLifecycleEventPage: + authorization.require("manage_template") try: return repository.list_template_lifecycle_events(template_id) except TemplateNotFoundError as error: @@ -124,6 +135,7 @@ def register_template_routes( template_id: str, request: ContractTemplateLifecycleRequest | None = None, ) -> ContractTemplateLifecycleEvent: + authorization.require("manage_template") try: return repository.publish_template(template_id, request or ContractTemplateLifecycleRequest()) except TemplateNotFoundError as error: @@ -140,6 +152,7 @@ def register_template_routes( template_id: str, request: ContractTemplateLifecycleRequest | None = None, ) -> ContractTemplateLifecycleEvent: + authorization.require("manage_template") try: return repository.offline_template(template_id, request or ContractTemplateLifecycleRequest()) except TemplateNotFoundError as error: @@ -154,6 +167,7 @@ def register_template_routes( template_id: str, request: ContractTemplateRollbackRequest | None = None, ) -> ContractTemplateLifecycleEvent: + authorization.require("manage_template") try: return repository.rollback_template(template_id, request or ContractTemplateRollbackRequest()) except TemplateNotFoundError as error: @@ -167,6 +181,7 @@ def register_template_routes( tags=["templates"], ) def create_contract_from_template(template_id: str, request: ContractTemplateDraftRequest) -> ContractDetail: + authorization.require("create_contract") try: return repository.create_contract_from_template(template_id, request) except TemplateNotFoundError as error: diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/authorization.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/authorization.py new file mode 100644 index 00000000..1e838ab1 --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/authorization.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from typing import Literal + +from fastapi import HTTPException + +from yuqei_contract_api.config import ContractApiSettings +from yuqei_contract_api.domain import UserRole, normalize_role +from yuqei_contract_api.repository_base import ContractRepository +from yuqei_contract_api.tenant_context import get_tenant_context, get_user_context + + +Capability = Literal[ + "view_contract", + "create_contract", + "edit_contract", + "submit_approval", + "process_approval", + "start_review", + "download_contract", + "view_file", + "upload_file", + "view_audit", + "archive_contract", + "manage_template", + "manage_legal_asset", + "manage_approval_rule", + "manage_backup", + "run_review_worker", +] + + +ROLE_CAPABILITIES: dict[UserRole, frozenset[Capability]] = { + "admin": frozenset( + { + "view_contract", + "create_contract", + "edit_contract", + "submit_approval", + "process_approval", + "start_review", + "download_contract", + "view_file", + "upload_file", + "view_audit", + "archive_contract", + "manage_template", + "manage_legal_asset", + "manage_approval_rule", + "manage_backup", + "run_review_worker", + } + ), + "legal": frozenset( + { + "view_contract", + "create_contract", + "edit_contract", + "submit_approval", + "start_review", + "download_contract", + "view_file", + "upload_file", + "view_audit", + "archive_contract", + "manage_template", + "manage_legal_asset", + "manage_approval_rule", + } + ), + "business": frozenset( + { + "view_contract", + "create_contract", + "edit_contract", + "submit_approval", + "start_review", + "view_file", + "upload_file", + } + ), + "approver": frozenset({"view_contract", "process_approval", "view_file", "view_audit"}), + "observer": frozenset({"view_contract", "view_file", "view_audit"}), +} + + +class RequestAuthorization: + """Resolve capabilities from the authenticated tenant membership. + + Anonymous role overrides remain available only when proxy authentication is + disabled. This keeps local fixtures and explicit demo environments usable, + while authenticated requests never trust a query or request-body role. + """ + + def __init__(self, repository: ContractRepository, settings: ContractApiSettings) -> None: + self._identity = getattr(repository, "identity", None) + self._settings = settings + + def role(self, compatibility_role: str | None = None) -> UserRole: + if not self._settings.require_web_proxy_auth: + return normalize_role(compatibility_role) + + tenant_id = get_tenant_context() or "default" + user_id = get_user_context() + if user_id: + if self._identity is None: + raise HTTPException(status_code=403, detail="Identity membership cannot be verified.") + users = self._identity.list_users(tenant_id=tenant_id).items + user = next((item for item in users if item.id == user_id), None) + if user is None: + raise HTTPException(status_code=403, detail="User is not a member of this workspace.") + if user.status != "active": + raise HTTPException(status_code=403, detail="User membership is not active.") + return normalize_role(user.role) + + raise HTTPException(status_code=401, detail="Authenticated user context is required.") + + def require(self, capability: Capability, *, compatibility_role: str | None = None) -> UserRole: + role = self.role(compatibility_role) + if capability not in ROLE_CAPABILITIES[role]: + raise HTTPException(status_code=403, detail=f"Permission denied: {capability} is required.") + return role diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/config.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/config.py index 8ed0459e..6f4929e6 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/config.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/config.py @@ -6,6 +6,8 @@ from urllib.parse import urlparse from pydantic import BaseModel, Field, SecretStr from pydantic_settings import BaseSettings, SettingsConfigDict +from yuqei_contract_api.anti_abuse import anti_abuse_config_status, contract_endpoint_risk_policies + class ContractApiSettings(BaseSettings): service_name: str = "contract-api" @@ -49,6 +51,12 @@ class ContractApiSettings(BaseSettings): identity_login_ip_limit: int = 50 identity_anti_enumeration_floor_ms: int = 120 identity_anti_enumeration_jitter_ms: int = 30 + anti_abuse_mode: Literal["disabled", "observe", "enforce"] = "disabled" + anti_abuse_hash_secret: str = "" + anti_abuse_s3_records_daily_limit: int = 1000 + anti_abuse_s4_records_daily_limit: int = 1000 + anti_abuse_files_daily_limit: int = 50 + anti_abuse_bytes_daily_limit: int = 500 * 1024 * 1024 model_config = SettingsConfigDict( env_file=".env", @@ -77,6 +85,7 @@ def check_runtime_config(settings: ContractApiSettings) -> RuntimeConfigStatus: _check_web_proxy_auth_config(settings), _check_identity_delivery_config(settings), _check_identity_rate_limit_config(settings), + _check_anti_abuse_config(settings), _check_worker_config(settings), _check_file_storage_config(settings), ] @@ -375,6 +384,30 @@ def _check_identity_rate_limit_config(settings: ContractApiSettings) -> RuntimeC ) +def _check_anti_abuse_config(settings: ContractApiSettings) -> RuntimeConfigCheck: + status, message, details = anti_abuse_config_status( + mode=settings.anti_abuse_mode, + environment=settings.environment, + redis_url=settings.redis_url, + hash_secret=settings.anti_abuse_hash_secret, + policy_count=len(contract_endpoint_risk_policies(settings.api_prefix)), + ) + details.update( + { + "s3_records_daily_limit": settings.anti_abuse_s3_records_daily_limit, + "s4_records_daily_limit": settings.anti_abuse_s4_records_daily_limit, + "files_daily_limit": settings.anti_abuse_files_daily_limit, + "bytes_daily_limit": settings.anti_abuse_bytes_daily_limit, + } + ) + return RuntimeConfigCheck( + name="anti_abuse", + status=status, # type: ignore[arg-type] + message=message, + details=details, + ) + + @lru_cache def get_settings() -> ContractApiSettings: return ContractApiSettings() diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/identity.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/identity.py index 92d450a3..96d5bfce 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/identity.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/identity.py @@ -1,15 +1,18 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import UTC, datetime, timedelta +from hashlib import sha256 from threading import Lock from typing import Literal from uuid import uuid4 -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from yuqei_contract_api.domain import UserRole, normalize_role from yuqei_contract_api.identity_security import ( DUMMY_CREDENTIAL_HASH, + HASH_SCHEME, create_email_code, create_invitation_token, hash_email_code, @@ -27,17 +30,45 @@ from yuqei_contract_api.persistence.models import DEFAULT_TENANT_ID TenantStatus = Literal["active", "suspended", "archived"] +WorkspaceType = Literal["system", "personal", "organization"] +PlatformPermission = Literal["platform_admin"] TenantUserStatus = Literal["active", "invited", "disabled"] TenantInvitationStatus = Literal["pending", "accepted", "revoked", "expired"] EmailChallengePurpose = Literal["email_verification", "password_reset"] EmailChallengeStatus = Literal["pending", "verified", "expired", "locked"] SsoProvider = Literal["saml", "oidc"] +AgreementType = Literal["user_terms", "privacy_policy", "service_agreement", "ai_use_notice"] +AgreementLifecycleStatus = Literal["draft", "effective", "retired"] +RegistrationKind = Literal["personal", "organization"] +AgreementConsentSource = Literal["personal_registration", "organization_registration"] +GlobalAccountStatus = Literal["active", "disabled", "archived"] +WorkspaceMembershipStatus = Literal["active", "invited", "disabled"] +AccountConflictSeverity = Literal["critical", "warning"] +AccountConflictCode = Literal[ + "duplicate_global_accounts", + "missing_global_account", + "missing_membership", + "orphan_membership", + "membership_identity_mismatch", + "duplicate_membership_mapping", + "membership_state_drift", + "owner_flag_drift", + "disabled_account_with_active_membership", + "credential_identity_mismatch", + "unsupported_credential_scheme", + "active_identity_without_credential", + "legacy_multi_credential_unified_missing", +] class IdentityConflictError(ValueError): """Raised when an identity management record violates a unique boundary.""" +class LastPlatformAdministratorError(ValueError): + """Raised when an update would remove the final effective platform administrator.""" + + class IdentityCapabilities(BaseModel): email_delivery_mode: Literal["disabled", "development", "smtp"] registration_enabled: bool @@ -54,9 +85,76 @@ class IdentityBootstrapResult(BaseModel): email: str +class AgreementVersionInfo(BaseModel): + agreement_type: AgreementType + version: str + title: str + document_path: str + lifecycle_status: AgreementLifecycleStatus + is_effective: bool + legal_review_required: bool + required_for_registration: list[RegistrationKind] = Field(default_factory=list) + effective_at: datetime | None = None + + +class AgreementVersionPage(BaseModel): + items: list[AgreementVersionInfo] + total: int + + +class AgreementAcceptanceRequest(BaseModel): + agreement_type: AgreementType + version: str + accepted: Literal[True] + + +class AgreementConsentItem(BaseModel): + id: str + tenant_id: str + user_id: str + agreement_type: AgreementType + agreement_version: str + agreed_at: datetime + source: AgreementConsentSource + client_ip: str | None = None + user_agent: str | None = None + created_at: datetime + + +class AgreementConsentPage(BaseModel): + items: list[AgreementConsentItem] + total: int + + +class GlobalAccountInfo(BaseModel): + id: str + email: str + display_name: str + status: GlobalAccountStatus = "active" + email_verified_at: datetime | None = None + created_at: datetime + updated_at: datetime + + +class WorkspaceMembershipInfo(BaseModel): + id: str + account_id: str + tenant_id: str + tenant_user_id: str + role: UserRole + status: WorkspaceMembershipStatus = "active" + is_owner: bool = False + joined_at: datetime + created_at: datetime + updated_at: datetime + + class TenantInfo(BaseModel): id: str name: str + workspace_type: WorkspaceType = "organization" + owner_user_id: str | None = None + legal_name: str | None = None status: TenantStatus = "active" plan: str | None = None region: str | None = None @@ -71,9 +169,23 @@ class TenantPage(BaseModel): total: int +class WorkspaceAccessItem(BaseModel): + workspace: TenantInfo + membership: WorkspaceMembershipInfo + + +class WorkspaceAccessPage(BaseModel): + items: list[WorkspaceAccessItem] + total: int + current_tenant_id: str + + class TenantCreateRequest(BaseModel): id: str | None = None name: str + workspace_type: WorkspaceType = "organization" + owner_user_id: str | None = None + legal_name: str | None = None status: TenantStatus = "active" plan: str | None = None region: str | None = None @@ -83,6 +195,9 @@ class TenantCreateRequest(BaseModel): class TenantUpdateRequest(BaseModel): name: str | None = None + workspace_type: WorkspaceType | None = None + owner_user_id: str | None = None + legal_name: str | None = None status: TenantStatus | None = None plan: str | None = None region: str | None = None @@ -100,6 +215,7 @@ class TenantUserItem(BaseModel): title: str | None = None department: str | None = None phone: str | None = None + platform_permissions: list[PlatformPermission] = Field(default_factory=list) settings: dict[str, object] = Field(default_factory=dict) invited_at: datetime | None = None last_login_at: datetime | None = None @@ -112,6 +228,580 @@ class TenantUserPage(BaseModel): total: int +class PlatformAdministratorItem(BaseModel): + user_id: str + tenant_id: str + tenant_name: str + email: str + display_name: str + role: UserRole + status: TenantUserStatus + last_login_at: datetime | None = None + created_at: datetime + updated_at: datetime + + +class PlatformAdministratorPage(BaseModel): + items: list[PlatformAdministratorItem] + total: int + active_total: int + + +class AccountConflictCredentialCoverage(BaseModel): + global_active: bool + legacy_present: int + active_users_without_credential: int + + +class AccountConflictItem(BaseModel): + diagnostic_id: str + normalized_email: str | None = None + severity: AccountConflictSeverity + conflict_codes: list[AccountConflictCode] + account_count: int + user_count: int + membership_count: int + workspace_count: int + credential_coverage: AccountConflictCredentialCoverage + last_observed_at: datetime + + +class AccountConflictIssue(BaseModel): + conflict_code: AccountConflictCode + severity: AccountConflictSeverity + entity_type: str + entity_ids: list[str] + + +class AccountConflictAccount(BaseModel): + id: str + email: str + display_name: str + status: GlobalAccountStatus + email_verified_at: datetime | None = None + updated_at: datetime + + +class AccountConflictUser(BaseModel): + id: str + tenant_id: str + email: str + display_name: str + role: UserRole + status: TenantUserStatus + deleted: bool = False + updated_at: datetime + + +class AccountConflictMembership(BaseModel): + id: str + account_id: str + tenant_id: str + tenant_user_id: str + role: UserRole + status: WorkspaceMembershipStatus + is_owner: bool + updated_at: datetime + + +class AccountConflictCredential(BaseModel): + id: str + scope: Literal["global", "legacy"] + account_id: str | None = None + tenant_id: str | None = None + user_id: str | None = None + email: str | None = None + status: str + scheme: str + supported: bool + password_updated_at: datetime | None = None + password_reset_required: bool = False + + +class AccountConflictDetail(AccountConflictItem): + issues: list[AccountConflictIssue] + accounts: list[AccountConflictAccount] + users: list[AccountConflictUser] + memberships: list[AccountConflictMembership] + credentials: list[AccountConflictCredential] + + +class AccountConflictPage(BaseModel): + items: list[AccountConflictItem] + total: int + page: int + page_size: int + critical_total: int + warning_total: int + + +@dataclass(frozen=True) +class AccountDiagnosticTenantRecord: + id: str + owner_user_id: str | None + deleted: bool + updated_at: datetime + + +@dataclass(frozen=True) +class AccountDiagnosticUserRecord: + id: str + tenant_id: str + email: str + display_name: str + role: UserRole + status: TenantUserStatus + deleted: bool + updated_at: datetime + + +@dataclass(frozen=True) +class AccountDiagnosticCredentialRecord: + id: str + scope: Literal["global", "legacy"] + account_id: str | None + tenant_id: str | None + user_id: str | None + email: str | None + status: str + scheme: str + password_updated_at: datetime | None + password_reset_required: bool + updated_at: datetime + + +def build_account_conflict_details( + *, + accounts: list[GlobalAccountInfo], + users: list[AccountDiagnosticUserRecord], + memberships: list[WorkspaceMembershipInfo], + tenants: list[AccountDiagnosticTenantRecord], + credentials: list[AccountDiagnosticCredentialRecord], +) -> list[AccountConflictDetail]: + accounts_by_id = {item.id: item for item in accounts} + users_by_id = {item.id: item for item in users} + tenants_by_id = {item.id: item for item in tenants} + + group_accounts: dict[str, dict[str, GlobalAccountInfo]] = {} + group_users: dict[str, dict[str, AccountDiagnosticUserRecord]] = {} + group_memberships: dict[str, dict[str, WorkspaceMembershipInfo]] = {} + group_credentials: dict[str, dict[str, AccountDiagnosticCredentialRecord]] = {} + + def ensure_group(key: str) -> None: + group_accounts.setdefault(key, {}) + group_users.setdefault(key, {}) + group_memberships.setdefault(key, {}) + group_credentials.setdefault(key, {}) + + def email_group(email: str | None, fallback: str) -> str: + normalized = _diagnostic_normalize_email(email) + return normalized or fallback + + for account in accounts: + key = email_group(account.email, f"orphan-account:{account.id}") + ensure_group(key) + group_accounts[key][account.id] = account + for user in users: + key = email_group(user.email, f"orphan-user:{user.id}") + ensure_group(key) + group_users[key][user.id] = user + for membership in memberships: + user = users_by_id.get(membership.tenant_user_id) + account = accounts_by_id.get(membership.account_id) + key = email_group( + user.email if user is not None else account.email if account is not None else None, + f"orphan-membership:{membership.id}", + ) + ensure_group(key) + group_memberships[key][membership.id] = membership + if user is not None: + group_users[key][user.id] = user + if account is not None: + group_accounts[key][account.id] = account + for credential in credentials: + user = users_by_id.get(credential.user_id or "") + account = accounts_by_id.get(credential.account_id or "") + key = email_group( + user.email if user is not None else account.email if account is not None else credential.email, + f"orphan-credential:{credential.id}", + ) + ensure_group(key) + group_credentials[key][credential.id] = credential + if user is not None: + group_users[key][user.id] = user + if account is not None: + group_accounts[key][account.id] = account + + details: list[AccountConflictDetail] = [] + for group_key in sorted(group_accounts | group_users | group_memberships | group_credentials): + current_accounts = list(group_accounts[group_key].values()) + current_users = list(group_users[group_key].values()) + current_memberships = list(group_memberships[group_key].values()) + current_credentials = list(group_credentials[group_key].values()) + issues: list[AccountConflictIssue] = [] + + def add_issue( + code: AccountConflictCode, + severity: AccountConflictSeverity, + entity_type: str, + entity_ids: list[str], + ) -> None: + resolved_ids = sorted(set(entity_ids)) + if resolved_ids: + issues.append( + AccountConflictIssue( + conflict_code=code, + severity=severity, + entity_type=entity_type, + entity_ids=resolved_ids, + ) + ) + + normalized_group_email = None if group_key.startswith("orphan-") else group_key + same_email_accounts = [ + item + for item in current_accounts + if _diagnostic_normalize_email(item.email) == normalized_group_email + ] + if len(same_email_accounts) > 1: + add_issue( + "duplicate_global_accounts", + "critical", + "global_account", + [item.id for item in same_email_accounts], + ) + + memberships_by_user: dict[str, list[WorkspaceMembershipInfo]] = {} + memberships_by_account_tenant: dict[tuple[str, str], list[WorkspaceMembershipInfo]] = {} + for membership in current_memberships: + memberships_by_user.setdefault(membership.tenant_user_id, []).append(membership) + memberships_by_account_tenant.setdefault( + (membership.account_id, membership.tenant_id), [] + ).append(membership) + + account = accounts_by_id.get(membership.account_id) + user = users_by_id.get(membership.tenant_user_id) + tenant = tenants_by_id.get(membership.tenant_id) + if ( + account is None + or user is None + or user.deleted + or tenant is None + or tenant.deleted + ): + add_issue("orphan_membership", "critical", "workspace_membership", [membership.id]) + continue + if ( + _diagnostic_normalize_email(account.email) + != _diagnostic_normalize_email(user.email) + or membership.tenant_id != user.tenant_id + ): + add_issue( + "membership_identity_mismatch", + "critical", + "workspace_membership", + [membership.id, account.id, user.id], + ) + if membership.role != user.role or membership.status != user.status: + add_issue( + "membership_state_drift", + "warning", + "workspace_membership", + [membership.id, user.id], + ) + expected_owner = tenant.owner_user_id == user.id + if membership.is_owner != expected_owner: + add_issue( + "owner_flag_drift", + "warning", + "workspace_membership", + [membership.id, tenant.id, user.id], + ) + if account.status != "active" and membership.status == "active": + add_issue( + "disabled_account_with_active_membership", + "critical", + "workspace_membership", + [membership.id, account.id], + ) + + for membership_group in memberships_by_user.values(): + if len(membership_group) > 1: + add_issue( + "duplicate_membership_mapping", + "critical", + "workspace_membership", + [item.id for item in membership_group], + ) + for membership_group in memberships_by_account_tenant.values(): + if len(membership_group) > 1: + add_issue( + "duplicate_membership_mapping", + "critical", + "workspace_membership", + [item.id for item in membership_group], + ) + + for user in current_users: + if user.deleted: + continue + matching_accounts = { + item.id + for item in accounts + if _diagnostic_normalize_email(item.email) + == _diagnostic_normalize_email(user.email) + } + if user.status != "disabled" and not matching_accounts: + add_issue( + "missing_global_account", + "critical" if user.status == "active" else "warning", + "tenant_user", + [user.id], + ) + if not memberships_by_user.get(user.id): + add_issue( + "missing_membership", + "critical" if user.status == "active" else "warning", + "tenant_user", + [user.id], + ) + + active_global_credentials = [ + item + for item in current_credentials + if item.scope == "global" and item.status == "active" + ] + legacy_credentials = [item for item in current_credentials if item.scope == "legacy"] + for credential in current_credentials: + if credential.scheme != HASH_SCHEME: + add_issue( + "unsupported_credential_scheme", + "critical", + "credential", + [credential.id], + ) + if credential.scope == "global": + if credential.account_id not in accounts_by_id: + add_issue( + "credential_identity_mismatch", + "critical", + "credential", + [credential.id], + ) + continue + user = users_by_id.get(credential.user_id or "") + if ( + user is None + or user.deleted + or credential.tenant_id != user.tenant_id + or _diagnostic_normalize_email(credential.email) + != _diagnostic_normalize_email(user.email) + ): + add_issue( + "credential_identity_mismatch", + "critical", + "credential", + [credential.id, credential.user_id or ""], + ) + + active_users_without_credential = 0 + for user in current_users: + if user.deleted or user.status != "active": + continue + membership_account_ids = { + item.account_id for item in memberships_by_user.get(user.id, []) + } + has_global_credential = any( + item.account_id in membership_account_ids + for item in active_global_credentials + ) + has_legacy_credential = any( + item.user_id == user.id and item.status == "active" + for item in legacy_credentials + ) + if not has_global_credential and not has_legacy_credential: + active_users_without_credential += 1 + add_issue( + "active_identity_without_credential", + "critical", + "tenant_user", + [user.id], + ) + + if len(legacy_credentials) > 1 and not active_global_credentials: + add_issue( + "legacy_multi_credential_unified_missing", + "warning", + "credential", + [item.id for item in legacy_credentials], + ) + + if not issues: + continue + issue_keys: set[tuple[str, str, tuple[str, ...]]] = set() + unique_issues: list[AccountConflictIssue] = [] + for issue in issues: + key = (issue.conflict_code, issue.entity_type, tuple(issue.entity_ids)) + if key not in issue_keys: + issue_keys.add(key) + unique_issues.append(issue) + unique_issues.sort( + key=lambda item: ( + 0 if item.severity == "critical" else 1, + item.conflict_code, + item.entity_ids, + ) + ) + severity: AccountConflictSeverity = ( + "critical" if any(item.severity == "critical" for item in unique_issues) else "warning" + ) + entity_ids = sorted( + { + *[item.id for item in current_accounts], + *[item.id for item in current_users], + *[item.id for item in current_memberships], + *[item.id for item in current_credentials], + } + ) + diagnostic_id = "account-conflict-" + sha256( + "|".join(entity_ids).encode("utf-8") + ).hexdigest()[:24] + observed_at = max( + [ + *[item.updated_at for item in current_accounts], + *[item.updated_at for item in current_users], + *[item.updated_at for item in current_memberships], + *[item.updated_at for item in current_credentials], + ] + ) + conflict_codes = list(dict.fromkeys(item.conflict_code for item in unique_issues)) + details.append( + AccountConflictDetail( + diagnostic_id=diagnostic_id, + normalized_email=normalized_group_email, + severity=severity, + conflict_codes=conflict_codes, + account_count=len(current_accounts), + user_count=len(current_users), + membership_count=len(current_memberships), + workspace_count=len( + { + *[item.tenant_id for item in current_users], + *[item.tenant_id for item in current_memberships], + } + ), + credential_coverage=AccountConflictCredentialCoverage( + global_active=bool(active_global_credentials), + legacy_present=len(legacy_credentials), + active_users_without_credential=active_users_without_credential, + ), + last_observed_at=observed_at, + issues=unique_issues, + accounts=[ + AccountConflictAccount( + id=item.id, + email=item.email, + display_name=item.display_name, + status=item.status, + email_verified_at=item.email_verified_at, + updated_at=item.updated_at, + ) + for item in sorted(current_accounts, key=lambda value: value.id) + ], + users=[ + AccountConflictUser( + id=item.id, + tenant_id=item.tenant_id, + email=item.email, + display_name=item.display_name, + role=item.role, + status=item.status, + deleted=item.deleted, + updated_at=item.updated_at, + ) + for item in sorted(current_users, key=lambda value: value.id) + ], + memberships=[ + AccountConflictMembership(**item.model_dump()) + for item in sorted(current_memberships, key=lambda value: value.id) + ], + credentials=[ + AccountConflictCredential( + id=item.id, + scope=item.scope, + account_id=item.account_id, + tenant_id=item.tenant_id, + user_id=item.user_id, + email=item.email, + status=item.status, + scheme=item.scheme, + supported=item.scheme == HASH_SCHEME, + password_updated_at=item.password_updated_at, + password_reset_required=item.password_reset_required, + ) + for item in sorted(current_credentials, key=lambda value: value.id) + ], + ) + ) + + details.sort( + key=lambda item: ( + 0 if item.severity == "critical" else 1, + -_datetime_timestamp(item.last_observed_at), + item.normalized_email or "", + item.diagnostic_id, + ) + ) + return details + + +def paginate_account_conflicts( + details: list[AccountConflictDetail], + *, + page: int, + page_size: int, + severity: AccountConflictSeverity | None = None, + conflict_code: AccountConflictCode | None = None, + email: str | None = None, +) -> AccountConflictPage: + normalized_email = _diagnostic_normalize_email(email) or None + matching_scope = [ + item + for item in details + if (conflict_code is None or conflict_code in item.conflict_codes) + and (normalized_email is None or item.normalized_email == normalized_email) + ] + filtered = [ + item + for item in matching_scope + if severity is None or item.severity == severity + ] + start = (page - 1) * page_size + return AccountConflictPage( + items=[ + AccountConflictItem( + **item.model_dump( + exclude={"issues", "accounts", "users", "memberships", "credentials"} + ) + ) + for item in filtered[start : start + page_size] + ], + total=len(filtered), + page=page, + page_size=page_size, + critical_total=sum(1 for item in matching_scope if item.severity == "critical"), + warning_total=sum(1 for item in matching_scope if item.severity == "warning"), + ) + + +def _datetime_timestamp(value: datetime) -> float: + return (value if value.tzinfo is not None else value.replace(tzinfo=UTC)).timestamp() + + +def _diagnostic_normalize_email(value: str | None) -> str: + return (value or "").strip().lower() + + class TenantUserCreateRequest(BaseModel): tenant_id: str | None = None email: str @@ -152,16 +842,13 @@ class AccountSettings(BaseModel): class AccountSettingsUpdateRequest(BaseModel): - tenant_id: str | None = None - user_id: str | None = None - email: str | None = None + model_config = ConfigDict(extra="forbid") + display_name: str | None = None - role: UserRole | None = None locale: str | None = None timezone: str | None = None notification_settings: dict[str, object] | None = None settings: dict[str, object] | None = None - updated_by: str | None = None class TenantRegistrationRequest(BaseModel): @@ -169,7 +856,7 @@ class TenantRegistrationRequest(BaseModel): company_name: str admin_email: str admin_display_name: str - admin_password: str | None = None + admin_password: str | None = Field(default=None, min_length=12, max_length=128) admin_department: str | None = None admin_title: str | None = None admin_phone: str | None = None @@ -177,6 +864,20 @@ class TenantRegistrationRequest(BaseModel): region: str | None = "cn" settings: dict[str, object] = Field(default_factory=dict) created_by: str | None = "self-service" + agreement_acceptances: list[AgreementAcceptanceRequest] + + +class PersonalRegistrationRequest(BaseModel): + workspace_id: str | None = None + workspace_name: str | None = None + email: str + display_name: str + password: str = Field(min_length=12, max_length=128) + plan: str | None = "personal" + region: str | None = "cn" + settings: dict[str, object] = Field(default_factory=dict) + created_by: str | None = "self-service" + agreement_acceptances: list[AgreementAcceptanceRequest] class TenantLoginRequest(BaseModel): @@ -185,11 +886,17 @@ class TenantLoginRequest(BaseModel): tenant_id: str | None = None +class WorkspaceSwitchRequest(BaseModel): + tenant_id: str + + class IdentitySession(BaseModel): tenant: TenantInfo user: TenantUserItem account: AccountSettings - auth_mode: Literal["password", "legacy_email_session", "invitation"] = "password" + auth_mode: Literal["password", "legacy_email_session", "invitation", "workspace_switch"] = "password" + global_account: GlobalAccountInfo | None = None + membership: WorkspaceMembershipInfo | None = None class TenantInvitationCreateRequest(BaseModel): @@ -221,7 +928,7 @@ class TenantInvitationItem(BaseModel): class TenantInvitationAcceptRequest(BaseModel): token: str - password: str + password: str = Field(min_length=12, max_length=128) display_name: str | None = None phone: str | None = None @@ -270,7 +977,7 @@ class PasswordResetRequest(BaseModel): class PasswordResetConfirmRequest(BaseModel): challenge_id: str code: str = Field(min_length=6, max_length=6, pattern=r"^\d{6}$") - new_password: str + new_password: str = Field(min_length=12, max_length=128) class TenantSsoConfig(BaseModel): @@ -305,10 +1012,106 @@ class TenantSsoConfigUpdateRequest(BaseModel): updated_by: str | None = None +_CURRENT_REGISTRATION_AGREEMENTS: tuple[AgreementVersionInfo, ...] = ( + AgreementVersionInfo( + agreement_type="user_terms", + version="2026-08-01-draft", + title="用户协议", + document_path="/terms", + lifecycle_status="draft", + is_effective=False, + legal_review_required=True, + required_for_registration=["personal", "organization"], + ), + AgreementVersionInfo( + agreement_type="privacy_policy", + version="2026-08-01-draft", + title="隐私政策", + document_path="/privacy", + lifecycle_status="draft", + is_effective=False, + legal_review_required=True, + required_for_registration=["personal", "organization"], + ), + AgreementVersionInfo( + agreement_type="service_agreement", + version="2026-08-01-draft", + title="服务协议", + document_path="/service-agreement", + lifecycle_status="draft", + is_effective=False, + legal_review_required=True, + required_for_registration=["personal", "organization"], + ), + AgreementVersionInfo( + agreement_type="ai_use_notice", + version="2026-08-01-draft", + title="AI 使用说明", + document_path="/ai-disclaimer", + lifecycle_status="draft", + is_effective=False, + legal_review_required=True, + required_for_registration=["personal", "organization"], + ), +) + + +def current_registration_agreements() -> AgreementVersionPage: + items = [item.model_copy(deep=True) for item in _CURRENT_REGISTRATION_AGREEMENTS] + return AgreementVersionPage(items=items, total=len(items)) + + +def validate_registration_agreements( + acceptances: list[AgreementAcceptanceRequest], + *, + registration_kind: RegistrationKind, +) -> list[AgreementVersionInfo]: + required = [ + item + for item in _CURRENT_REGISTRATION_AGREEMENTS + if registration_kind in item.required_for_registration + ] + expected = {(item.agreement_type, item.version) for item in required} + submitted = {(item.agreement_type, item.version) for item in acceptances} + if len(submitted) != len(acceptances) or submitted != expected: + raise ValueError("Agreement acceptances must exactly match the current required versions.") + return [item.model_copy(deep=True) for item in required] + + +def build_agreement_consents( + agreements: list[AgreementVersionInfo], + *, + tenant_id: str, + user_id: str, + source: AgreementConsentSource, + client_ip: str | None, + user_agent: str | None, + now: datetime, +) -> list[AgreementConsentItem]: + return [ + AgreementConsentItem( + id=f"agreement-consent-{uuid4().hex[:12]}", + tenant_id=tenant_id, + user_id=user_id, + agreement_type=agreement.agreement_type, + agreement_version=agreement.version, + agreed_at=now, + source=source, + client_ip=client_ip, + user_agent=user_agent, + created_at=now, + ) + for agreement in agreements + ] + + def default_tenant(now: datetime | None = None) -> TenantInfo: resolved_now = now or datetime.now(UTC) return TenantInfo( id=DEFAULT_TENANT_ID, + workspace_type="system", + owner_user_id="user-current-admin", + legal_name=None, name="默认租户", status="active", plan="trial", @@ -355,6 +1158,7 @@ class InMemoryIdentityRepository: title="管理员", department="平台运营", phone=None, + platform_permissions=["platform_admin"], settings={}, invited_at=now, last_login_at=None, @@ -369,7 +1173,18 @@ class InMemoryIdentityRepository: self._email_challenge_attempts: dict[str, int] = {} self._identity_rate_limit_events: list[IdentityRateLimitEvent] = [] self._identity_rate_limit_lock = Lock() + self._registration_lock = Lock() + self._platform_admin_lock = Lock() self._sso_configs: dict[tuple[str, SsoProvider], TenantSsoConfig] = {} + self._agreement_consents: dict[str, AgreementConsentItem] = {} + self._global_accounts: dict[str, GlobalAccountInfo] = {} + self._global_credentials: dict[str, str] = {} + self._workspace_memberships: dict[str, WorkspaceMembershipInfo] = {} + self._ensure_global_account_membership( + self._users["user-current-admin"], + tenant, + is_owner=True, + ) def list_tenants(self, *, status: TenantStatus | None = None) -> TenantPage: items = [item for item in self._tenants.values() if status is None or item.status == status] @@ -390,6 +1205,9 @@ class InMemoryIdentityRepository: item = TenantInfo( id=tenant_id, name=request.name.strip(), + workspace_type=request.workspace_type, + owner_user_id=request.owner_user_id, + legal_name=request.legal_name, status=request.status, plan=request.plan, region=request.region, @@ -420,6 +1238,133 @@ class InMemoryIdentityRepository: ] return TenantUserPage(items=sorted(items, key=lambda item: item.updated_at, reverse=True), total=len(items)) + def list_platform_administrators(self) -> PlatformAdministratorPage: + users = [ + item + for item in self._users.values() + if "platform_admin" in item.platform_permissions + ] + users.sort(key=lambda item: (item.status != "active", item.display_name.lower(), item.id)) + items = [ + PlatformAdministratorItem( + user_id=user.id, + tenant_id=user.tenant_id, + tenant_name=self._tenants.get(user.tenant_id, default_tenant()).name, + email=user.email, + display_name=user.display_name, + role=user.role, + status=user.status, + last_login_at=user.last_login_at, + created_at=user.created_at, + updated_at=user.updated_at, + ) + for user in users + ] + return PlatformAdministratorPage( + items=items, + total=len(items), + active_total=sum(1 for item in items if item.status == "active"), + ) + + def list_account_conflicts( + self, + *, + page: int = 1, + page_size: int = 20, + severity: AccountConflictSeverity | None = None, + conflict_code: AccountConflictCode | None = None, + email: str | None = None, + ) -> AccountConflictPage: + return paginate_account_conflicts( + self._account_conflict_details(), + page=page, + page_size=page_size, + severity=severity, + conflict_code=conflict_code, + email=email, + ) + + def get_account_conflict(self, diagnostic_id: str) -> AccountConflictDetail | None: + return next( + ( + item + for item in self._account_conflict_details() + if item.diagnostic_id == diagnostic_id + ), + None, + ) + + def _account_conflict_details(self) -> list[AccountConflictDetail]: + now = datetime.now(UTC) + return build_account_conflict_details( + accounts=list(self._global_accounts.values()), + users=[ + AccountDiagnosticUserRecord( + id=item.id, + tenant_id=item.tenant_id, + email=item.email, + display_name=item.display_name, + role=item.role, + status=item.status, + deleted=False, + updated_at=item.updated_at, + ) + for item in self._users.values() + ], + memberships=list(self._workspace_memberships.values()), + tenants=[ + AccountDiagnosticTenantRecord( + id=item.id, + owner_user_id=item.owner_user_id, + deleted=False, + updated_at=item.updated_at, + ) + for item in self._tenants.values() + ], + credentials=[ + *[ + AccountDiagnosticCredentialRecord( + id=f"global:{account_id}", + scope="global", + account_id=account_id, + tenant_id=None, + user_id=None, + email=None, + status="active", + scheme=HASH_SCHEME, + password_updated_at=( + self._global_accounts[account_id].updated_at + if account_id in self._global_accounts + else now + ), + password_reset_required=False, + updated_at=( + self._global_accounts[account_id].updated_at + if account_id in self._global_accounts + else now + ), + ) + for account_id in self._global_credentials + ], + *[ + AccountDiagnosticCredentialRecord( + id=f"legacy:{user_id}", + scope="legacy", + account_id=None, + tenant_id=self._users[user_id].tenant_id if user_id in self._users else None, + user_id=user_id, + email=self._users[user_id].email if user_id in self._users else None, + status="active", + scheme=HASH_SCHEME, + password_updated_at=self._users[user_id].updated_at if user_id in self._users else now, + password_reset_required=False, + updated_at=self._users[user_id].updated_at if user_id in self._users else now, + ) + for user_id in self._credentials + ], + ], + ) + def create_user(self, request: TenantUserCreateRequest, *, tenant_id: str = DEFAULT_TENANT_ID) -> TenantUserItem: now = datetime.now(UTC) resolved_tenant_id = request.tenant_id or tenant_id @@ -445,39 +1390,413 @@ class InMemoryIdentityRepository: ) self._users[user_id] = item self._account_settings[user_id] = _account_from_user_item(item) + tenant = self._tenants.get(resolved_tenant_id) + if tenant is None: + self._users.pop(user_id, None) + self._account_settings.pop(user_id, None) + raise ValueError("Workspace not found.") + try: + self._ensure_global_account_membership( + item, + tenant, + is_owner=tenant.owner_user_id == item.id, + ) + except Exception: + self._users.pop(user_id, None) + self._account_settings.pop(user_id, None) + raise return item - def register_tenant_admin(self, request: TenantRegistrationRequest) -> IdentitySession: - tenant = self.create_tenant( - TenantCreateRequest( - id=request.tenant_id, - name=request.company_name, + def register_tenant_admin( + self, + request: TenantRegistrationRequest, + *, + client_ip: str | None = None, + user_agent: str | None = None, + ) -> IdentitySession: + agreements = validate_registration_agreements( + request.agreement_acceptances, + registration_kind="organization", + ) + password_hash = hash_password(request.admin_password) if request.admin_password else None + now = datetime.now(UTC) + tenant = None + admin_user = None + with self._registration_lock: + try: + tenant = self.create_tenant( + TenantCreateRequest( + id=request.tenant_id, + name=request.company_name, + workspace_type="organization", + legal_name=request.company_name, + status="active", + plan=request.plan, + region=request.region, + settings=request.settings, + created_by=request.created_by, + ) + ) + admin_user = self.create_user( + TenantUserCreateRequest( + tenant_id=tenant.id, + email=request.admin_email, + display_name=request.admin_display_name, + role="admin", + status="active", + title=request.admin_title, + department=request.admin_department, + phone=request.admin_phone, + settings={}, + created_by=request.created_by, + ), + tenant_id=tenant.id, + ) + account = self._account_settings[admin_user.id] + consents = build_agreement_consents( + agreements, + tenant_id=tenant.id, + user_id=admin_user.id, + source="organization_registration", + client_ip=client_ip, + user_agent=user_agent, + now=now, + ) + if password_hash is not None: + self._credentials[admin_user.id] = password_hash + tenant = tenant.model_copy(update={"owner_user_id": admin_user.id, "user_count": 1}) + self._tenants[tenant.id] = tenant + global_account, membership = self._ensure_global_account_membership( + admin_user, + tenant, + is_owner=True, + ) + if request.admin_password: + global_account = self._ensure_global_credential( + global_account, + password=request.admin_password, + ) + self._agreement_consents.update({item.id: item for item in consents}) + except Exception: + if admin_user is not None: + self._users.pop(admin_user.id, None) + self._account_settings.pop(admin_user.id, None) + self._credentials.pop(admin_user.id, None) + removed_account_ids = { + item.account_id + for item in self._workspace_memberships.values() + if item.tenant_user_id == admin_user.id + } + self._workspace_memberships = { + item_id: item + for item_id, item in self._workspace_memberships.items() + if item.tenant_user_id != admin_user.id + } + active_account_ids = { + item.account_id for item in self._workspace_memberships.values() + } + for account_id in removed_account_ids - active_account_ids: + self._global_accounts.pop(account_id, None) + self._global_credentials.pop(account_id, None) + self._agreement_consents = { + item_id: item + for item_id, item in self._agreement_consents.items() + if item.user_id != admin_user.id + } + if tenant is not None: + self._tenants.pop(tenant.id, None) + raise + return IdentitySession( + tenant=tenant, + user=admin_user, + account=account, + auth_mode="password", + global_account=global_account, + membership=membership, + ) + + def register_personal_workspace( + self, + request: PersonalRegistrationRequest, + *, + client_ip: str | None = None, + user_agent: str | None = None, + ) -> IdentitySession: + agreements = validate_registration_agreements( + request.agreement_acceptances, + registration_kind="personal", + ) + now = datetime.now(UTC) + tenant_id = _normalize_id(request.workspace_id or f"personal-{uuid4().hex[:12]}") + email = _normalize_email(request.email) + display_name = request.display_name.strip() + workspace_name = (request.workspace_name or f"{display_name}的个人工作空间").strip() + user_id = f"user-{uuid4().hex[:12]}" + password_hash = hash_password(request.password) + with self._registration_lock: + if tenant_id in self._tenants: + raise IdentityConflictError(f"Workspace already exists: {tenant_id}") + if any( + user.email == email + and self._tenants.get(user.tenant_id, default_tenant()).workspace_type == "personal" + for user in self._users.values() + ): + raise IdentityConflictError("A personal workspace already exists for this email.") + tenant = TenantInfo( + id=tenant_id, + name=workspace_name, + workspace_type="personal", + owner_user_id=user_id, + legal_name=None, status="active", plan=request.plan, region=request.region, settings=request.settings, - created_by=request.created_by, + user_count=1, + created_at=now, + updated_at=now, ) - ) - admin_user = self.create_user( - TenantUserCreateRequest( - tenant_id=tenant.id, - email=request.admin_email, - display_name=request.admin_display_name, + owner = TenantUserItem( + id=user_id, + tenant_id=tenant_id, + email=email, + display_name=display_name, role="admin", status="active", - title=request.admin_title, - department=request.admin_department, - phone=request.admin_phone, - settings={}, - created_by=request.created_by, - ), - tenant_id=tenant.id, + title=None, + department=None, + phone=None, + platform_permissions=[], + settings={"workspace_membership": "owner"}, + invited_at=now, + last_login_at=None, + created_at=now, + updated_at=now, + ) + account = _account_from_user_item(owner) + consents = build_agreement_consents( + agreements, + tenant_id=tenant_id, + user_id=user_id, + source="personal_registration", + client_ip=client_ip, + user_agent=user_agent, + now=now, + ) + global_account, membership = self._ensure_global_account_membership( + owner, + tenant, + is_owner=True, + ) + try: + global_account = self._ensure_global_credential( + global_account, + password=request.password, + ) + except Exception: + self._workspace_memberships.pop(membership.id, None) + if not any( + item.account_id == global_account.id + for item in self._workspace_memberships.values() + ): + self._global_accounts.pop(global_account.id, None) + self._global_credentials.pop(global_account.id, None) + raise + self._tenants[tenant_id] = tenant + self._users[user_id] = owner + self._account_settings[user_id] = account + self._credentials[user_id] = password_hash + self._agreement_consents.update({item.id: item for item in consents}) + return IdentitySession( + tenant=tenant, + user=owner, + account=account, + auth_mode="password", + global_account=global_account, + membership=membership, + ) + + def list_agreement_consents( + self, + *, + tenant_id: str, + user_id: str | None = None, + ) -> AgreementConsentPage: + items = [ + item + for item in self._agreement_consents.values() + if item.tenant_id == tenant_id and (user_id is None or item.user_id == user_id) + ] + items.sort(key=lambda item: (item.agreed_at, item.id), reverse=True) + return AgreementConsentPage(items=items, total=len(items)) + + def _ensure_global_account_membership( + self, + user: TenantUserItem, + tenant: TenantInfo, + *, + is_owner: bool, + ) -> tuple[GlobalAccountInfo, WorkspaceMembershipInfo]: + now = datetime.now(UTC) + global_account = next( + (item for item in self._global_accounts.values() if item.email == user.email), + None, + ) + if global_account is None: + global_account = GlobalAccountInfo( + id=f"global-account-{uuid4().hex[:12]}", + email=user.email, + display_name=user.display_name, + status="active", + email_verified_at=None, + created_at=now, + updated_at=now, + ) + self._global_accounts[global_account.id] = global_account + elif global_account.status != "active": + raise ValueError("Global account is not active.") + membership = next( + ( + item + for item in self._workspace_memberships.values() + if item.tenant_user_id == user.id + or (item.account_id == global_account.id and item.tenant_id == tenant.id) + ), + None, + ) + if membership is None: + membership = WorkspaceMembershipInfo( + id=f"membership-{uuid4().hex[:12]}", + account_id=global_account.id, + tenant_id=tenant.id, + tenant_user_id=user.id, + role=user.role, + status=user.status, + is_owner=is_owner, + joined_at=user.invited_at or user.created_at, + created_at=now, + updated_at=now, + ) + else: + membership = membership.model_copy( + update={ + "role": user.role, + "status": user.status, + "is_owner": is_owner, + "updated_at": now, + } + ) + self._workspace_memberships[membership.id] = membership + return global_account, membership + + def _ensure_global_credential( + self, + account: GlobalAccountInfo, + *, + password: str, + email_verified: bool = False, + replace: bool = False, + ) -> GlobalAccountInfo: + now = datetime.now(UTC) + stored_hash = self._global_credentials.get(account.id) + if stored_hash is not None and not replace: + if not verify_password(password, stored_hash): + raise IdentityConflictError( + "This email already has a different global password; sign in or reset the password first." + ) + elif stored_hash is None: + memberships = [ + membership + for membership in self._workspace_memberships.values() + if membership.account_id == account.id and membership.status != "disabled" + ] + legacy_hashes = [ + self._credentials[membership.tenant_user_id] + for membership in memberships + if membership.tenant_user_id in self._credentials + ] + if len(legacy_hashes) != len(memberships) or any( + not verify_password(password, item) for item in legacy_hashes + ): + raise IdentityConflictError( + "This email has conflicting workspace passwords; verify the email or reset the password first." + ) + if stored_hash is None or replace: + self._global_credentials[account.id] = hash_password(password) + account = account.model_copy( + update={ + "email_verified_at": account.email_verified_at or (now if email_verified else None), + "updated_at": now, + } + ) + self._global_accounts[account.id] = account + return account + + def list_account_workspaces(self, *, tenant_id: str, user_id: str) -> WorkspaceAccessPage: + current_membership = next( + ( + item + for item in self._workspace_memberships.values() + if item.tenant_id == tenant_id and item.tenant_user_id == user_id and item.status == "active" + ), + None, + ) + if current_membership is None: + raise ValueError("Active workspace membership is required.") + global_account = self._global_accounts.get(current_membership.account_id) + if global_account is None or global_account.status != "active": + raise ValueError("Active global account is required.") + items: list[WorkspaceAccessItem] = [] + for membership in self._workspace_memberships.values(): + if membership.account_id != global_account.id or membership.status != "active": + continue + if global_account.email_verified_at is None and membership.tenant_id != tenant_id: + continue + tenant = self._tenants.get(membership.tenant_id) + user = self._users.get(membership.tenant_user_id) + if tenant is None or tenant.status != "active" or user is None or user.status != "active": + continue + user_count = sum(1 for item in self._users.values() if item.tenant_id == tenant.id) + items.append( + WorkspaceAccessItem( + workspace=tenant.model_copy(update={"user_count": user_count}), + membership=membership, + ) + ) + items.sort(key=lambda item: (item.workspace.name, item.workspace.id)) + return WorkspaceAccessPage(items=items, total=len(items), current_tenant_id=tenant_id) + + def switch_workspace(self, *, tenant_id: str, user_id: str, target_tenant_id: str) -> IdentitySession: + workspaces = self.list_account_workspaces(tenant_id=tenant_id, user_id=user_id) + target = next((item for item in workspaces.items if item.workspace.id == target_tenant_id), None) + if target is None: + raise ValueError("Target workspace is not available for this account.") + target_user = self._users[target.membership.tenant_user_id] + account = self._account_settings.get(target_user.id) or _account_from_user_item(target_user) + self._account_settings[target_user.id] = account + return IdentitySession( + tenant=target.workspace, + user=target_user, + account=account, + auth_mode="workspace_switch", + global_account=self._global_accounts[target.membership.account_id], + membership=target.membership, + ) + + def has_platform_permission( + self, + *, + tenant_id: str, + user_id: str, + permission: PlatformPermission = "platform_admin", + ) -> bool: + user = self._users.get(user_id) + return bool( + user is not None + and user.tenant_id == tenant_id + and user.status == "active" + and permission in user.platform_permissions ) - account = self._account_settings[admin_user.id] - if request.admin_password: - self._credentials[admin_user.id] = hash_password(request.admin_password) - return IdentitySession(tenant=tenant, user=admin_user, account=account, auth_mode="password") def login_by_email(self, request: TenantLoginRequest) -> IdentitySession: email = _normalize_email(request.email) @@ -485,26 +1804,55 @@ class InMemoryIdentityRepository: user for user in self._users.values() if user.email == email - and user.status != "disabled" + and user.status == "active" and (request.tenant_id is None or user.tenant_id == request.tenant_id) ] if not matches: verify_password(request.password or "", DUMMY_CREDENTIAL_HASH) raise ValueError("User not found or disabled.") - if len(matches) > 1: - verify_password(request.password or "", DUMMY_CREDENTIAL_HASH) - raise IdentityConflictError("Email belongs to multiple tenants; tenant_id is required.") - user = matches[0] - credential = self._credentials.get(user.id) - if credential is None: - verify_password(request.password or "", DUMMY_CREDENTIAL_HASH) - raise ValueError("Invalid email or password.") - if not verify_password(request.password or "", credential): + global_account = next( + (item for item in self._global_accounts.values() if item.email == email), + None, + ) + global_hash = self._global_credentials.get(global_account.id) if global_account else None + if global_hash is not None: + verified_matches = matches if verify_password(request.password or "", global_hash) else [] + else: + verified_matches = [ + user + for user in matches + if verify_password( + request.password or "", + self._credentials.get(user.id, DUMMY_CREDENTIAL_HASH), + ) + and user.id in self._credentials + ] + if not verified_matches: raise ValueError("Invalid email or password.") + verified_matches.sort( + key=lambda item: ( + self._tenants.get(item.tenant_id, default_tenant()).workspace_type != "personal", + item.tenant_id, + item.id, + ) + ) + user = verified_matches[0] tenant = self.get_current_tenant(user.tenant_id) account = self._account_settings.get(user.id) or _account_from_user_item(user) self._account_settings[user.id] = account - return IdentitySession(tenant=tenant, user=user, account=account, auth_mode="password") + global_account, membership = self._ensure_global_account_membership( + user, + tenant, + is_owner=tenant.owner_user_id == user.id, + ) + return IdentitySession( + tenant=tenant, + user=user, + account=account, + auth_mode="password", + global_account=global_account, + membership=membership, + ) def create_invitation( self, @@ -562,6 +1910,30 @@ class InMemoryIdentityRepository: user = self._users.get(invitation.user_id) if user is None: raise ValueError("Invitation user not found.") + global_account = next( + (item for item in self._global_accounts.values() if item.email == user.email), + None, + ) + if global_account is not None: + global_hash = self._global_credentials.get(global_account.id) + legacy_hashes = [ + self._credentials[membership.tenant_user_id] + for membership in self._workspace_memberships.values() + if membership.account_id == global_account.id + and membership.tenant_user_id != user.id + and membership.tenant_user_id in self._credentials + ] + if ( + global_hash is not None + and not verify_password(request.password, global_hash) + ) or ( + global_hash is None + and legacy_hashes + and any(not verify_password(request.password, item) for item in legacy_hashes) + ): + raise IdentityConflictError( + "This invitation belongs to an existing account; use its current password or reset it first." + ) updated_user = user.model_copy( update={ "display_name": request.display_name.strip() if request.display_name else user.display_name, @@ -583,11 +1955,25 @@ class InMemoryIdentityRepository: } ) self._invitations[invitation.id] = (updated_invitation, stored_hash) + tenant = self.get_current_tenant(updated_user.tenant_id) + global_account, membership = self._ensure_global_account_membership( + updated_user, + tenant, + is_owner=tenant.owner_user_id == updated_user.id, + ) + global_account = self._ensure_global_credential( + global_account, + password=request.password, + email_verified=True, + replace=False, + ) return IdentitySession( - tenant=self.get_current_tenant(updated_user.tenant_id), + tenant=tenant, user=updated_user, account=account, auth_mode="invitation", + global_account=global_account, + membership=membership, ) def send_email_challenge( @@ -741,12 +2127,22 @@ class InMemoryIdentityRepository: ) -> None: del invitation_id, status, error_code - def confirm_email_challenge(self, request: EmailVerificationConfirmRequest) -> EmailVerificationResult: + def confirm_email_challenge( + self, + request: EmailVerificationConfirmRequest, + *, + tenant_id: str | None = None, + user_id: str | None = None, + ) -> EmailVerificationResult: now = datetime.now(UTC) entry = self._email_challenges.get(request.challenge_id) if entry is None: raise ValueError("Email challenge not found.") challenge, code_hash = entry + if tenant_id is not None and challenge.tenant_id != tenant_id: + raise PermissionError("Email challenge is outside the allowed workspace.") + if user_id is not None and challenge.user_id != user_id: + raise PermissionError("Email challenge is outside the allowed account.") if challenge.purpose != "email_verification": raise ValueError("Email challenge purpose is invalid.") if challenge.status != "pending" or challenge.expires_at < now: @@ -763,6 +2159,22 @@ class InMemoryIdentityRepository: raise ValueError("Invalid email code.") updated = challenge.model_copy(update={"status": "verified", "verified_at": now, "dev_code": None, "updated_at": now}) self._email_challenges[updated.id] = (updated, code_hash) + if challenge.user_id: + user = self._users.get(challenge.user_id) + if user is not None: + tenant = self.get_current_tenant(user.tenant_id) + global_account, _ = self._ensure_global_account_membership( + user, + tenant, + is_owner=tenant.owner_user_id == user.id, + ) + stored_hash = self._credentials.get(user.id) + if stored_hash is not None: + self._global_credentials.setdefault(global_account.id, stored_hash) + global_account = global_account.model_copy( + update={"email_verified_at": now, "updated_at": now} + ) + self._global_accounts[global_account.id] = global_account return EmailVerificationResult(verified=True, challenge=updated) def confirm_password_reset(self, request: PasswordResetConfirmRequest) -> IdentitySession: @@ -795,7 +2207,26 @@ class InMemoryIdentityRepository: self._credentials[user.id] = hash_password(request.new_password) account = self._account_settings.get(user.id) or _account_from_user_item(user) self._account_settings[user.id] = account - return IdentitySession(tenant=self.get_current_tenant(user.tenant_id), user=user, account=account, auth_mode="password") + tenant = self.get_current_tenant(user.tenant_id) + global_account, membership = self._ensure_global_account_membership( + user, + tenant, + is_owner=tenant.owner_user_id == user.id, + ) + global_account = self._ensure_global_credential( + global_account, + password=request.new_password, + email_verified=True, + replace=True, + ) + return IdentitySession( + tenant=tenant, + user=user, + account=account, + auth_mode="password", + global_account=global_account, + membership=membership, + ) def get_sso_config(self, *, tenant_id: str = DEFAULT_TENANT_ID, provider: SsoProvider = "saml") -> TenantSsoConfig: now = datetime.now(UTC) @@ -828,17 +2259,40 @@ class InMemoryIdentityRepository: return updated def update_user(self, user_id: str, request: TenantUserUpdateRequest) -> TenantUserItem | None: - item = self._users.get(user_id) - if item is None: - return None - data = request.model_dump(exclude_unset=True) - data.pop("updated_by", None) - if "role" in data and data["role"] is not None: - data["role"] = normalize_role(data["role"]) - data["updated_at"] = datetime.now(UTC) - updated = item.model_copy(update=data) - self._users[user_id] = updated - return updated + with self._platform_admin_lock: + item = self._users.get(user_id) + if item is None: + return None + data = request.model_dump(exclude_unset=True) + data.pop("updated_by", None) + if ( + data.get("status") is not None + and data["status"] != "active" + and item.status == "active" + and "platform_admin" in item.platform_permissions + and sum( + 1 + for user in self._users.values() + if user.status == "active" and "platform_admin" in user.platform_permissions + ) + <= 1 + ): + raise LastPlatformAdministratorError( + "The final active platform administrator cannot be disabled." + ) + if "role" in data and data["role"] is not None: + data["role"] = normalize_role(data["role"]) + data["updated_at"] = datetime.now(UTC) + updated = item.model_copy(update=data) + self._users[user_id] = updated + tenant = self._tenants.get(updated.tenant_id) + if tenant is not None: + self._ensure_global_account_membership( + updated, + tenant, + is_owner=tenant.owner_user_id == updated.id, + ) + return updated def get_account_settings( self, @@ -846,7 +2300,27 @@ class InMemoryIdentityRepository: tenant_id: str = DEFAULT_TENANT_ID, user_id: str = "current-admin", ) -> AccountSettings: - return self._account_settings.get(user_id) or default_account_settings(tenant_id=tenant_id) + current = self._account_settings.get(user_id) or default_account_settings(tenant_id=tenant_id) + user = self._users.get(user_id) + if user is None or user.tenant_id != tenant_id: + return current + membership = next( + ( + item + for item in self._workspace_memberships.values() + if item.tenant_user_id == user.id and item.tenant_id == tenant_id + ), + None, + ) + global_account = self._global_accounts.get(membership.account_id) if membership is not None else None + return current.model_copy( + update={ + "tenant_id": tenant_id, + "user_id": user.id, + "email": global_account.email if global_account is not None else user.email, + "role": normalize_role(user.role), + } + ) def update_account_settings( self, @@ -854,23 +2328,16 @@ class InMemoryIdentityRepository: *, tenant_id: str = DEFAULT_TENANT_ID, user_id: str = "current-admin", + actor: str | None = None, ) -> AccountSettings: - current = self.get_account_settings( - tenant_id=request.tenant_id or tenant_id, - user_id=request.user_id or user_id, - ) + current = self.get_account_settings(tenant_id=tenant_id, user_id=user_id) data = request.model_dump(exclude_unset=True) - data.pop("updated_by", None) - data["tenant_id"] = data.get("tenant_id") or current.tenant_id - data["user_id"] = data.get("user_id") or current.user_id - data["email"] = _normalize_email(data.get("email") or current.email) - data["display_name"] = (data.get("display_name") or current.display_name).strip() - if data.get("role") is not None: - data["role"] = normalize_role(data["role"]) + if "display_name" in data and data["display_name"] is not None: + data["display_name"] = data["display_name"].strip() data["updated_at"] = datetime.now(UTC) updated = current.model_copy(update=data) - self._account_settings[updated.user_id] = updated - return updated + self._account_settings[user_id] = updated + return self.get_account_settings(tenant_id=tenant_id, user_id=user_id) def _normalize_email(email: str) -> str: diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/main.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/main.py index a9c1374c..c2fd0f14 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/main.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/main.py @@ -4,11 +4,23 @@ from fastapi import FastAPI, HTTPException, Request from fastapi.responses import JSONResponse from yuqei_contract_api import __version__ +from yuqei_contract_api.anti_abuse import ( + AntiAbuseGuard, + EgressBudgetLimits, + apply_anti_abuse_headers, + check_egress_budget, + contract_endpoint_risk_policies, + egress_budget_response, + estimate_records_count, + persist_security_event, + rate_limit_response, +) from yuqei_contract_api.api.contracts_core import register_contract_core_routes from yuqei_contract_api.api.files_search_backup import register_files_search_backup_routes from yuqei_contract_api.api.identity import register_identity_routes from yuqei_contract_api.api.legal_assets import register_legal_asset_routes from yuqei_contract_api.api.review import register_review_routes +from yuqei_contract_api.api.security import register_security_routes from yuqei_contract_api.api.support import register_support_routes from yuqei_contract_api.api.system import register_system_routes from yuqei_contract_api.api.templates import register_template_routes @@ -56,6 +68,20 @@ def create_app( app.state.latency_metrics = latency_metrics app.state.error_logs = error_logs app.state.deprecated_alias_usages = deprecated_alias_usages + anti_abuse_guard = AntiAbuseGuard( + mode=resolved_settings.anti_abuse_mode, + service_name=resolved_settings.service_name, + policies=contract_endpoint_risk_policies(resolved_settings.api_prefix), + hash_secret=resolved_settings.anti_abuse_hash_secret, + redis_url=resolved_settings.redis_url, + ) + app.state.anti_abuse_guard = anti_abuse_guard + egress_budget_limits = EgressBudgetLimits( + s3_records_per_day=resolved_settings.anti_abuse_s3_records_daily_limit, + s4_records_per_day=resolved_settings.anti_abuse_s4_records_daily_limit, + files_per_day=resolved_settings.anti_abuse_files_daily_limit, + bytes_per_day=resolved_settings.anti_abuse_bytes_daily_limit, + ) app.state.request_count = request_count app.state.error_count = error_count app.state.start_time = app_start_time @@ -83,6 +109,44 @@ def create_app( set_tenant_context(tenant_id) set_user_context(user_id) set_request_tenant_id(tenant_id) + anti_abuse_decision = await anti_abuse_guard.inspect(request, tenant_id=tenant_id, user_id=user_id) + estimated_records_count = estimate_records_count(request, anti_abuse_decision) + if anti_abuse_decision.decision == "block": + anti_abuse_guard.record( + request, + decision=anti_abuse_decision, + tenant_id=tenant_id, + user_id=user_id, + status_code=429, + trace_id=trace_id, + ) + if anti_abuse_guard.events: + persist_security_event(getattr(repository, "engine", None), anti_abuse_guard.events[-1]) + return rate_limit_response(anti_abuse_decision) + anti_abuse_decision = check_egress_budget( + getattr(repository, "engine", None), + anti_abuse_decision, + tenant_id=tenant_id, + user_id=user_id, + limits=egress_budget_limits, + estimated_records_count=estimated_records_count, + ) + if anti_abuse_decision.decision == "block": + anti_abuse_guard.record( + request, + decision=anti_abuse_decision, + tenant_id=tenant_id, + user_id=user_id, + status_code=403, + trace_id=trace_id, + ) + if anti_abuse_guard.events: + persist_security_event( + getattr(repository, "engine", None), + anti_abuse_guard.events[-1], + records_count=0, + ) + return egress_budget_response(anti_abuse_decision) start_time = time.time() endpoint = request.url.path @@ -115,6 +179,21 @@ def create_app( ) response.headers["X-Trace-Id"] = trace_id + apply_anti_abuse_headers(response, anti_abuse_decision, mode=resolved_settings.anti_abuse_mode) + anti_abuse_guard.record( + request, + decision=anti_abuse_decision, + tenant_id=tenant_id, + user_id=user_id, + status_code=response.status_code, + trace_id=trace_id, + ) + if anti_abuse_guard.events: + persist_security_event( + getattr(repository, "engine", None), + anti_abuse_guard.events[-1], + records_count=estimated_records_count, + ) if response.status_code >= 400: app.state.error_count = len([m for m in latency_metrics if m["status_code"] >= 400]) @@ -155,6 +234,7 @@ def create_app( register_system_routes(app, settings=resolved_settings, repository=repository) register_identity_routes(app, settings=resolved_settings, repository=repository) + register_security_routes(app, settings=resolved_settings, repository=repository) register_support_routes(app, settings=resolved_settings, repository=repository) register_template_routes(app, settings=resolved_settings, repository=repository) register_legal_asset_routes(app, settings=resolved_settings, repository=repository) diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/identity.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/identity.py index f5cb3e3d..3e682e11 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/identity.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/identity.py @@ -4,7 +4,7 @@ from datetime import UTC, datetime, timedelta from typing import Literal from uuid import uuid4 -from sqlalchemy import delete, func, select, text +from sqlalchemy import delete, func, inspect, select, text from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import sessionmaker @@ -16,18 +16,33 @@ from yuqei_contract_api.identity_rate_limit import ( hash_identity_dimension, ) from yuqei_contract_api.identity import ( + AccountConflictCode, + AccountConflictDetail, + AccountConflictPage, + AccountConflictSeverity, + AccountDiagnosticCredentialRecord, + AccountDiagnosticTenantRecord, + AccountDiagnosticUserRecord, AccountSettings, AccountSettingsUpdateRequest, + AgreementConsentItem, + AgreementConsentPage, EmailChallengeItem, EmailChallengePurpose, EmailVerificationConfirmRequest, EmailVerificationResult, EmailVerificationSendRequest, + GlobalAccountInfo, IdentityConflictError, IdentityBootstrapResult, IdentitySession, + LastPlatformAdministratorError, PasswordResetConfirmRequest, PasswordResetRequest, + PersonalRegistrationRequest, + PlatformAdministratorItem, + PlatformAdministratorPage, + PlatformPermission, SsoProvider, TenantCreateRequest, TenantInfo, @@ -46,8 +61,15 @@ from yuqei_contract_api.identity import ( TenantUserPage, TenantUserStatus, TenantUserUpdateRequest, + WorkspaceAccessItem, + WorkspaceAccessPage, + WorkspaceMembershipInfo, + build_account_conflict_details, + build_agreement_consents, default_account_settings, default_tenant, + paginate_account_conflicts, + validate_registration_agreements, ) from yuqei_contract_api.identity_security import ( create_email_code, @@ -61,6 +83,9 @@ from yuqei_contract_api.identity_security import ( from yuqei_contract_api.persistence.models import ( DEFAULT_TENANT_ID, AccountSettingsModel, + GlobalAccountCredentialModel, + GlobalAccountModel, + IdentityAgreementConsentModel, IdentityRateLimitEventModel, TenantEmailChallengeModel, TenantInvitationModel, @@ -68,6 +93,7 @@ from yuqei_contract_api.persistence.models import ( TenantSsoConfigModel, TenantUserCredentialModel, TenantUserModel, + WorkspaceMembershipModel, ) @@ -76,6 +102,7 @@ class IdentityRepository: self._session_factory = session_factory def seed_defaults(self) -> None: + self._ensure_local_workspace_schema() now = datetime.now(UTC) with self._session_factory.begin() as session: tenant = session.get(TenantModel, DEFAULT_TENANT_ID) @@ -83,6 +110,9 @@ class IdentityRepository: session.add( TenantModel( id=DEFAULT_TENANT_ID, + workspace_type="system", + owner_user_id="user-current-admin", + legal_name=None, name="默认租户", status="active", plan="trial", @@ -107,6 +137,7 @@ class IdentityRepository: title="管理员", department="平台运营", phone=None, + platform_permissions=["platform_admin"], settings={}, invited_at=now, last_login_at=None, @@ -161,6 +192,84 @@ class IdentityRepository: updated_at=now, ) ) + session.flush() + tenant = session.get(TenantModel, DEFAULT_TENANT_ID) + user = session.get(TenantUserModel, "user-current-admin") + if tenant is not None and user is not None: + global_account, _ = self._ensure_global_identity(session, user, tenant, now=now) + stored_credential = session.execute( + select(TenantUserCredentialModel).where( + TenantUserCredentialModel.tenant_id == DEFAULT_TENANT_ID, + TenantUserCredentialModel.user_id == user.id, + TenantUserCredentialModel.deleted_at.is_(None), + ) + ).scalar_one_or_none() + if stored_credential is not None: + self._ensure_global_credential_from_hash( + session, + global_account, + password_hash=stored_credential.password_hash, + password_scheme=stored_credential.password_scheme, + password_updated_at=stored_credential.password_updated_at, + now=now, + email_verified=stored_credential.email_verified_at is not None, + ) + + def _ensure_local_workspace_schema(self) -> None: + """Keep developer SQLite databases compatible when create_all sees pre-existing tables.""" + with self._session_factory() as session: + bind = session.get_bind() + if bind.dialect.name != "sqlite": + return + inspector = inspect(bind) + table_names = set(inspector.get_table_names()) + if "tenants" not in table_names or "tenant_users" not in table_names: + return + tenant_columns = {column["name"] for column in inspector.get_columns("tenants")} + user_columns = {column["name"] for column in inspector.get_columns("tenant_users")} + identity_account_columns = ( + {column["name"] for column in inspector.get_columns("identity_accounts")} + if "identity_accounts" in table_names + else set() + ) + with bind.begin() as connection: + if "workspace_type" not in tenant_columns: + connection.exec_driver_sql( + "alter table tenants add column workspace_type varchar(32) not null default 'organization'" + ) + if "owner_user_id" not in tenant_columns: + connection.exec_driver_sql("alter table tenants add column owner_user_id varchar(64)") + if "legal_name" not in tenant_columns: + connection.exec_driver_sql("alter table tenants add column legal_name varchar(255)") + if "platform_permissions" not in user_columns: + connection.exec_driver_sql( + "alter table tenant_users add column platform_permissions json not null default '[]'" + ) + if identity_account_columns and "email_verified_at" not in identity_account_columns: + connection.exec_driver_sql( + "alter table identity_accounts add column email_verified_at datetime" + ) + connection.exec_driver_sql( + "update tenants set workspace_type = 'system', owner_user_id = 'user-current-admin' where id = 'default'" + ) + connection.exec_driver_sql( + "update tenants set legal_name = name where workspace_type = 'organization' and legal_name is null" + ) + connection.exec_driver_sql( + "update tenant_users set platform_permissions = '[\"platform_admin\"]' " + "where tenant_id = 'default' and id = 'user-current-admin'" + ) + connection.exec_driver_sql( + "create index if not exists ix_tenants_workspace_type on tenants (workspace_type)" + ) + connection.exec_driver_sql( + "create index if not exists ix_tenants_owner_user_id on tenants (owner_user_id)" + ) + if identity_account_columns: + connection.exec_driver_sql( + "create index if not exists ix_identity_accounts_email_verified_at " + "on identity_accounts (email_verified_at)" + ) def bootstrap_admin_credential( self, @@ -181,6 +290,9 @@ class IdentityRepository: if tenant is None: tenant = TenantModel( id=tenant_id, + workspace_type="system" if tenant_id == DEFAULT_TENANT_ID else "organization", + owner_user_id=None, + legal_name=company_name.strip() if tenant_id != DEFAULT_TENANT_ID else None, name=company_name.strip(), status="active", plan="enterprise", @@ -220,6 +332,7 @@ class IdentityRepository: title="管理员", department="平台运营", phone=None, + platform_permissions=["platform_admin"] if tenant_id == DEFAULT_TENANT_ID else [], settings={}, invited_at=now, last_login_at=None, @@ -231,6 +344,14 @@ class IdentityRepository: session.add(user) elif normalize_role(user.role) != "admin" or user.status != "active": raise ValueError("Bootstrap account must be an active administrator.") + if tenant_id == DEFAULT_TENANT_ID and "platform_admin" not in (user.platform_permissions or []): + user.platform_permissions = [*(user.platform_permissions or []), "platform_admin"] + user.updated_by = actor + user.updated_at = now + if tenant.owner_user_id is None: + tenant.owner_user_id = user.id + tenant.updated_by = actor + tenant.updated_at = now credential = session.execute( select(TenantUserCredentialModel).where( @@ -271,6 +392,35 @@ class IdentityRepository: ) status = "created" session.flush() + global_account, _ = self._ensure_global_identity(session, user, tenant, now=now) + if force_rotate: + self._ensure_global_credential( + session, + global_account, + password=password, + now=now, + email_verified=True, + actor=actor, + replace=True, + ) + else: + stored_credential = session.execute( + select(TenantUserCredentialModel).where( + TenantUserCredentialModel.tenant_id == tenant_id, + TenantUserCredentialModel.user_id == user.id, + TenantUserCredentialModel.deleted_at.is_(None), + ) + ).scalar_one() + self._ensure_global_credential_from_hash( + session, + global_account, + password_hash=stored_credential.password_hash, + password_scheme=stored_credential.password_scheme, + password_updated_at=stored_credential.password_updated_at, + now=now, + email_verified=True, + ) + session.flush() return IdentityBootstrapResult( status=status, tenant_id=tenant_id, @@ -315,6 +465,9 @@ class IdentityRepository: model = TenantModel( id=tenant_id, name=request.name.strip(), + workspace_type=request.workspace_type, + owner_user_id=request.owner_user_id, + legal_name=request.legal_name, status=request.status, plan=request.plan, region=request.region, @@ -362,6 +515,157 @@ class IdentityRepository: models = session.execute(statement.order_by(TenantUserModel.updated_at.desc(), TenantUserModel.id)).scalars().all() return TenantUserPage(items=[_user_from_model(model) for model in models], total=len(models)) + def list_platform_administrators(self) -> PlatformAdministratorPage: + with self._session_factory() as session: + models = session.execute( + select(TenantUserModel).where(TenantUserModel.deleted_at.is_(None)) + ).scalars().all() + models = [ + model + for model in models + if "platform_admin" in (model.platform_permissions or []) + ] + tenant_ids = {model.tenant_id for model in models} + tenants = { + model.id: model + for model in session.execute( + select(TenantModel).where(TenantModel.id.in_(tenant_ids)) + ).scalars().all() + } if tenant_ids else {} + models.sort( + key=lambda model: ( + model.status != "active", + model.display_name.lower(), + model.id, + ) + ) + items = [ + PlatformAdministratorItem( + user_id=model.id, + tenant_id=model.tenant_id, + tenant_name=tenants[model.tenant_id].name if model.tenant_id in tenants else model.tenant_id, + email=model.email, + display_name=model.display_name, + role=normalize_role(model.role), + status=model.status, + last_login_at=model.last_login_at, + created_at=model.created_at, + updated_at=model.updated_at, + ) + for model in models + ] + return PlatformAdministratorPage( + items=items, + total=len(items), + active_total=sum(1 for item in items if item.status == "active"), + ) + + def list_account_conflicts( + self, + *, + page: int = 1, + page_size: int = 20, + severity: AccountConflictSeverity | None = None, + conflict_code: AccountConflictCode | None = None, + email: str | None = None, + ) -> AccountConflictPage: + return paginate_account_conflicts( + self._account_conflict_details(), + page=page, + page_size=page_size, + severity=severity, + conflict_code=conflict_code, + email=email, + ) + + def get_account_conflict(self, diagnostic_id: str) -> AccountConflictDetail | None: + return next( + ( + item + for item in self._account_conflict_details() + if item.diagnostic_id == diagnostic_id + ), + None, + ) + + def _account_conflict_details(self) -> list[AccountConflictDetail]: + with self._session_factory() as session: + account_models = session.execute(select(GlobalAccountModel)).scalars().all() + user_models = session.execute(select(TenantUserModel)).scalars().all() + membership_models = session.execute(select(WorkspaceMembershipModel)).scalars().all() + tenant_models = session.execute(select(TenantModel)).scalars().all() + global_credential_models = session.execute( + select(GlobalAccountCredentialModel) + ).scalars().all() + legacy_credential_models = session.execute( + select(TenantUserCredentialModel).where( + TenantUserCredentialModel.deleted_at.is_(None) + ) + ).scalars().all() + return build_account_conflict_details( + accounts=[_global_account_from_model(item) for item in account_models], + users=[ + AccountDiagnosticUserRecord( + id=item.id, + tenant_id=item.tenant_id, + email=item.email, + display_name=item.display_name, + role=normalize_role(item.role), + status=item.status, + deleted=item.deleted_at is not None, + updated_at=item.updated_at, + ) + for item in user_models + ], + memberships=[ + _workspace_membership_from_model(item) + for item in membership_models + ], + tenants=[ + AccountDiagnosticTenantRecord( + id=item.id, + owner_user_id=item.owner_user_id, + deleted=item.deleted_at is not None, + updated_at=item.updated_at, + ) + for item in tenant_models + ], + credentials=[ + *[ + AccountDiagnosticCredentialRecord( + id=item.id, + scope="global", + account_id=item.account_id, + tenant_id=None, + user_id=None, + email=None, + status=item.status, + scheme=item.password_scheme, + password_updated_at=item.password_updated_at, + password_reset_required=False, + updated_at=item.updated_at, + ) + for item in global_credential_models + ], + *[ + AccountDiagnosticCredentialRecord( + id=item.id, + scope="legacy", + account_id=None, + tenant_id=item.tenant_id, + user_id=item.user_id, + email=item.email, + status="active", + scheme=item.password_scheme, + password_updated_at=item.password_updated_at, + password_reset_required=item.password_reset_required, + updated_at=item.updated_at, + ) + for item in legacy_credential_models + ], + ], + ) + def create_user(self, request: TenantUserCreateRequest, *, tenant_id: str = DEFAULT_TENANT_ID) -> TenantUserItem: now = datetime.now(UTC) resolved_tenant_id = request.tenant_id or tenant_id @@ -396,11 +700,26 @@ class IdentityRepository: now=now, ) ) + session.flush() + tenant = session.get(TenantModel, resolved_tenant_id) + if tenant is None: + raise ValueError("Workspace not found.") + self._ensure_global_identity(session, model, tenant, now=now) except IntegrityError as exc: raise IdentityConflictError(f"User already exists in tenant: {email}") from exc return _user_from_model(model) - def register_tenant_admin(self, request: TenantRegistrationRequest) -> IdentitySession: + def register_tenant_admin( + self, + request: TenantRegistrationRequest, + *, + client_ip: str | None = None, + user_agent: str | None = None, + ) -> IdentitySession: + agreements = validate_registration_agreements( + request.agreement_acceptances, + registration_kind="organization", + ) now = datetime.now(UTC) tenant_id = _normalize_id(request.tenant_id or f"tenant-{uuid4().hex[:12]}") email = _normalize_email(request.admin_email) @@ -414,6 +733,7 @@ class IdentityRepository: title=request.admin_title, department=request.admin_department, phone=request.admin_phone, + platform_permissions=[], settings={}, invited_at=now, last_login_at=None, @@ -425,6 +745,9 @@ class IdentityRepository: tenant = TenantModel( id=tenant_id, name=request.company_name.strip(), + workspace_type="organization", + owner_user_id=admin_user.id, + legal_name=request.company_name.strip(), status="active", plan=request.plan, region=request.region, @@ -448,8 +771,28 @@ class IdentityRepository: password=request.admin_password, actor=request.created_by, now=now, - email_verified=True, + email_verified=False, ) + consent_models = [ + _agreement_consent_model(item) + for item in build_agreement_consents( + agreements, + tenant_id=tenant_id, + user_id=admin_user.id, + source="organization_registration", + client_ip=client_ip, + user_agent=user_agent, + now=now, + ) + ] + global_account = GlobalAccountModel( + id=f"identity-account-{uuid4().hex[:12]}", + email=email, + display_name=admin_user.display_name, + status="active", + created_at=now, + updated_at=now, + ) try: with self._session_factory.begin() as session: session.add(tenant) @@ -457,22 +800,216 @@ class IdentityRepository: session.add(account) if credential is not None: session.add(credential) + session.add_all(consent_models) session.flush() + global_account, membership = self._ensure_global_identity( + session, + admin_user, + tenant, + now=now, + preferred_account=global_account, + ) + if request.admin_password: + self._ensure_global_credential( + session, + global_account, + password=request.admin_password, + now=now, + email_verified=False, + actor=request.created_by or "organization-registration", + ) return IdentitySession( tenant=_tenant_from_model(tenant, user_count=1), user=_user_from_model(admin_user), account=_account_from_model(account), auth_mode="password" if credential is not None else "legacy_email_session", + global_account=_global_account_from_model(global_account), + membership=_workspace_membership_from_model(membership), ) except IntegrityError as exc: raise IdentityConflictError("Tenant or admin user already exists.") from exc + def register_personal_workspace( + self, + request: PersonalRegistrationRequest, + *, + client_ip: str | None = None, + user_agent: str | None = None, + ) -> IdentitySession: + agreements = validate_registration_agreements( + request.agreement_acceptances, + registration_kind="personal", + ) + now = datetime.now(UTC) + tenant_id = _normalize_id(request.workspace_id or f"personal-{uuid4().hex[:12]}") + email = _normalize_email(request.email) + display_name = request.display_name.strip() + workspace_name = (request.workspace_name or f"{display_name}的个人工作空间").strip() + owner = TenantUserModel( + id=f"user-{uuid4().hex[:12]}", + tenant_id=tenant_id, + email=email, + display_name=display_name, + role="admin", + status="active", + title=None, + department=None, + phone=None, + platform_permissions=[], + settings={"workspace_membership": "owner"}, + invited_at=now, + last_login_at=None, + created_by=request.created_by, + updated_by=request.created_by, + created_at=now, + updated_at=now, + ) + workspace = TenantModel( + id=tenant_id, + name=workspace_name, + workspace_type="personal", + owner_user_id=owner.id, + legal_name=None, + status="active", + plan=request.plan, + region=request.region, + settings=request.settings, + created_by=request.created_by, + updated_by=request.created_by, + created_at=now, + updated_at=now, + ) + account = _account_model_from_user_model( + owner, + account_id=f"account-{uuid4().hex[:12]}", + actor=request.created_by, + now=now, + ) + credential = _credential_model_from_user_model( + owner, + credential_id=f"credential-{uuid4().hex[:12]}", + password=request.password, + actor=request.created_by, + now=now, + email_verified=False, + ) + consent_models = [ + _agreement_consent_model(item) + for item in build_agreement_consents( + agreements, + tenant_id=tenant_id, + user_id=owner.id, + source="personal_registration", + client_ip=client_ip, + user_agent=user_agent, + now=now, + ) + ] + global_account = GlobalAccountModel( + id=f"identity-account-{uuid4().hex[:12]}", + email=email, + display_name=display_name, + status="active", + created_at=now, + updated_at=now, + ) + try: + with self._session_factory.begin() as session: + existing_personal_owner = session.execute( + select(TenantUserModel.id) + .join(TenantModel, TenantModel.id == TenantUserModel.tenant_id) + .where( + TenantUserModel.email == email, + TenantUserModel.deleted_at.is_(None), + TenantModel.workspace_type == "personal", + TenantModel.deleted_at.is_(None), + ) + .limit(1) + ).scalar_one_or_none() + if existing_personal_owner is not None: + raise IdentityConflictError("A personal workspace already exists for this email.") + session.add(workspace) + session.add(owner) + session.add(account) + session.add(credential) + session.add_all(consent_models) + session.flush() + global_account, membership = self._ensure_global_identity( + session, + owner, + workspace, + now=now, + preferred_account=global_account, + ) + self._ensure_global_credential( + session, + global_account, + password=request.password, + now=now, + email_verified=False, + actor=request.created_by or "personal-registration", + ) + return IdentitySession( + tenant=_tenant_from_model(workspace, user_count=1), + user=_user_from_model(owner), + account=_account_from_model(account), + auth_mode="password", + global_account=_global_account_from_model(global_account), + membership=_workspace_membership_from_model(membership), + ) + except IdentityConflictError: + raise + except IntegrityError as exc: + raise IdentityConflictError("Personal workspace or owner already exists.") from exc + + def list_agreement_consents( + self, + *, + tenant_id: str, + user_id: str | None = None, + ) -> AgreementConsentPage: + with self._session_factory() as session: + statement = select(IdentityAgreementConsentModel).where( + IdentityAgreementConsentModel.tenant_id == tenant_id + ) + if user_id is not None: + statement = statement.where(IdentityAgreementConsentModel.user_id == user_id) + models = session.execute( + statement.order_by( + IdentityAgreementConsentModel.agreed_at.desc(), + IdentityAgreementConsentModel.id.desc(), + ) + ).scalars().all() + return AgreementConsentPage( + items=[_agreement_consent_from_model(model) for model in models], + total=len(models), + ) + + def has_platform_permission( + self, + *, + tenant_id: str, + user_id: str, + permission: PlatformPermission = "platform_admin", + ) -> bool: + with self._session_factory() as session: + user = session.get(TenantUserModel, user_id) + if ( + user is not None + and user.tenant_id == tenant_id + and user.status == "active" + and user.deleted_at is None + and permission in (user.platform_permissions or []) + ): + return True + return False + def login_by_email(self, request: TenantLoginRequest) -> IdentitySession: email = _normalize_email(request.email) with self._session_factory.begin() as session: statement = select(TenantUserModel).where( TenantUserModel.email == email, - TenantUserModel.status != "disabled", + TenantUserModel.status == "active", TenantUserModel.deleted_at.is_(None), ) if request.tenant_id: @@ -481,22 +1018,43 @@ class IdentityRepository: if not users: verify_password(request.password or "", DUMMY_CREDENTIAL_HASH) raise ValueError("User not found or disabled.") - if len(users) > 1: - verify_password(request.password or "", DUMMY_CREDENTIAL_HASH) - raise IdentityConflictError("Email belongs to multiple tenants; tenant_id is required.") - user = users[0] - credential = session.execute( - select(TenantUserCredentialModel).where( - TenantUserCredentialModel.tenant_id == user.tenant_id, - TenantUserCredentialModel.user_id == user.id, - TenantUserCredentialModel.deleted_at.is_(None), - ) + global_account = session.execute( + select(GlobalAccountModel).where(GlobalAccountModel.email == email) ).scalar_one_or_none() - if credential is None: - verify_password(request.password or "", DUMMY_CREDENTIAL_HASH) - raise ValueError("Invalid email or password.") - if not verify_password(request.password or "", credential.password_hash): + global_credential = None + if global_account is not None: + global_credential = session.execute( + select(GlobalAccountCredentialModel).where( + GlobalAccountCredentialModel.account_id == global_account.id, + GlobalAccountCredentialModel.status == "active", + ) + ).scalar_one_or_none() + if global_credential is not None: + if not verify_password(request.password or "", global_credential.password_hash): + raise ValueError("Invalid email or password.") + verified_users = users + else: + credentials = { + credential.user_id: credential + for credential in session.execute( + select(TenantUserCredentialModel).where( + TenantUserCredentialModel.user_id.in_([user.id for user in users]), + TenantUserCredentialModel.deleted_at.is_(None), + ) + ).scalars() + } + verified_users = [ + user + for user in users + if verify_password( + request.password or "", + credentials[user.id].password_hash if user.id in credentials else DUMMY_CREDENTIAL_HASH, + ) + and user.id in credentials + ] + if not verified_users: raise ValueError("Invalid email or password.") + user = verified_users[0] tenant = session.get(TenantModel, user.tenant_id) if tenant is None or tenant.deleted_at is not None: raise ValueError("Tenant not found.") @@ -520,11 +1078,132 @@ class IdentityRepository: ) session.add(account) session.flush() + global_account, membership = self._backfill_account_memberships(session, user) return IdentitySession( tenant=_tenant_from_model(tenant, user_count=_count_users(session, tenant.id)), user=_user_from_model(user), account=_account_from_model(account), auth_mode="password", + global_account=_global_account_from_model(global_account), + membership=_workspace_membership_from_model(membership), + ) + + def list_account_workspaces(self, *, tenant_id: str, user_id: str) -> WorkspaceAccessPage: + with self._session_factory.begin() as session: + current_user = session.get(TenantUserModel, user_id) + if ( + current_user is None + or current_user.tenant_id != tenant_id + or current_user.deleted_at is not None + or current_user.status == "disabled" + ): + raise ValueError("Current workspace identity is not available.") + global_account, _ = self._backfill_account_memberships(session, current_user) + if global_account.status != "active": + raise ValueError("Global account is not active.") + access_statement = ( + select(WorkspaceMembershipModel, TenantModel) + .join(TenantModel, TenantModel.id == WorkspaceMembershipModel.tenant_id) + .where( + WorkspaceMembershipModel.account_id == global_account.id, + WorkspaceMembershipModel.status == "active", + TenantModel.status == "active", + TenantModel.deleted_at.is_(None), + ) + .order_by(TenantModel.updated_at.desc(), TenantModel.id) + ) + if global_account.email_verified_at is None: + access_statement = access_statement.where( + WorkspaceMembershipModel.tenant_id == tenant_id + ) + rows = session.execute(access_statement).all() + items = [ + WorkspaceAccessItem( + workspace=_tenant_from_model( + workspace, + user_count=_count_users(session, workspace.id), + ), + membership=_workspace_membership_from_model(membership), + ) + for membership, workspace in rows + ] + return WorkspaceAccessPage( + items=items, + total=len(items), + current_tenant_id=tenant_id, + ) + + def switch_workspace( + self, + *, + tenant_id: str, + user_id: str, + target_tenant_id: str, + ) -> IdentitySession: + with self._session_factory.begin() as session: + current_user = session.get(TenantUserModel, user_id) + if ( + current_user is None + or current_user.tenant_id != tenant_id + or current_user.deleted_at is not None + or current_user.status == "disabled" + ): + raise ValueError("Current workspace identity is not available.") + global_account, _ = self._backfill_account_memberships(session, current_user) + if ( + global_account.status != "active" + or ( + target_tenant_id != tenant_id + and global_account.email_verified_at is None + ) + ): + raise PermissionError("Workspace access is not allowed.") + membership = session.execute( + select(WorkspaceMembershipModel).where( + WorkspaceMembershipModel.account_id == global_account.id, + WorkspaceMembershipModel.tenant_id == target_tenant_id, + WorkspaceMembershipModel.status == "active", + ) + ).scalar_one_or_none() + if membership is None: + raise PermissionError("Workspace access is not allowed.") + target_user = session.get(TenantUserModel, membership.tenant_user_id) + target_workspace = session.get(TenantModel, target_tenant_id) + if ( + target_user is None + or target_user.deleted_at is not None + or target_user.status != "active" + or target_workspace is None + or target_workspace.deleted_at is not None + or target_workspace.status != "active" + ): + raise PermissionError("Workspace access is not allowed.") + account = session.execute( + select(AccountSettingsModel).where( + AccountSettingsModel.tenant_id == target_tenant_id, + AccountSettingsModel.user_id == target_user.id, + AccountSettingsModel.deleted_at.is_(None), + ) + ).scalar_one_or_none() + if account is None: + account = _account_model_from_user_model( + target_user, + account_id=f"account-{uuid4().hex[:12]}", + actor="workspace-switch-backfill", + now=datetime.now(UTC), + ) + session.add(account) + session.flush() + return IdentitySession( + tenant=_tenant_from_model( + target_workspace, + user_count=_count_users(session, target_workspace.id), + ), + user=_user_from_model(target_user), + account=_account_from_model(account), + auth_mode="workspace_switch", + global_account=_global_account_from_model(global_account), + membership=_workspace_membership_from_model(membership), ) def create_invitation( @@ -589,6 +1268,10 @@ class IdentityRepository: session.add(account) session.add(invitation) session.flush() + tenant = session.get(TenantModel, resolved_tenant_id) + if tenant is None: + raise ValueError("Workspace not found.") + self._ensure_global_identity(session, user, tenant, now=now) return _invitation_from_model(invitation, invite_token=token) except IntegrityError as exc: raise IdentityConflictError(f"User already exists in tenant: {user.email}") from exc @@ -689,12 +1372,24 @@ class IdentityRepository: account.role = normalize_role(user.role) account.updated_by = user.id account.updated_at = now + global_account, membership = self._backfill_account_memberships(session, user) + self._ensure_global_credential( + session, + global_account, + password=request.password, + now=now, + email_verified=True, + actor="invitation", + replace=False, + ) session.flush() return IdentitySession( tenant=_tenant_from_model(tenant, user_count=_count_users(session, tenant.id)), user=_user_from_model(user), account=_account_from_model(account), auth_mode="invitation", + global_account=_global_account_from_model(global_account), + membership=_workspace_membership_from_model(membership), ) def send_email_challenge( @@ -864,7 +1559,13 @@ class IdentityRepository: with self._session_factory.begin() as session: session.execute(delete(IdentityRateLimitEventModel).where(*conditions)) - def confirm_email_challenge(self, request: EmailVerificationConfirmRequest) -> EmailVerificationResult: + def confirm_email_challenge( + self, + request: EmailVerificationConfirmRequest, + *, + tenant_id: str | None = None, + user_id: str | None = None, + ) -> EmailVerificationResult: now = datetime.now(UTC) error: str | None = None result: EmailVerificationResult | None = None @@ -872,6 +1573,10 @@ class IdentityRepository: challenge = session.get(TenantEmailChallengeModel, request.challenge_id) if challenge is None or challenge.deleted_at is not None: raise ValueError("Email challenge not found.") + if tenant_id is not None and challenge.tenant_id != tenant_id: + raise PermissionError("Email challenge is outside the allowed workspace.") + if user_id is not None and challenge.user_id != user_id: + raise PermissionError("Email challenge is outside the allowed account.") if challenge.purpose != "email_verification": raise ValueError("Email challenge purpose is invalid.") if challenge.status != "pending" or _as_utc(challenge.expires_at) < now: @@ -905,6 +1610,22 @@ class IdentityRepository: credential.email_verified_at = now credential.updated_by = "email-verification" credential.updated_at = now + user = session.get(TenantUserModel, challenge.user_id) + tenant = session.get(TenantModel, challenge.tenant_id) + if user is not None and tenant is not None: + global_account, _ = self._backfill_account_memberships(session, user) + global_account.email_verified_at = global_account.email_verified_at or now + global_account.updated_at = now + if credential is not None: + self._ensure_global_credential_from_hash( + session, + global_account, + password_hash=credential.password_hash, + password_scheme=credential.password_scheme, + password_updated_at=credential.password_updated_at, + now=now, + email_verified=True, + ) session.flush() result = EmailVerificationResult( verified=True, @@ -995,12 +1716,24 @@ class IdentityRepository: now=now, ) session.add(account) + global_account, membership = self._backfill_account_memberships(session, user) + self._ensure_global_credential( + session, + global_account, + password=request.new_password, + now=now, + email_verified=True, + actor="password-reset", + replace=True, + ) session.flush() result = IdentitySession( tenant=_tenant_from_model(tenant, user_count=_count_users(session, tenant.id)), user=_user_from_model(user), account=_account_from_model(account), auth_mode="password", + global_account=_global_account_from_model(global_account), + membership=_workspace_membership_from_model(membership), ) if error: raise ValueError(error) @@ -1071,12 +1804,44 @@ class IdentityRepository: return None data = request.model_dump(exclude_unset=True) actor = data.pop("updated_by", None) + if ( + data.get("status") is not None + and data["status"] != "active" + and model.status == "active" + and "platform_admin" in (model.platform_permissions or []) + ): + active_users = session.execute( + select(TenantUserModel) + .where( + TenantUserModel.status == "active", + TenantUserModel.deleted_at.is_(None), + ) + .with_for_update() + ).scalars().all() + active_platform_admins = [ + user + for user in active_users + if "platform_admin" in (user.platform_permissions or []) + ] + if len(active_platform_admins) <= 1: + raise LastPlatformAdministratorError( + "The final active platform administrator cannot be disabled." + ) if "role" in data and data["role"] is not None: data["role"] = normalize_role(data["role"]) for field, value in data.items(): setattr(model, field, value) model.updated_by = actor model.updated_at = datetime.now(UTC) + membership = session.execute( + select(WorkspaceMembershipModel).where( + WorkspaceMembershipModel.tenant_user_id == model.id + ) + ).scalar_one_or_none() + if membership is not None: + membership.role = normalize_role(model.role) + membership.status = _membership_status(model.status) + membership.updated_at = model.updated_at session.flush() return _user_from_model(model) @@ -1095,8 +1860,23 @@ class IdentityRepository: ) ).scalar_one_or_none() if model is None: - return default_account_settings(tenant_id=tenant_id) - return _account_from_model(model) + current = default_account_settings(tenant_id=tenant_id).model_copy( + update={"id": f"account-{user_id}", "user_id": user_id} + ) + else: + current = _account_from_model(model) + authoritative_identity = self._account_identity(session, tenant_id=tenant_id, user_id=user_id) + if authoritative_identity is None: + return current + email, role = authoritative_identity + return current.model_copy( + update={ + "tenant_id": tenant_id, + "user_id": user_id, + "email": email, + "role": role, + } + ) def update_account_settings( self, @@ -1104,55 +1884,74 @@ class IdentityRepository: *, tenant_id: str = DEFAULT_TENANT_ID, user_id: str = "current-admin", + actor: str | None = None, ) -> AccountSettings: now = datetime.now(UTC) - resolved_tenant_id = request.tenant_id or tenant_id - resolved_user_id = request.user_id or user_id with self._session_factory.begin() as session: - self._ensure_tenant_exists(session, resolved_tenant_id, actor=request.updated_by) + tenant = session.get(TenantModel, tenant_id) + if tenant is None: + raise ValueError("An active workspace is required.") + authoritative_identity = self._account_identity(session, tenant_id=tenant_id, user_id=user_id) + if authoritative_identity is None and not ( + tenant_id == DEFAULT_TENANT_ID and user_id == "current-admin" + ): + raise ValueError("An active workspace membership is required.") + authoritative_email, authoritative_role = authoritative_identity or ("admin@example.com", "admin") model = session.execute( select(AccountSettingsModel).where( - AccountSettingsModel.tenant_id == resolved_tenant_id, - AccountSettingsModel.user_id == resolved_user_id, + AccountSettingsModel.tenant_id == tenant_id, + AccountSettingsModel.user_id == user_id, AccountSettingsModel.deleted_at.is_(None), ) ).scalar_one_or_none() if model is None: - defaults = default_account_settings(tenant_id=resolved_tenant_id, now=now) + defaults = default_account_settings(tenant_id=tenant_id, now=now) model = AccountSettingsModel( id=f"account-{uuid4().hex[:12]}", - tenant_id=resolved_tenant_id, - user_id=resolved_user_id, - email=request.email or defaults.email, + tenant_id=tenant_id, + user_id=user_id, + email=authoritative_email, display_name=(request.display_name or defaults.display_name).strip(), - role=normalize_role(request.role or defaults.role), + role=authoritative_role, locale=request.locale or defaults.locale, timezone=request.timezone or defaults.timezone, notification_settings=request.notification_settings or defaults.notification_settings, settings=request.settings or defaults.settings, - created_by=request.updated_by, - updated_by=request.updated_by, + created_by=actor, + updated_by=actor, created_at=now, updated_at=now, ) session.add(model) else: data = request.model_dump(exclude_unset=True) - actor = data.pop("updated_by", None) - data.pop("tenant_id", None) - data.pop("user_id", None) - if "email" in data and data["email"] is not None: - data["email"] = _normalize_email(data["email"]) if "display_name" in data and data["display_name"] is not None: data["display_name"] = data["display_name"].strip() - if "role" in data and data["role"] is not None: - data["role"] = normalize_role(data["role"]) for field, value in data.items(): setattr(model, field, value) + model.email = authoritative_email + model.role = authoritative_role model.updated_by = actor model.updated_at = now session.flush() - return _account_from_model(model) + return _account_from_model(model).model_copy( + update={"email": authoritative_email, "role": authoritative_role} + ) + + def _account_identity(self, session, *, tenant_id: str, user_id: str) -> tuple[str, str] | None: + user = session.get(TenantUserModel, user_id) + if user is None or user.tenant_id != tenant_id or user.status != "active": + return None + membership = session.execute( + select(WorkspaceMembershipModel).where( + WorkspaceMembershipModel.tenant_id == tenant_id, + WorkspaceMembershipModel.tenant_user_id == user_id, + WorkspaceMembershipModel.status == "active", + ) + ).scalar_one_or_none() + global_account = session.get(GlobalAccountModel, membership.account_id) if membership is not None else None + email = global_account.email if global_account is not None else user.email + return email, normalize_role(user.role) def _ensure_tenant_exists(self, session, tenant_id: str, *, actor: str | None = None) -> None: tenant = session.get(TenantModel, tenant_id) @@ -1162,6 +1961,9 @@ class IdentityRepository: session.add( TenantModel( id=tenant_id, + workspace_type="system" if tenant_id == DEFAULT_TENANT_ID else "organization", + owner_user_id=None, + legal_name=None, name="默认租户" if tenant_id == DEFAULT_TENANT_ID else tenant_id, status="active", plan="trial" if tenant_id == DEFAULT_TENANT_ID else None, @@ -1174,11 +1976,220 @@ class IdentityRepository: ) ) + def _ensure_global_identity( + self, + session, + user: TenantUserModel, + tenant: TenantModel, + *, + now: datetime | None = None, + preferred_account: GlobalAccountModel | None = None, + ) -> tuple[GlobalAccountModel, WorkspaceMembershipModel]: + resolved_now = now or datetime.now(UTC) + email = _normalize_email(user.email) + global_account = session.execute( + select(GlobalAccountModel).where(GlobalAccountModel.email == email) + ).scalar_one_or_none() + if global_account is None: + global_account = preferred_account or GlobalAccountModel( + id=f"identity-account-{uuid4().hex[:12]}", + email=email, + display_name=user.display_name, + status="active", + created_at=resolved_now, + updated_at=resolved_now, + ) + session.add(global_account) + session.flush() + elif global_account.status != "active": + raise ValueError("Global account is not active.") + else: + global_account.updated_at = resolved_now + + membership = session.execute( + select(WorkspaceMembershipModel).where( + WorkspaceMembershipModel.tenant_user_id == user.id + ) + ).scalar_one_or_none() + if membership is None: + membership = session.execute( + select(WorkspaceMembershipModel).where( + WorkspaceMembershipModel.account_id == global_account.id, + WorkspaceMembershipModel.tenant_id == user.tenant_id, + ) + ).scalar_one_or_none() + if membership is None: + membership = WorkspaceMembershipModel( + id=f"workspace-membership-{uuid4().hex[:12]}", + account_id=global_account.id, + tenant_id=user.tenant_id, + tenant_user_id=user.id, + role=normalize_role(user.role), + status=_membership_status(user.status), + is_owner=tenant.owner_user_id == user.id, + joined_at=user.created_at or resolved_now, + created_at=resolved_now, + updated_at=resolved_now, + ) + session.add(membership) + else: + membership.account_id = global_account.id + membership.tenant_id = user.tenant_id + membership.tenant_user_id = user.id + membership.role = normalize_role(user.role) + membership.status = _membership_status(user.status) + membership.is_owner = tenant.owner_user_id == user.id + membership.updated_at = resolved_now + session.flush() + return global_account, membership + + def _ensure_global_credential( + self, + session, + account: GlobalAccountModel, + *, + password: str, + now: datetime, + email_verified: bool = False, + actor: str = "identity", + replace: bool = False, + ) -> GlobalAccountCredentialModel: + del actor + credential = session.execute( + select(GlobalAccountCredentialModel).where( + GlobalAccountCredentialModel.account_id == account.id, + GlobalAccountCredentialModel.status == "active", + ) + ).scalar_one_or_none() + if credential is not None: + if not replace and not verify_password(password, credential.password_hash): + raise IdentityConflictError( + "This email already has a different global password; sign in or reset the password first." + ) + if replace: + credential.password_hash = hash_password(password) + credential.password_scheme = HASH_SCHEME + credential.password_updated_at = now + credential.updated_at = now + else: + memberships = session.execute( + select(WorkspaceMembershipModel).where( + WorkspaceMembershipModel.account_id == account.id, + WorkspaceMembershipModel.status != "disabled", + ) + ).scalars().all() + legacy_credentials = session.execute( + select(TenantUserCredentialModel) + .join( + WorkspaceMembershipModel, + WorkspaceMembershipModel.tenant_user_id == TenantUserCredentialModel.user_id, + ) + .where( + WorkspaceMembershipModel.account_id == account.id, + TenantUserCredentialModel.deleted_at.is_(None), + ) + ).scalars().all() + if ( + len(legacy_credentials) != len(memberships) + or any(not verify_password(password, item.password_hash) for item in legacy_credentials) + ): + raise IdentityConflictError( + "This email has conflicting workspace passwords; verify the email or reset the password first." + ) + credential = GlobalAccountCredentialModel( + id=f"identity-credential-{uuid4().hex[:12]}", + account_id=account.id, + password_hash=hash_password(password), + password_scheme=HASH_SCHEME, + password_updated_at=now, + status="active", + created_at=now, + updated_at=now, + ) + session.add(credential) + if email_verified and account.email_verified_at is None: + account.email_verified_at = now + account.updated_at = now + account.updated_at = now + credential.updated_at = now + session.flush() + return credential + + def _ensure_global_credential_from_hash( + self, + session, + account: GlobalAccountModel, + *, + password_hash: str, + password_scheme: str, + password_updated_at: datetime, + now: datetime, + email_verified: bool = False, + replace: bool = False, + ) -> GlobalAccountCredentialModel: + credential = session.execute( + select(GlobalAccountCredentialModel).where( + GlobalAccountCredentialModel.account_id == account.id, + GlobalAccountCredentialModel.status == "active", + ) + ).scalar_one_or_none() + if credential is None: + credential = GlobalAccountCredentialModel( + id=f"identity-credential-{uuid4().hex[:12]}", + account_id=account.id, + password_hash=password_hash, + password_scheme=password_scheme, + password_updated_at=password_updated_at, + status="active", + created_at=now, + updated_at=now, + ) + session.add(credential) + elif replace: + credential.password_hash = password_hash + credential.password_scheme = password_scheme + credential.password_updated_at = password_updated_at + credential.updated_at = now + if email_verified: + account.email_verified_at = account.email_verified_at or now + account.updated_at = now + session.flush() + return credential + + def _backfill_account_memberships( + self, + session, + current_user: TenantUserModel, + ) -> tuple[GlobalAccountModel, WorkspaceMembershipModel]: + users = session.execute( + select(TenantUserModel) + .where( + TenantUserModel.email == _normalize_email(current_user.email), + TenantUserModel.deleted_at.is_(None), + ) + .order_by(TenantUserModel.created_at, TenantUserModel.id) + ).scalars().all() + current_membership = None + global_account = None + for user in users: + tenant = session.get(TenantModel, user.tenant_id) + if tenant is None or tenant.deleted_at is not None: + continue + global_account, membership = self._ensure_global_identity(session, user, tenant) + if user.id == current_user.id: + current_membership = membership + if global_account is None or current_membership is None: + raise ValueError("Global account membership could not be resolved.") + return global_account, current_membership + def _tenant_from_model(model: TenantModel, *, user_count: int) -> TenantInfo: return TenantInfo( id=model.id, name=model.name, + workspace_type=model.workspace_type, # type: ignore[arg-type] + owner_user_id=model.owner_user_id, + legal_name=model.legal_name, status=model.status, # type: ignore[arg-type] plan=model.plan, region=model.region, @@ -1200,6 +2211,7 @@ def _user_from_model(model: TenantUserModel) -> TenantUserItem: title=model.title, department=model.department, phone=model.phone, + platform_permissions=list(model.platform_permissions or []), settings=dict(model.settings or {}), invited_at=model.invited_at, last_login_at=model.last_login_at, @@ -1225,6 +2237,41 @@ def _account_from_model(model: AccountSettingsModel) -> AccountSettings: ) +def _global_account_from_model(model: GlobalAccountModel) -> GlobalAccountInfo: + return GlobalAccountInfo( + id=model.id, + email=model.email, + display_name=model.display_name, + status=model.status, # type: ignore[arg-type] + email_verified_at=model.email_verified_at, + created_at=model.created_at, + updated_at=model.updated_at, + ) + + +def _workspace_membership_from_model(model: WorkspaceMembershipModel) -> WorkspaceMembershipInfo: + return WorkspaceMembershipInfo( + id=model.id, + account_id=model.account_id, + tenant_id=model.tenant_id, + tenant_user_id=model.tenant_user_id, + role=normalize_role(model.role), + status=model.status, # type: ignore[arg-type] + is_owner=bool(model.is_owner), + joined_at=model.joined_at, + created_at=model.created_at, + updated_at=model.updated_at, + ) + + +def _membership_status(status: str) -> str: + if status == "invited": + return "invited" + if status == "disabled": + return "disabled" + return "active" + + def _account_model_from_user_model( user: TenantUserModel, *, @@ -1276,6 +2323,36 @@ def _credential_model_from_user_model( ) +def _agreement_consent_model(item: AgreementConsentItem) -> IdentityAgreementConsentModel: + return IdentityAgreementConsentModel( + id=item.id, + tenant_id=item.tenant_id, + user_id=item.user_id, + agreement_type=item.agreement_type, + agreement_version=item.agreement_version, + agreed_at=item.agreed_at, + source=item.source, + client_ip=item.client_ip, + user_agent=item.user_agent, + created_at=item.created_at, + ) + + +def _agreement_consent_from_model(model: IdentityAgreementConsentModel) -> AgreementConsentItem: + return AgreementConsentItem( + id=model.id, + tenant_id=model.tenant_id, + user_id=model.user_id, + agreement_type=model.agreement_type, # type: ignore[arg-type] + agreement_version=model.agreement_version, + agreed_at=model.agreed_at, + source=model.source, # type: ignore[arg-type] + client_ip=model.client_ip, + user_agent=model.user_agent, + created_at=model.created_at, + ) + + def _invitation_from_model(model: TenantInvitationModel, *, invite_token: str | None = None) -> TenantInvitationItem: return TenantInvitationItem( id=model.id, diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/models.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/models.py index 2f6095c8..2acc7be5 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/models.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/models.py @@ -1,8 +1,21 @@ from __future__ import annotations -from datetime import datetime +from datetime import date, datetime -from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint +from sqlalchemy import ( + Boolean, + CheckConstraint, + Date, + DateTime, + Float, + ForeignKey, + Index, + Integer, + JSON, + String, + Text, + UniqueConstraint, +) from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship DEFAULT_TENANT_ID = "default" @@ -20,9 +33,18 @@ class GovernanceMixin: class TenantModel(GovernanceMixin, Base): __tablename__ = "tenants" + __table_args__ = ( + CheckConstraint( + "workspace_type in ('system', 'personal', 'organization')", + name="ck_tenants_workspace_type", + ), + ) id: Mapped[str] = mapped_column(String(64), primary_key=True) name: Mapped[str] = mapped_column(String(255), nullable=False) + workspace_type: Mapped[str] = mapped_column(String(32), nullable=False, index=True, default="organization") + owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + legal_name: Mapped[str | None] = mapped_column(String(255), nullable=True) status: Mapped[str] = mapped_column(String(32), nullable=False, index=True, default="active") plan: Mapped[str | None] = mapped_column(String(64), nullable=True) region: Mapped[str | None] = mapped_column(String(64), nullable=True) @@ -48,6 +70,7 @@ class TenantUserModel(GovernanceMixin, Base): title: Mapped[str | None] = mapped_column(String(128), nullable=True) department: Mapped[str | None] = mapped_column(String(128), nullable=True) phone: Mapped[str | None] = mapped_column(String(64), nullable=True) + platform_permissions: Mapped[list] = mapped_column(JSON, nullable=False, default=list) settings: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) invited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) @@ -97,6 +120,88 @@ class TenantUserCredentialModel(GovernanceMixin, Base): updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) +class IdentityAgreementConsentModel(Base): + __tablename__ = "identity_agreement_consents" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "user_id", + "agreement_type", + "agreement_version", + name="ux_identity_agreement_consents_subject_version", + ), + Index("ix_identity_agreement_consents_tenant_agreed", "tenant_id", "agreed_at"), + Index("ix_identity_agreement_consents_user_agreed", "user_id", "agreed_at"), + ) + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + tenant_id: Mapped[str] = mapped_column(String(64), ForeignKey("tenants.id"), nullable=False, index=True) + user_id: Mapped[str] = mapped_column(String(64), ForeignKey("tenant_users.id"), nullable=False, index=True) + agreement_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + agreement_version: Mapped[str] = mapped_column(String(128), nullable=False, index=True) + agreed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + source: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + client_ip: Mapped[str | None] = mapped_column(String(64), nullable=True) + user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + +class GlobalAccountModel(Base): + __tablename__ = "identity_accounts" + __table_args__ = ( + UniqueConstraint("email", name="ux_identity_accounts_email"), + Index("ix_identity_accounts_status", "status"), + ) + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + email: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + display_name: Mapped[str] = mapped_column(String(128), nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="active") + email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + + +class GlobalAccountCredentialModel(Base): + __tablename__ = "identity_account_credentials" + __table_args__ = ( + UniqueConstraint("account_id", name="ux_identity_account_credentials_account"), + Index("ix_identity_account_credentials_status", "status"), + ) + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + account_id: Mapped[str] = mapped_column( + String(64), ForeignKey("identity_accounts.id"), nullable=False, index=True + ) + password_hash: Mapped[str] = mapped_column(String(512), nullable=False) + password_scheme: Mapped[str] = mapped_column(String(32), nullable=False, default="pbkdf2_sha256") + password_updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="active") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + + +class WorkspaceMembershipModel(Base): + __tablename__ = "workspace_memberships" + __table_args__ = ( + UniqueConstraint("account_id", "tenant_id", name="ux_workspace_memberships_account_tenant"), + UniqueConstraint("tenant_user_id", name="ux_workspace_memberships_tenant_user"), + Index("ix_workspace_memberships_account_status", "account_id", "status"), + Index("ix_workspace_memberships_tenant_status", "tenant_id", "status"), + ) + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + account_id: Mapped[str] = mapped_column(String(64), ForeignKey("identity_accounts.id"), nullable=False, index=True) + tenant_id: Mapped[str] = mapped_column(String(64), ForeignKey("tenants.id"), nullable=False, index=True) + tenant_user_id: Mapped[str] = mapped_column(String(64), ForeignKey("tenant_users.id"), nullable=False, index=True) + role: Mapped[str] = mapped_column(String(32), nullable=False, default="business") + status: Mapped[str] = mapped_column(String(32), nullable=False, default="active") + is_owner: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + joined_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + + class TenantInvitationModel(GovernanceMixin, Base): __tablename__ = "tenant_invitations" __table_args__ = ( @@ -169,6 +274,57 @@ class IdentityRateLimitEventModel(Base): expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) +class SecurityAccessEventModel(Base): + __tablename__ = "security_access_events" + __table_args__ = ( + Index("ix_security_access_events_tenant_created", "tenant_id", "created_at"), + Index("ix_security_access_events_user_created", "user_id", "created_at"), + Index("ix_security_access_events_route_created", "route_key", "created_at"), + Index("ix_security_access_events_decision_created", "decision", "created_at"), + Index("ix_security_access_events_ip_created", "ip_hash", "created_at"), + ) + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True, default=DEFAULT_TENANT_ID) + user_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + session_id_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + ip_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + ua_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) + route_key: Mapped[str] = mapped_column(String(128), nullable=False, index=True) + http_method: Mapped[str] = mapped_column(String(16), nullable=False) + path: Mapped[str] = mapped_column(String(512), nullable=False, default="") + resource_type: Mapped[str | None] = mapped_column(String(64), nullable=True) + resource_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + data_class: Mapped[str] = mapped_column(String(8), nullable=False, index=True) + records_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + bytes_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + decision: Mapped[str] = mapped_column(String(32), nullable=False, index=True) + reason_code: Mapped[str | None] = mapped_column(String(255), nullable=True) + status_code: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + trace_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + + +class DataEgressLedgerModel(Base): + __tablename__ = "data_egress_ledger" + __table_args__ = ( + UniqueConstraint("tenant_id", "user_id", "ledger_date", "data_class", name="ux_data_egress_ledger_subject_day"), + Index("ix_data_egress_ledger_tenant_date", "tenant_id", "ledger_date"), + Index("ix_data_egress_ledger_user_date", "user_id", "ledger_date"), + ) + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True, default=DEFAULT_TENANT_ID) + user_id: Mapped[str] = mapped_column(String(128), nullable=False, default="") + ledger_date: Mapped[date] = mapped_column(Date, nullable=False, index=True) + data_class: Mapped[str] = mapped_column(String(8), nullable=False, index=True) + records_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + files_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + bytes_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + reports_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + + class TenantSsoConfigModel(GovernanceMixin, Base): __tablename__ = "tenant_sso_configs" __table_args__ = ( diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/schema_check.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/schema_check.py index e6badc94..aeb778bb 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/schema_check.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/schema_check.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, Field from sqlalchemy import create_engine, inspect as sqlalchemy_inspect, text from sqlalchemy.engine import Engine +from yuqei_contract_api.anti_abuse import anti_abuse_config_status, contract_endpoint_risk_policies from yuqei_contract_api.display_cache_governance import ( DEFAULT_SAMPLE_LIMIT, DISPLAY_CACHE_CONTEXT_TABLES, @@ -32,7 +33,12 @@ CORE_TABLES = ( "tenant_invitations", "tenant_email_challenges", "tenant_sso_configs", + "identity_accounts", + "identity_account_credentials", + "workspace_memberships", "identity_rate_limit_events", + "security_access_events", + "data_egress_ledger", "contracts", "contract_versions", "approval_instances", @@ -131,6 +137,9 @@ def check_contract_schema( database_url: str, *, environment: str, + anti_abuse_mode: str = "disabled", + redis_url: str = "", + anti_abuse_hash_secret: str = "", engine: Engine | None = None, ) -> RuntimeSchemaStatus: strict = environment in {"staging", "production"} @@ -202,6 +211,14 @@ def check_contract_schema( ) ) checks.append(_check_extensions(connection, dialect=dialect, strict=strict)) + checks.append( + _check_anti_abuse_runtime( + environment=environment, + mode=anti_abuse_mode, + redis_url=redis_url, + hash_secret=anti_abuse_hash_secret, + ) + ) except Exception as exc: # pragma: no cover - exact driver errors vary by environment. checks.append( RuntimeSchemaCheck( @@ -218,6 +235,28 @@ def check_contract_schema( return RuntimeSchemaStatus(status=_aggregate_status(checks), checks=checks) +def _check_anti_abuse_runtime( + *, + environment: str, + mode: str, + redis_url: str, + hash_secret: str, +) -> RuntimeSchemaCheck: + status, message, details = anti_abuse_config_status( + mode=mode, # type: ignore[arg-type] + environment=environment, + redis_url=redis_url, + hash_secret=hash_secret, + policy_count=len(contract_endpoint_risk_policies("/api/v1")), + ) + return RuntimeSchemaCheck( + name="anti_abuse_runtime", + status=status, # type: ignore[arg-type] + message=message, + details=details, + ) + + def contract_schema_head() -> str: migrations_dir = Path(__file__).resolve().parents[2] / "migrations" config = Config() diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/sqlalchemy_file_orchestration.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/sqlalchemy_file_orchestration.py index 0364d157..39143be4 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/sqlalchemy_file_orchestration.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/sqlalchemy_file_orchestration.py @@ -2,6 +2,7 @@ from __future__ import annotations import base64 import io +import os import zipfile from collections.abc import Callable from datetime import UTC, datetime @@ -32,6 +33,10 @@ from yuqei_contract_api.repository_base import ContractWorkflowError from yuqei_contract_api.sqlalchemy_action_context import sync_contract_action_display_cache +DEFAULT_BATCH_DOWNLOAD_MAX_FILES = 20 +DEFAULT_BATCH_DOWNLOAD_MAX_BYTES = 100 * 1024 * 1024 + + class SqlAlchemyFileOrchestration: def __init__( self, @@ -169,6 +174,10 @@ class SqlAlchemyFileOrchestration: requested_ids = list(dict.fromkeys(request.file_ids)) if not requested_ids: return None + max_files = _int_from_env("CONTRACT_API_BATCH_DOWNLOAD_MAX_FILES", DEFAULT_BATCH_DOWNLOAD_MAX_FILES) + max_bytes = _int_from_env("CONTRACT_API_BATCH_DOWNLOAD_MAX_BYTES", DEFAULT_BATCH_DOWNLOAD_MAX_BYTES) + if len(requested_ids) > max_files: + raise ContractWorkflowError(f"Batch download supports at most {max_files} files per request.") now = datetime.now(UTC) tenant_id = self._tenant_id() @@ -184,15 +193,22 @@ class SqlAlchemyFileOrchestration: ordered_rows = [rows_by_id[file_id] for file_id in requested_ids if file_id in rows_by_id] if not ordered_rows: return None + declared_total_size = sum(max(row.file_size or 0, 0) for row in ordered_rows) + if declared_total_size > max_bytes: + raise ContractWorkflowError(f"Batch download total size exceeds {max_bytes} bytes.") archive_entries: list[tuple[str, bytes]] = [] used_names: set[str] = set() downloaded_ids: list[str] = [] + actual_total_size = 0 for row in ordered_rows: try: content = self._files.read_stored_file(row) or b"" except FilePersistenceError as error: raise ContractWorkflowError(str(error)) from error + actual_total_size += len(content) + if actual_total_size > max_bytes: + raise ContractWorkflowError(f"Batch download total size exceeds {max_bytes} bytes.") archive_name = _unique_archive_name(row.file_name or row.id, used_names) archive_entries.append((archive_name, content)) downloaded_ids.append(row.id) @@ -243,3 +259,14 @@ def _unique_archive_name(file_name: str, used_names: set[str]) -> str: used_names.add(resolved) return resolved index += 1 + + +def _int_from_env(name: str, default: int) -> int: + value = os.getenv(name) + if value is None or value.strip() == "": + return default + try: + parsed = int(value) + except ValueError: + return default + return parsed if parsed > 0 else default diff --git a/yuqei-contract-platform/services/contract-api/tests/test_anti_abuse.py b/yuqei-contract-platform/services/contract-api/tests/test_anti_abuse.py new file mode 100644 index 00000000..6ea2d379 --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/tests/test_anti_abuse.py @@ -0,0 +1,191 @@ +import base64 +from datetime import UTC, date, datetime + +from fastapi.testclient import TestClient + +from yuqei_contract_api.config import ContractApiSettings +from yuqei_contract_api.main import create_app +from yuqei_contract_api.persistence.models import DataEgressLedgerModel, SecurityAccessEventModel + + +def make_settings(**overrides) -> ContractApiSettings: + values = { + "environment": "test", + "database_url": "sqlite+pysqlite:///:memory:", + "database_auto_create": True, + "seed_demo_data": True, + "anti_abuse_hash_secret": "test-contract-anti-abuse-secret-0001", + } + values.update(overrides) + return ContractApiSettings(**values) + + +def test_contract_anti_abuse_observe_marks_sensitive_responses() -> None: + app = create_app(make_settings(anti_abuse_mode="observe")) + client = TestClient(app) + + response = client.get("/api/v1/search", params={"keyword": "合同"}) + + assert response.status_code == 200 + assert response.headers["X-Anti-Abuse-Mode"] == "observe" + assert response.headers["X-Anti-Abuse-Decision"] in {"allow", "observe"} + assert response.headers["X-Anti-Abuse-Policy"] == "search.unified" + assert response.headers["X-Data-Class"] == "S2" + with app.state.contract_repository._session_factory() as session: # noqa: SLF001 + assert session.query(SecurityAccessEventModel).count() == 1 + assert session.query(DataEgressLedgerModel).count() == 0 + + +def test_contract_anti_abuse_enforce_blocks_after_policy_limit() -> None: + app = create_app(make_settings(anti_abuse_mode="enforce", redis_url="redis://localhost:6379/0")) + client = TestClient(app) + + last_response = None + for _ in range(12): + last_response = client.get("/api/v1/files/file-missing/download", headers={"X-User-Id": "user-1"}) + + assert last_response is not None + assert last_response.status_code == 429 + assert last_response.headers["X-Anti-Abuse-Decision"] == "block" + assert last_response.headers["X-Anti-Abuse-Policy"] == "files.download" + assert "Retry-After" in last_response.headers + with app.state.contract_repository._session_factory() as session: # noqa: SLF001 + assert session.query(SecurityAccessEventModel).count() >= 1 + assert session.query(DataEgressLedgerModel).count() >= 1 + + +def test_contract_schema_check_reports_anti_abuse_runtime(tmp_path) -> None: + database_path = tmp_path / "contract-api.sqlite3" + app = create_app( + make_settings( + database_url=f"sqlite+pysqlite:///{database_path.as_posix()}", + seed_demo_data=False, + anti_abuse_mode="observe", + ) + ) + client = TestClient(app) + + response = client.get("/api/v1/runtime/schema-check") + + assert response.status_code == 200 + checks = {item["name"]: item for item in response.json()["checks"]} + assert checks["anti_abuse_runtime"]["status"] == "warning" + assert checks["anti_abuse_runtime"]["details"]["mode"] == "observe" + + +def test_contract_security_admin_endpoints_return_events_and_ledger() -> None: + app = create_app(make_settings(anti_abuse_mode="observe")) + client = TestClient(app) + + client.get("/api/v1/files/file-missing/download", headers={"X-User-Id": "user-1"}) + events_response = client.get("/api/v1/security/access-events", headers={"X-User-Id": "admin-user"}) + ledger_response = client.get("/api/v1/security/egress-ledger", headers={"X-User-Id": "admin-user"}) + + assert events_response.status_code == 200 + assert events_response.json()["total"] >= 1 + assert events_response.json()["items"][0]["route_key"] == "files.download" + assert ledger_response.status_code == 200 + assert ledger_response.json()["total"] >= 1 + + +def test_contract_daily_record_budget_blocks_sensitive_detail_path() -> None: + app = create_app(make_settings(anti_abuse_mode="enforce", anti_abuse_s4_records_daily_limit=1)) + client = TestClient(app) + with app.state.contract_repository._session_factory.begin() as session: # noqa: SLF001 + session.add( + DataEgressLedgerModel( + id="egress-ledger-test-records", + tenant_id="default", + user_id="user-1", + ledger_date=date.today(), + data_class="S4", + records_count=1, + files_count=0, + bytes_count=0, + reports_count=0, + updated_at=datetime.now(UTC), + ) + ) + + response = client.get("/api/v1/files/file-missing", headers={"X-User-Id": "user-1"}) + + assert response.status_code == 403 + assert response.headers["X-Anti-Abuse-Reason"] == "daily_records_budget_exceeded" + + +def test_contract_file_download_records_daily_files_and_bytes() -> None: + app = create_app(make_settings(anti_abuse_mode="enforce", anti_abuse_bytes_daily_limit=1024)) + client = TestClient(app) + raw_content = b"contract attachment" + create_response = client.post( + "/api/v1/files", + json={ + "file_name": "attachment.txt", + "file_size": len(raw_content), + "content_type": "text/plain", + "content_base64": base64.b64encode(raw_content).decode("ascii"), + "uploaded_by": "tester", + }, + ) + assert create_response.status_code == 200 + file_id = create_response.json()["id"] + + response = client.get(f"/api/v1/files/{file_id}/download", headers={"X-User-Id": "user-1"}) + + assert response.status_code == 200 + with app.state.contract_repository._session_factory() as session: # noqa: SLF001 + ledger = ( + session.query(DataEgressLedgerModel) + .filter_by(tenant_id="default", user_id="user-1", ledger_date=date.today(), data_class="S4") + .one() + ) + assert ledger.files_count >= 1 + assert ledger.bytes_count >= len(raw_content) + + +def test_contract_file_download_blocks_when_daily_bytes_would_be_exceeded() -> None: + app = create_app(make_settings(anti_abuse_mode="enforce", anti_abuse_bytes_daily_limit=5)) + client = TestClient(app) + raw_content = b"too-large" + create_response = client.post( + "/api/v1/files", + json={ + "file_name": "too-large.txt", + "file_size": len(raw_content), + "content_type": "text/plain", + "content_base64": base64.b64encode(raw_content).decode("ascii"), + "uploaded_by": "tester", + }, + ) + assert create_response.status_code == 200 + file_id = create_response.json()["id"] + + response = client.get(f"/api/v1/files/{file_id}/download", headers={"X-User-Id": "user-1"}) + + assert response.status_code == 403 + assert response.headers["X-Anti-Abuse-Reason"] == "daily_bytes_budget_exceeded" + + +def test_contract_batch_download_enforces_hard_file_limit(monkeypatch) -> None: + monkeypatch.setenv("CONTRACT_API_BATCH_DOWNLOAD_MAX_FILES", "1") + app = create_app(make_settings(anti_abuse_mode="enforce")) + client = TestClient(app) + file_ids = [] + for index in range(2): + create_response = client.post( + "/api/v1/files", + json={ + "file_name": f"attachment-{index}.txt", + "file_size": 4, + "content_type": "text/plain", + "content_base64": base64.b64encode(b"data").decode("ascii"), + "uploaded_by": "tester", + }, + ) + assert create_response.status_code == 200 + file_ids.append(create_response.json()["id"]) + + response = client.post("/api/v1/files/batch-download", json={"file_ids": file_ids}) + + assert response.status_code == 409 + assert "at most 1 files" in response.json()["detail"] diff --git a/yuqei-contract-platform/services/contract-api/tests/test_identity_account_conflicts.py b/yuqei-contract-platform/services/contract-api/tests/test_identity_account_conflicts.py new file mode 100644 index 00000000..217034f5 --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/tests/test_identity_account_conflicts.py @@ -0,0 +1,212 @@ +import json +from datetime import UTC, datetime +from pathlib import Path + +from fastapi.testclient import TestClient +from sqlalchemy import delete, select + +from yuqei_contract_api.anti_abuse import contract_endpoint_risk_policies, match_policy +from yuqei_contract_api.config import ContractApiSettings +from yuqei_contract_api.main import create_app +from yuqei_contract_api.persistence.models import ( + GlobalAccountCredentialModel, + GlobalAccountModel, + WorkspaceMembershipModel, +) + + +AUTH_VALUE = "x" * 48 +REGISTRATION_AGREEMENT_ACCEPTANCES = [ + {"agreement_type": "user_terms", "version": "2026-08-01-draft", "accepted": True}, + {"agreement_type": "privacy_policy", "version": "2026-08-01-draft", "accepted": True}, + {"agreement_type": "service_agreement", "version": "2026-08-01-draft", "accepted": True}, + {"agreement_type": "ai_use_notice", "version": "2026-08-01-draft", "accepted": True}, +] + + +def make_settings(tmp_path: Path) -> ContractApiSettings: + return ContractApiSettings( + environment="staging", + database_url=f"sqlite+pysqlite:///{tmp_path / 'account-conflicts.db'}", + database_auto_create=True, + seed_demo_data=False, + file_storage_dir=str(tmp_path / "files"), + web_proxy_shared_secret=AUTH_VALUE, + require_web_proxy_auth=True, + ) + + +def platform_headers() -> dict[str, str]: + return { + "X-Contract-Proxy-Key": AUTH_VALUE, + "X-Tenant-Id": "default", + "X-User-Id": "user-current-admin", + } + + +def test_account_conflicts_are_platform_only_and_clean_identity_is_not_flagged( + tmp_path: Path, +) -> None: + client = TestClient(create_app(make_settings(tmp_path))) + + clean = client.get( + "/api/v1/identity/account-conflicts", + headers=platform_headers(), + ) + missing_proxy = client.get("/api/v1/identity/account-conflicts") + invalid_page_size = client.get( + "/api/v1/identity/account-conflicts?page_size=101", + headers=platform_headers(), + ) + + assert clean.status_code == 200 + assert clean.json() == { + "items": [], + "total": 0, + "page": 1, + "page_size": 20, + "critical_total": 0, + "warning_total": 0, + } + assert missing_proxy.status_code == 401 + assert invalid_page_size.status_code == 422 + + registered = client.post( + "/api/v1/identity/register-company", + headers={"X-Contract-Proxy-Key": AUTH_VALUE}, + json={ + "tenant_id": "tenant-conflict-boundary", + "company_name": "Conflict Boundary Co", + "admin_email": "owner@conflict-boundary.example", + "admin_display_name": "Conflict Boundary Owner", + "admin_password": "TenantOwnerPassword123", # pragma: allowlist secret + "agreement_acceptances": REGISTRATION_AGREEMENT_ACCEPTANCES, + }, + ) + assert registered.status_code == 200 + tenant_admin_headers = { + "X-Contract-Proxy-Key": AUTH_VALUE, + "X-Tenant-Id": "tenant-conflict-boundary", + "X-User-Id": registered.json()["user"]["id"], + } + forbidden = client.get( + "/api/v1/identity/account-conflicts", + headers=tenant_admin_headers, + ) + assert forbidden.status_code == 403 + + +def test_account_conflict_list_and_detail_detect_corruption_without_hashes( + tmp_path: Path, +) -> None: + app = create_app(make_settings(tmp_path)) + client = TestClient(app) + repository = app.state.contract_repository.identity + now = datetime.now(UTC) + + with repository._session_factory.begin() as session: + default_account = session.execute( + select(GlobalAccountModel).where(GlobalAccountModel.email == "admin@example.com") + ).scalar_one() + session.add( + GlobalAccountModel( + id="identity-account-duplicate-case", + email="ADMIN@EXAMPLE.COM", + display_name="Duplicate Case Account", + status="active", + email_verified_at=None, + created_at=now, + updated_at=now, + ) + ) + global_credential = session.execute( + select(GlobalAccountCredentialModel).where( + GlobalAccountCredentialModel.account_id == default_account.id + ) + ).scalar_one() + global_credential.password_scheme = "unsupported_hash" + membership = session.execute( + select(WorkspaceMembershipModel).where( + WorkspaceMembershipModel.tenant_user_id == "user-current-admin" + ) + ).scalar_one() + membership.role = "observer" + membership.is_owner = False + + response = client.get( + "/api/v1/identity/account-conflicts?email=ADMIN%40EXAMPLE.COM", + headers=platform_headers(), + ) + assert response.status_code == 200 + payload = response.json() + assert payload["total"] == 1 + assert payload["critical_total"] == 1 + item = payload["items"][0] + assert { + "duplicate_global_accounts", + "membership_state_drift", + "owner_flag_drift", + "unsupported_credential_scheme", + }.issubset(set(item["conflict_codes"])) + assert item["diagnostic_id"].startswith("account-conflict-") + assert "admin@example.com" not in item["diagnostic_id"] + + detail = client.get( + f"/api/v1/identity/account-conflicts/{item['diagnostic_id']}", + headers=platform_headers(), + ) + missing = client.get( + "/api/v1/identity/account-conflicts/account-conflict-missing", + headers=platform_headers(), + ) + assert detail.status_code == 200 + assert missing.status_code == 404 + serialized = json.dumps(detail.json(), ensure_ascii=False) + for forbidden_key in ( + "password_hash", + "credential_hash", + "token_hash", + "code_hash", + "dev_code", + ): + assert forbidden_key not in serialized + + +def test_missing_membership_is_stable_and_filterable(tmp_path: Path) -> None: + app = create_app(make_settings(tmp_path)) + client = TestClient(app) + repository = app.state.contract_repository.identity + + with repository._session_factory.begin() as session: + session.execute( + delete(WorkspaceMembershipModel).where( + WorkspaceMembershipModel.tenant_user_id == "user-current-admin" + ) + ) + + first = client.get( + "/api/v1/identity/account-conflicts?conflict_code=missing_membership", + headers=platform_headers(), + ) + second = client.get( + "/api/v1/identity/account-conflicts?severity=critical", + headers=platform_headers(), + ) + + assert first.status_code == second.status_code == 200 + assert first.json()["total"] == 1 + assert first.json()["items"][0]["diagnostic_id"] == second.json()["items"][0]["diagnostic_id"] + assert "missing_membership" in first.json()["items"][0]["conflict_codes"] + + +def test_account_conflicts_use_sensitive_s3_access_policy() -> None: + policy = match_policy( + "GET", + "/api/v1/identity/account-conflicts/account-conflict-example", + contract_endpoint_risk_policies("/api/v1"), + ) + + assert policy is not None + assert policy.route_key == "identity.account_conflicts" + assert policy.data_class == "S3" + assert policy.sensitive is True diff --git a/yuqei-contract-platform/services/contract-api/tests/test_identity_agreement_migration.py b/yuqei-contract-platform/services/contract-api/tests/test_identity_agreement_migration.py new file mode 100644 index 00000000..1be86538 --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/tests/test_identity_agreement_migration.py @@ -0,0 +1,212 @@ +from pathlib import Path + +from alembic import command +from alembic.config import Config +from sqlalchemy import create_engine, inspect, text + +from yuqei_contract_api.config import get_settings + + +SERVICE_ROOT = Path(__file__).resolve().parents[1] + + +def test_agreement_consent_migration_upgrade_and_downgrade(tmp_path: Path, monkeypatch) -> None: + database_path = tmp_path / "agreement-migration.sqlite3" + database_url = f"sqlite+pysqlite:///{database_path.as_posix()}" + monkeypatch.setenv("CONTRACT_API_DATABASE_URL", database_url) + get_settings.cache_clear() + config = Config(str(SERVICE_ROOT / "alembic.ini")) + + try: + command.upgrade(config, "20260801_0033") + engine = create_engine(database_url) + assert "identity_agreement_consents" not in inspect(engine).get_table_names() + + command.upgrade(config, "head") + inspector = inspect(engine) + assert "identity_agreement_consents" in inspector.get_table_names() + columns = {column["name"] for column in inspector.get_columns("identity_agreement_consents")} + assert columns == { + "id", + "tenant_id", + "user_id", + "agreement_type", + "agreement_version", + "agreed_at", + "source", + "client_ip", + "user_agent", + "created_at", + } + unique_constraints = { + item["name"] for item in inspector.get_unique_constraints("identity_agreement_consents") + } + assert "ux_identity_agreement_consents_subject_version" in unique_constraints + + command.downgrade(config, "20260801_0033") + assert "identity_agreement_consents" not in inspect(engine).get_table_names() + + command.upgrade(config, "head") + reupgraded = inspect(engine) + assert "identity_agreement_consents" in reupgraded.get_table_names() + assert {column["name"] for column in reupgraded.get_columns("identity_agreement_consents")} == columns + engine.dispose() + finally: + get_settings.cache_clear() + + +def test_global_account_migration_backfills_shared_email_and_reupgrades(tmp_path: Path, monkeypatch) -> None: + database_path = tmp_path / "global-account-migration.sqlite3" + database_url = f"sqlite+pysqlite:///{database_path.as_posix()}" + monkeypatch.setenv("CONTRACT_API_DATABASE_URL", database_url) + get_settings.cache_clear() + config = Config(str(SERVICE_ROOT / "alembic.ini")) + + try: + command.upgrade(config, "20260801_0034") + engine = create_engine(database_url) + with engine.begin() as connection: + connection.execute( + text( + """ + insert into tenants ( + id, name, workspace_type, owner_user_id, status, settings, + created_at, updated_at + ) values ( + 'personal-migration', 'Migration Personal', 'personal', + 'user-migration-personal', 'active', '{}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """ + ) + ) + connection.execute( + text( + """ + insert into tenant_users ( + id, tenant_id, email, display_name, role, status, settings, + platform_permissions, created_at, updated_at + ) values ( + 'user-migration-lone', 'personal-migration', 'lone@example.com', + 'Lone User', 'business', 'active', '{}', '[]', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """ + ) + ) + connection.execute( + text( + """ + insert into tenant_user_credentials ( + id, tenant_id, user_id, email, password_hash, password_scheme, + password_updated_at, password_reset_required, email_verified_at, + created_at, updated_at + ) values ( + 'credential-migration-lone', 'personal-migration', + 'user-migration-lone', 'lone@example.com', + 'lone-password-hash', 'pbkdf2_sha256', CURRENT_TIMESTAMP, + 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """ + ) + ) + connection.execute( + text( + """ + insert into tenant_users ( + id, tenant_id, email, display_name, role, status, settings, + platform_permissions, created_at, updated_at + ) values ( + 'user-migration-personal', 'personal-migration', ' ADMIN@example.com ', + 'Migration Admin', 'admin', 'active', '{}', '[]', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """ + ) + ) + connection.execute( + text( + """ + insert into tenant_user_credentials ( + id, tenant_id, user_id, email, password_hash, password_scheme, + password_updated_at, password_reset_required, email_verified_at, + created_at, updated_at + ) values ( + 'credential-migration-personal', 'personal-migration', + 'user-migration-personal', 'admin@example.com', + 'migration-password-hash', 'pbkdf2_sha256', CURRENT_TIMESTAMP, + 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """ + ) + ) + + command.upgrade(config, "head") + inspector = inspect(engine) + assert { + "identity_accounts", + "identity_account_credentials", + "workspace_memberships", + }.issubset(inspector.get_table_names()) + assert "email_verified_at" in { + column["name"] for column in inspector.get_columns("identity_accounts") + } + with engine.connect() as connection: + accounts = connection.execute( + text("select id, email from identity_accounts order by email") + ).mappings().all() + memberships = connection.execute( + text( + """ + select m.account_id, m.tenant_id, m.tenant_user_id, m.is_owner + from workspace_memberships m + join identity_accounts a on a.id = m.account_id + where a.email = 'admin@example.com' + order by m.tenant_id + """ + ) + ).mappings().all() + assert [item["email"] for item in accounts].count("admin@example.com") == 1 + assert {item["tenant_id"] for item in memberships} == {"default", "personal-migration"} + assert len({item["account_id"] for item in memberships}) == 1 + personal = next(item for item in memberships if item["tenant_id"] == "personal-migration") + assert personal["tenant_user_id"] == "user-migration-personal" + assert bool(personal["is_owner"]) is True + with engine.connect() as connection: + assert connection.scalar( + text( + """ + select count(*) + from identity_account_credentials c + join identity_accounts a on a.id = c.account_id + where a.email = 'admin@example.com' + """ + ) + ) == 0 + assert connection.scalar( + text( + """ + select count(*) + from identity_account_credentials c + join identity_accounts a on a.id = c.account_id + where a.email = 'lone@example.com' + """ + ) + ) == 1 + assert connection.scalar( + text("select count(*) from identity_accounts where email_verified_at is not null") + ) == 0 + + command.downgrade(config, "20260801_0034") + downgraded = inspect(engine) + assert "identity_accounts" not in downgraded.get_table_names() + assert "identity_account_credentials" not in downgraded.get_table_names() + assert "workspace_memberships" not in downgraded.get_table_names() + + command.upgrade(config, "head") + with engine.connect() as connection: + assert connection.scalar(text("select count(*) from identity_accounts where email = 'admin@example.com'")) == 1 + assert connection.scalar(text("select count(*) from identity_account_credentials")) == 1 + assert connection.scalar(text("select count(*) from workspace_memberships")) == 3 + engine.dispose() + finally: + get_settings.cache_clear() diff --git a/yuqei-contract-platform/services/contract-api/tests/test_identity_management.py b/yuqei-contract-platform/services/contract-api/tests/test_identity_management.py index f7ba09f4..3998b3f8 100644 --- a/yuqei-contract-platform/services/contract-api/tests/test_identity_management.py +++ b/yuqei-contract-platform/services/contract-api/tests/test_identity_management.py @@ -6,6 +6,14 @@ from yuqei_contract_api.config import ContractApiSettings from yuqei_contract_api.main import create_app +REGISTRATION_AGREEMENT_ACCEPTANCES = [ + {"agreement_type": "user_terms", "version": "2026-08-01-draft", "accepted": True}, + {"agreement_type": "privacy_policy", "version": "2026-08-01-draft", "accepted": True}, + {"agreement_type": "service_agreement", "version": "2026-08-01-draft", "accepted": True}, + {"agreement_type": "ai_use_notice", "version": "2026-08-01-draft", "accepted": True}, +] + + def make_settings(tmp_path: Path) -> ContractApiSettings: return ContractApiSettings( environment="test", @@ -25,7 +33,18 @@ def test_identity_management_default_tenant_and_account(tmp_path: Path) -> None: tenants = tenants_response.json() assert tenants["total"] >= 1 assert tenants["items"][0]["id"] == "default" + assert tenants["items"][0]["workspace_type"] == "system" + assert tenants["items"][0]["owner_user_id"] == "user-current-admin" assert tenants["items"][0]["user_count"] >= 1 + identity_repository = app.state.contract_repository.identity + assert identity_repository.has_platform_permission( + tenant_id="default", + user_id="user-current-admin", + ) + assert not identity_repository.has_platform_permission( + tenant_id="default", + user_id="current-admin", + ) account_response = client.get("/api/v1/identity/account") assert account_response.status_code == 200 @@ -35,6 +54,34 @@ def test_identity_management_default_tenant_and_account(tmp_path: Path) -> None: assert account["role"] == "admin" +def test_identity_password_setters_require_twelve_characters(tmp_path: Path) -> None: + app = create_app(make_settings(tmp_path)) + client = TestClient(app) + + company_response = client.post( + "/api/v1/identity/register-company", + json={ + "company_name": "Password Policy Co", + "admin_email": "password-policy@example.com", + "admin_display_name": "Password Owner", + "admin_password": "Short123", + "agreement_acceptances": REGISTRATION_AGREEMENT_ACCEPTANCES, + }, + ) + invitation_response = client.post( + "/api/v1/identity/invitations/accept", + json={"token": "unused-token", "password": "Short123"}, + ) + reset_response = client.post( + "/api/v1/identity/password-reset/confirm", + json={"challenge_id": "unused-challenge", "code": "123456", "new_password": "Short123"}, + ) + + assert company_response.status_code == 422 + assert invitation_response.status_code == 422 + assert reset_response.status_code == 422 + + def test_identity_management_create_tenant_and_user_flow(tmp_path: Path) -> None: app = create_app(make_settings(tmp_path)) client = TestClient(app) @@ -53,6 +100,7 @@ def test_identity_management_create_tenant_and_user_flow(tmp_path: Path) -> None tenant = tenant_response.json() assert tenant["id"] == "tenant-acme" assert tenant["status"] == "active" + assert tenant["workspace_type"] == "organization" user_response = client.post( "/api/v1/identity/users", @@ -88,18 +136,68 @@ def test_identity_management_create_tenant_and_user_flow(tmp_path: Path) -> None assert patched["status"] == "disabled" -def test_identity_register_company_login_and_invite_flow(tmp_path: Path) -> None: +def test_registration_agreement_catalog_is_draft_and_rejects_unknown_versions(tmp_path: Path) -> None: app = create_app(make_settings(tmp_path)) client = TestClient(app) + catalog_response = client.get("/api/v1/identity/agreements/current") + assert catalog_response.status_code == 200 + catalog = catalog_response.json() + assert catalog["total"] == 4 + assert {item["agreement_type"] for item in catalog["items"]} == { + "user_terms", + "privacy_policy", + "service_agreement", + "ai_use_notice", + } + assert all(item["lifecycle_status"] == "draft" for item in catalog["items"]) + assert all(item["is_effective"] is False for item in catalog["items"]) + assert all(item["legal_review_required"] is True for item in catalog["items"]) + assert all(item["effective_at"] is None for item in catalog["items"]) + + missing_acceptances = client.post( + "/api/v1/identity/register-personal", + json={ + "workspace_id": "personal-missing-consent", + "email": "missing-consent@example.com", + "display_name": "Missing Consent", + "password": "MissingConsent123", + }, + ) + unknown_version_acceptances = [dict(item) for item in REGISTRATION_AGREEMENT_ACCEPTANCES] + unknown_version_acceptances[0]["version"] = "client-defined-version" + unknown_version = client.post( + "/api/v1/identity/register-company", + json={ + "tenant_id": "tenant-unknown-agreement", + "company_name": "Unknown Agreement Co", + "admin_email": "unknown-agreement@example.com", + "admin_display_name": "Unknown Agreement", + "admin_password": "UnknownAgreement123", + "agreement_acceptances": unknown_version_acceptances, + }, + ) + + assert missing_acceptances.status_code == 422 + assert unknown_version.status_code == 400 + tenants = client.get("/api/v1/identity/tenants").json()["items"] + assert not any(item["id"] in {"personal-missing-consent", "tenant-unknown-agreement"} for item in tenants) + + +def test_identity_register_company_login_and_invite_flow(tmp_path: Path) -> None: + app = create_app(make_settings(tmp_path)) + client = TestClient(app, client=("127.0.0.1", 50000)) + register_response = client.post( "/api/v1/identity/register-company", + headers={"User-Agent": "identity-consent-test/1.0"}, json={ "tenant_id": "tenant-yuqei", "company_name": "Yuqei Legal Tech", "admin_email": "Founder.Admin@Yuqei.com", "admin_display_name": "Founder Admin", "admin_password": "Founder12345", + "agreement_acceptances": REGISTRATION_AGREEMENT_ACCEPTANCES, "admin_department": "Management", "admin_title": "Tenant Admin", "plan": "trial", @@ -109,6 +207,8 @@ def test_identity_register_company_login_and_invite_flow(tmp_path: Path) -> None assert register_response.status_code == 200 session = register_response.json() assert session["tenant"]["id"] == "tenant-yuqei" + assert session["tenant"]["workspace_type"] == "organization" + assert session["tenant"]["legal_name"] == "Yuqei Legal Tech" assert session["tenant"]["user_count"] == 1 assert session["user"]["tenant_id"] == "tenant-yuqei" assert session["user"]["email"] == "founder.admin@yuqei.com" @@ -117,6 +217,24 @@ def test_identity_register_company_login_and_invite_flow(tmp_path: Path) -> None assert session["account"]["tenant_id"] == "tenant-yuqei" assert session["account"]["user_id"] == session["user"]["id"] + consent_response = client.get( + "/api/v1/identity/agreements/consents", + params={"tenant_id": "tenant-yuqei", "user_id": session["user"]["id"]}, + ) + assert consent_response.status_code == 200 + consents = consent_response.json() + assert consents["total"] == 4 + assert {item["agreement_type"] for item in consents["items"]} == { + "user_terms", + "privacy_policy", + "service_agreement", + "ai_use_notice", + } + assert all(item["agreement_version"] == "2026-08-01-draft" for item in consents["items"]) + assert all(item["source"] == "organization_registration" for item in consents["items"]) + assert all(item["client_ip"] == "127.0.0.1" for item in consents["items"]) + assert all(item["user_agent"] == "identity-consent-test/1.0" for item in consents["items"]) + current_tenant_response = client.get( "/api/v1/identity/tenants/current", headers={"X-Tenant-Id": "tenant-yuqei"}, @@ -186,7 +304,7 @@ def test_identity_register_company_login_and_invite_flow(tmp_path: Path) -> None "/api/v1/identity/invitations/accept", json={ "token": invitation["invite_token"], - "password": "Invite12345", + "password": "InvitePassword123", "display_name": "Activated User", }, ) @@ -199,12 +317,303 @@ def test_identity_register_company_login_and_invite_flow(tmp_path: Path) -> None invited_login_response = client.post( "/api/v1/identity/login", - json={"email": "invited-user@yuqei.com", "password": "Invite12345"}, + json={"email": "invited-user@yuqei.com", "password": "InvitePassword123"}, ) assert invited_login_response.status_code == 200 assert invited_login_response.json()["auth_mode"] == "password" +def test_identity_personal_registration_is_atomic_and_supports_multi_workspace_email(tmp_path: Path) -> None: + app = create_app(make_settings(tmp_path)) + client = TestClient(app) + shared_email = "shared.owner@example.com" + + company_response = client.post( + "/api/v1/identity/register-company", + json={ + "tenant_id": "tenant-shared-email", + "company_name": "Shared Email Co", + "admin_email": shared_email, + "admin_display_name": "Shared Owner", + "admin_password": "UnifiedAccountPassword123", + "agreement_acceptances": REGISTRATION_AGREEMENT_ACCEPTANCES, + }, + ) + assert company_response.status_code == 200 + company_session = company_response.json() + assert company_session["global_account"]["email"] == shared_email + assert company_session["membership"]["tenant_id"] == "tenant-shared-email" + assert company_session["membership"]["is_owner"] is True + assert company_session["global_account"]["email_verified_at"] is None + + conflicting_password = client.post( + "/api/v1/identity/register-personal", + json={ + "workspace_id": "personal-shared-owner-conflict", + "workspace_name": "Conflicting Workspace", + "email": shared_email, + "display_name": "Shared Owner", + "password": "DifferentPassword123", + "agreement_acceptances": REGISTRATION_AGREEMENT_ACCEPTANCES, + }, + ) + assert conflicting_password.status_code == 409 + + personal_response = client.post( + "/api/v1/identity/register-personal", + json={ + "workspace_id": "personal-shared-owner", + "workspace_name": "Shared Owner Workspace", + "email": shared_email, + "display_name": "Shared Owner", + "password": "UnifiedAccountPassword123", + "agreement_acceptances": REGISTRATION_AGREEMENT_ACCEPTANCES, + }, + ) + assert personal_response.status_code == 200 + personal_session = personal_response.json() + assert personal_session["tenant"]["workspace_type"] == "personal" + assert personal_session["tenant"]["legal_name"] is None + assert personal_session["tenant"]["owner_user_id"] == personal_session["user"]["id"] + assert personal_session["tenant"]["user_count"] == 1 + assert personal_session["user"]["role"] == "admin" + assert personal_session["user"]["status"] == "active" + assert personal_session["user"]["settings"]["workspace_membership"] == "owner" + assert personal_session["user"]["platform_permissions"] == [] + assert personal_session["account"]["user_id"] == personal_session["user"]["id"] + assert personal_session["auth_mode"] == "password" + assert personal_session["global_account"]["id"] == company_session["global_account"]["id"] + assert personal_session["membership"]["tenant_id"] == "personal-shared-owner" + assert personal_session["membership"]["is_owner"] is True + personal_consents = client.get( + "/api/v1/identity/agreements/consents", + params={ + "tenant_id": personal_session["tenant"]["id"], + "user_id": personal_session["user"]["id"], + }, + ).json() + assert personal_consents["total"] == 4 + assert all(item["source"] == "personal_registration" for item in personal_consents["items"]) + + workspace_agnostic_login = client.post( + "/api/v1/identity/login", + json={"email": shared_email, "password": "UnifiedAccountPassword123"}, + ) + assert workspace_agnostic_login.status_code == 200 + assert workspace_agnostic_login.json()["tenant"]["id"] == "personal-shared-owner" + assert workspace_agnostic_login.json()["global_account"]["id"] == company_session["global_account"]["id"] + personal_login = client.post( + "/api/v1/identity/login", + json={ + "tenant_id": "personal-shared-owner", + "email": shared_email, + "password": "UnifiedAccountPassword123", + }, + ) + assert personal_login.status_code == 200 + assert personal_login.json()["global_account"]["id"] == company_session["global_account"]["id"] + + unverified_workspaces_response = client.get( + "/api/v1/identity/workspaces", + headers={ + "X-Tenant-Id": "personal-shared-owner", + "X-User-Id": personal_session["user"]["id"], + }, + ) + assert unverified_workspaces_response.status_code == 200 + assert unverified_workspaces_response.json()["total"] == 1 + + unverified_switch = client.post( + "/api/v1/identity/workspaces/switch", + headers={ + "X-Tenant-Id": "personal-shared-owner", + "X-User-Id": personal_session["user"]["id"], + }, + json={"tenant_id": "tenant-shared-email"}, + ) + assert unverified_switch.status_code == 403 + + verification = client.post( + "/api/v1/identity/email-verification/send", + json={"tenant_id": "personal-shared-owner", "email": shared_email}, + ).json() + verified = client.post( + "/api/v1/identity/email-verification/confirm", + json={"challenge_id": verification["id"], "code": verification["dev_code"]}, + ) + assert verified.status_code == 200 + + workspaces_response = client.get( + "/api/v1/identity/workspaces", + headers={ + "X-Tenant-Id": "personal-shared-owner", + "X-User-Id": personal_session["user"]["id"], + }, + ) + assert workspaces_response.status_code == 200 + workspaces = workspaces_response.json() + assert workspaces["current_tenant_id"] == "personal-shared-owner" + assert workspaces["total"] == 2 + assert {item["workspace"]["id"] for item in workspaces["items"]} == { + "tenant-shared-email", + "personal-shared-owner", + } + + switch_response = client.post( + "/api/v1/identity/workspaces/switch", + headers={ + "X-Tenant-Id": "personal-shared-owner", + "X-User-Id": personal_session["user"]["id"], + }, + json={"tenant_id": "tenant-shared-email"}, + ) + assert switch_response.status_code == 200 + switched = switch_response.json() + assert switched["tenant"]["id"] == "tenant-shared-email" + assert switched["user"]["id"] == company_session["user"]["id"] + assert switched["auth_mode"] == "workspace_switch" + assert switched["global_account"]["id"] == company_session["global_account"]["id"] + + reset_challenge = client.post( + "/api/v1/identity/password-reset/request", + json={"tenant_id": "personal-shared-owner", "email": shared_email}, + ).json() + reset = client.post( + "/api/v1/identity/password-reset/confirm", + json={ + "challenge_id": reset_challenge["id"], + "code": reset_challenge["dev_code"], + "new_password": "RotatedGlobalPassword123", + }, + ) + assert reset.status_code == 200 + cross_workspace_login = client.post( + "/api/v1/identity/login", + json={ + "tenant_id": "tenant-shared-email", + "email": shared_email, + "password": "RotatedGlobalPassword123", + }, + ) + assert cross_workspace_login.status_code == 200 + stale_password_login = client.post( + "/api/v1/identity/login", + json={ + "tenant_id": "tenant-shared-email", + "email": shared_email, + "password": "UnifiedAccountPassword123", + }, + ) + assert stale_password_login.status_code == 401 + + forbidden_switch = client.post( + "/api/v1/identity/workspaces/switch", + headers={ + "X-Tenant-Id": "personal-shared-owner", + "X-User-Id": personal_session["user"]["id"], + }, + json={"tenant_id": "default"}, + ) + assert forbidden_switch.status_code == 403 + assert forbidden_switch.json() == {"detail": "Workspace access is not allowed."} + + duplicate_personal = client.post( + "/api/v1/identity/register-personal", + json={ + "workspace_id": "personal-shared-owner-duplicate", + "email": shared_email, + "display_name": "Shared Owner", + "password": "AnotherPassword123", + "agreement_acceptances": REGISTRATION_AGREEMENT_ACCEPTANCES, + }, + ) + assert duplicate_personal.status_code == 409 + + failed_duplicate_id = client.post( + "/api/v1/identity/register-personal", + json={ + "workspace_id": "personal-shared-owner", + "email": "rollback-check@example.com", + "display_name": "Rollback Check", + "password": "RollbackPassword123", + "agreement_acceptances": REGISTRATION_AGREEMENT_ACCEPTANCES, + }, + ) + assert failed_duplicate_id.status_code == 409 + rollback_recovery = client.post( + "/api/v1/identity/register-personal", + json={ + "workspace_id": "personal-rollback-recovery", + "email": "rollback-check@example.com", + "display_name": "Rollback Check", + "password": "RollbackPassword123", + "agreement_acceptances": REGISTRATION_AGREEMENT_ACCEPTANCES, + }, + ) + assert rollback_recovery.status_code == 200 + + +def test_existing_global_account_invitation_cannot_replace_password(tmp_path: Path) -> None: + app = create_app(make_settings(tmp_path)) + client = TestClient(app) + shared_email = "existing.account@example.com" + source = client.post( + "/api/v1/identity/register-company", + json={ + "tenant_id": "tenant-existing-account", + "company_name": "Existing Account Co", + "admin_email": shared_email, + "admin_display_name": "Existing Account", + "admin_password": "ExistingGlobalPassword123", + "agreement_acceptances": REGISTRATION_AGREEMENT_ACCEPTANCES, + }, + ) + target = client.post( + "/api/v1/identity/register-company", + json={ + "tenant_id": "tenant-invitation-target", + "company_name": "Invitation Target Co", + "admin_email": "target.admin@example.com", + "admin_display_name": "Target Admin", + "admin_password": "TargetAdminPassword123", + "agreement_acceptances": REGISTRATION_AGREEMENT_ACCEPTANCES, + }, + ) + assert source.status_code == target.status_code == 200 + invitation = client.post( + "/api/v1/identity/invitations", + headers={ + "X-Tenant-Id": "tenant-invitation-target", + "X-User-Id": target.json()["user"]["id"], + }, + json={ + "tenant_id": "tenant-invitation-target", + "email": shared_email, + "display_name": "Existing Account", + "role": "business", + }, + ) + assert invitation.status_code == 200 + token = invitation.json()["invite_token"] + + conflicting_acceptance = client.post( + "/api/v1/identity/invitations/accept", + json={"token": token, "password": "ReplacementPassword123"}, + ) + assert conflicting_acceptance.status_code == 409 + accepted = client.post( + "/api/v1/identity/invitations/accept", + json={"token": token, "password": "ExistingGlobalPassword123"}, + ) + assert accepted.status_code == 200 + assert accepted.json()["global_account"]["id"] == source.json()["global_account"]["id"] + original_login = client.post( + "/api/v1/identity/login", + json={"email": shared_email, "password": "ExistingGlobalPassword123"}, + ) + assert original_login.status_code == 200 + + def test_identity_management_account_settings_update(tmp_path: Path) -> None: app = create_app(make_settings(tmp_path)) client = TestClient(app) @@ -213,20 +622,39 @@ def test_identity_management_account_settings_update(tmp_path: Path) -> None: "/api/v1/identity/account", json={ "display_name": "平台管理员", - "email": "platform-admin@example.com", - "role": "admin", "timezone": "Asia/Shanghai", "notification_settings": {"email": False, "in_app": True}, - "updated_by": "admin", }, ) assert response.status_code == 200 account = response.json() assert account["display_name"] == "平台管理员" - assert account["email"] == "platform-admin@example.com" + assert account["email"] == "admin@example.com" + assert account["role"] == "admin" assert account["notification_settings"]["email"] is False + email_override = client.patch( + "/api/v1/identity/account", + json={"email": "platform-admin@example.com"}, + ) + role_override = client.patch( + "/api/v1/identity/account", + json={"role": "observer"}, + ) + scope_override = client.patch( + "/api/v1/identity/account", + json={"tenant_id": "other", "user_id": "other-user"}, + ) + assert email_override.status_code == 422 + assert role_override.status_code == 422 + assert scope_override.status_code == 422 + + unchanged = client.get("/api/v1/identity/account").json() + assert unchanged["email"] == "admin@example.com" + assert unchanged["role"] == "admin" + assert unchanged["display_name"] == "平台管理员" + def test_identity_email_verification_flow(tmp_path: Path) -> None: app = create_app(make_settings(tmp_path)) @@ -240,6 +668,7 @@ def test_identity_email_verification_flow(tmp_path: Path) -> None: "admin_email": "email.owner@example.com", "admin_display_name": "Email Owner", "admin_password": "EmailOwner123", + "agreement_acceptances": REGISTRATION_AGREEMENT_ACCEPTANCES, }, ) assert register_response.status_code == 200 @@ -279,6 +708,7 @@ def test_identity_password_reset_flow(tmp_path: Path) -> None: "admin_email": "reset.owner@example.com", "admin_display_name": "Reset Owner", "admin_password": "OldPassword123", + "agreement_acceptances": REGISTRATION_AGREEMENT_ACCEPTANCES, }, ) assert register_response.status_code == 200 diff --git a/yuqei-contract-platform/services/contract-api/tests/test_identity_security.py b/yuqei-contract-platform/services/contract-api/tests/test_identity_security.py index 1db229ff..c35f7742 100644 --- a/yuqei-contract-platform/services/contract-api/tests/test_identity_security.py +++ b/yuqei-contract-platform/services/contract-api/tests/test_identity_security.py @@ -23,6 +23,14 @@ from yuqei_contract_api.persistence.models import ( ) +REGISTRATION_AGREEMENT_ACCEPTANCES = [ + {"agreement_type": "user_terms", "version": "2026-08-01-draft", "accepted": True}, + {"agreement_type": "privacy_policy", "version": "2026-08-01-draft", "accepted": True}, + {"agreement_type": "service_agreement", "version": "2026-08-01-draft", "accepted": True}, + {"agreement_type": "ai_use_notice", "version": "2026-08-01-draft", "accepted": True}, +] + + def make_settings(tmp_path: Path, **overrides) -> ContractApiSettings: values = { "environment": "test", @@ -45,6 +53,7 @@ def register_user(client: TestClient, *, tenant_id: str = "tenant-security", ema "admin_email": email, "admin_display_name": "Security Owner", "admin_password": "SecurePassword123", # pragma: allowlist secret + "agreement_acceptances": REGISTRATION_AGREEMENT_ACCEPTANCES, }, ) assert response.status_code == 200 @@ -178,6 +187,7 @@ def test_production_disabled_email_mode_supports_admin_managed_onboarding(tmp_pa "admin_email": "owner@offline.example.com", "admin_display_name": "Offline Owner", "admin_password": "SecurePassword123", # pragma: allowlist secret + "agreement_acceptances": REGISTRATION_AGREEMENT_ACCEPTANCES, } public_registration = client.post( "/api/v1/identity/register-company", diff --git a/yuqei-contract-platform/services/contract-api/tests/test_proxy_auth.py b/yuqei-contract-platform/services/contract-api/tests/test_proxy_auth.py index e065ac0f..0b739306 100644 --- a/yuqei-contract-platform/services/contract-api/tests/test_proxy_auth.py +++ b/yuqei-contract-platform/services/contract-api/tests/test_proxy_auth.py @@ -1,12 +1,24 @@ from pathlib import Path +import pytest from fastapi.testclient import TestClient from yuqei_contract_api.config import ContractApiSettings, check_runtime_config +from yuqei_contract_api.identity import ( + InMemoryIdentityRepository, + LastPlatformAdministratorError, + TenantUserUpdateRequest, +) from yuqei_contract_api.main import create_app AUTH_VALUE = "x" * 48 +REGISTRATION_AGREEMENT_ACCEPTANCES = [ + {"agreement_type": "user_terms", "version": "2026-08-01-draft", "accepted": True}, + {"agreement_type": "privacy_policy", "version": "2026-08-01-draft", "accepted": True}, + {"agreement_type": "service_agreement", "version": "2026-08-01-draft", "accepted": True}, + {"agreement_type": "ai_use_notice", "version": "2026-08-01-draft", "accepted": True}, +] def make_settings(tmp_path: Path) -> ContractApiSettings: @@ -34,7 +46,14 @@ def test_non_public_requests_require_the_server_side_proxy_key(tmp_path: Path) - missing = client.get("/api/v1/contracts") wrong = client.get("/api/v1/contracts", headers={"X-Contract-Proxy-Key": "wrong"}) - valid = client.get("/api/v1/contracts", headers={"X-Contract-Proxy-Key": AUTH_VALUE}) + valid = client.get( + "/api/v1/contracts", + headers={ + "X-Contract-Proxy-Key": AUTH_VALUE, + "X-Tenant-Id": "default", + "X-User-Id": "user-current-admin", + }, + ) assert missing.status_code == 401 assert wrong.status_code == 401 @@ -68,6 +87,7 @@ def test_authenticated_tenant_cannot_override_identity_scope(tmp_path: Path) -> "admin_email": "owner@secure.example", "admin_display_name": "Tenant Owner", "admin_password": credential_value, + "agreement_acceptances": REGISTRATION_AGREEMENT_ACCEPTANCES, }, ) assert registered.status_code == 200 @@ -83,3 +103,396 @@ def test_authenticated_tenant_cannot_override_identity_scope(tmp_path: Path) -> assert own_scope.status_code == 200 assert foreign_scope.status_code == 403 + + admin_audit = client.get( + "/api/v1/identity/agreements/consents?tenant_id=tenant-secure", + headers=tenant_headers, + ) + assert admin_audit.status_code == 200 + assert admin_audit.json()["total"] == 4 + + observer_response = client.post( + "/api/v1/identity/users", + headers=tenant_headers, + json={ + "email": "observer@secure.example", + "display_name": "Consent Observer", + "role": "observer", + "status": "active", + }, + ) + assert observer_response.status_code == 200 + observer = observer_response.json() + observer_headers = { + **proxy_headers, + "X-Tenant-Id": "tenant-secure", + "X-User-Id": observer["id"], + } + own_consent_audit = client.get( + f"/api/v1/identity/agreements/consents?user_id={observer['id']}", + headers=observer_headers, + ) + workspace_consent_audit = client.get( + "/api/v1/identity/agreements/consents", + headers=observer_headers, + ) + + assert own_consent_audit.status_code == 200 + assert own_consent_audit.json()["total"] == 0 + assert workspace_consent_audit.status_code == 403 + + authoritative_account = client.get("/api/v1/identity/account", headers=observer_headers) + forbidden_identity_fields = client.patch( + "/api/v1/identity/account", + headers=observer_headers, + json={ + "tenant_id": "default", + "user_id": session["user"]["id"], + "email": "forged@example.com", + "role": "admin", + }, + ) + allowed_profile = client.patch( + "/api/v1/identity/account", + headers=observer_headers, + json={"display_name": "Consent Observer Updated", "timezone": "Asia/Shanghai"}, + ) + + assert authoritative_account.status_code == 200 + assert authoritative_account.json()["email"] == observer["email"] + assert authoritative_account.json()["role"] == "observer" + assert forbidden_identity_fields.status_code == 422 + assert allowed_profile.status_code == 200 + assert allowed_profile.json()["email"] == observer["email"] + assert allowed_profile.json()["role"] == "observer" + assert allowed_profile.json()["display_name"] == "Consent Observer Updated" + + +def test_email_verification_is_scoped_and_refreshes_self_session(tmp_path: Path) -> None: + settings = make_settings(tmp_path).model_copy( + update={"identity_challenge_resend_cooldown_seconds": 0} + ) + client = TestClient(create_app(settings)) + proxy_headers = {"X-Contract-Proxy-Key": AUTH_VALUE} + registered = client.post( + "/api/v1/identity/register-company", + headers=proxy_headers, + json={ + "tenant_id": "tenant-email-scope", + "company_name": "Email Scope Tenant", + "admin_email": "owner@email-scope.example", + "admin_display_name": "Workspace Owner", + "admin_password": "OwnerPassword123", + "agreement_acceptances": REGISTRATION_AGREEMENT_ACCEPTANCES, + }, + ) + assert registered.status_code == 200 + owner = registered.json()["user"] + owner_headers = { + **proxy_headers, + "X-Tenant-Id": "tenant-email-scope", + "X-User-Id": owner["id"], + } + admin_response = client.post( + "/api/v1/identity/users", + headers=owner_headers, + json={ + "email": "admin@email-scope.example", + "display_name": "Workspace Admin", + "role": "admin", + "status": "active", + }, + ) + assert admin_response.status_code == 200 + workspace_admin = admin_response.json() + demoted = client.patch( + f"/api/v1/identity/users/{owner['id']}", + headers=owner_headers, + json={"role": "business"}, + ) + assert demoted.status_code == 200 + admin_headers = { + **proxy_headers, + "X-Tenant-Id": "tenant-email-scope", + "X-User-Id": workspace_admin["id"], + } + + forbidden_send = client.post( + "/api/v1/identity/email-verification/send", + headers=owner_headers, + json={"email": workspace_admin["email"]}, + ) + foreign_workspace_send = client.post( + "/api/v1/identity/email-verification/send", + headers=owner_headers, + json={"tenant_id": "default", "email": owner["email"]}, + ) + assert forbidden_send.status_code == 403 + assert foreign_workspace_send.status_code == 403 + + admin_managed = client.post( + "/api/v1/identity/email-verification/send", + headers=admin_headers, + json={"email": owner["email"]}, + ) + assert admin_managed.status_code == 200 + admin_confirmed = client.post( + "/api/v1/identity/email-verification/confirm", + headers=admin_headers, + json={ + "challenge_id": admin_managed.json()["id"], + "code": admin_managed.json()["dev_code"], + }, + ) + assert admin_confirmed.status_code == 200 + assert admin_confirmed.json()["session"] is None + + self_challenge = client.post( + "/api/v1/identity/email-verification/send", + headers=owner_headers, + json={"email": owner["email"]}, + ) + assert self_challenge.status_code == 200 + self_confirmed = client.post( + "/api/v1/identity/email-verification/confirm", + headers=owner_headers, + json={ + "challenge_id": self_challenge.json()["id"], + "code": self_challenge.json()["dev_code"], + }, + ) + assert self_confirmed.status_code == 200 + refreshed_session = self_confirmed.json()["session"] + assert refreshed_session["user"]["id"] == owner["id"] + assert refreshed_session["tenant"]["id"] == "tenant-email-scope" + assert refreshed_session["global_account"]["email_verified_at"] is not None + + admin_challenge = client.post( + "/api/v1/identity/email-verification/send", + headers=admin_headers, + json={"email": workspace_admin["email"]}, + ) + assert admin_challenge.status_code == 200 + cross_account_confirm = client.post( + "/api/v1/identity/email-verification/confirm", + headers=owner_headers, + json={ + "challenge_id": admin_challenge.json()["id"], + "code": admin_challenge.json()["dev_code"], + }, + ) + assert cross_account_confirm.status_code == 403 + admin_self_confirmed = client.post( + "/api/v1/identity/email-verification/confirm", + headers=admin_headers, + json={ + "challenge_id": admin_challenge.json()["id"], + "code": admin_challenge.json()["dev_code"], + }, + ) + assert admin_self_confirmed.status_code == 200 + + +def test_platform_admin_api_uses_explicit_permission_claim(tmp_path: Path) -> None: + client = TestClient(create_app(make_settings(tmp_path))) + proxy_headers = {"X-Contract-Proxy-Key": AUTH_VALUE, "X-Tenant-Id": "default"} + + explicit_admin = client.post( + "/api/v1/identity/tenants", + headers={**proxy_headers, "X-User-Id": "user-current-admin"}, + json={"id": "tenant-explicit-platform-admin", "name": "Explicit Admin Co"}, + ) + legacy_id_only = client.post( + "/api/v1/identity/tenants", + headers={**proxy_headers, "X-User-Id": "current-admin"}, + json={"id": "tenant-legacy-id-only", "name": "Legacy ID Co"}, + ) + + assert explicit_admin.status_code == 200 + assert legacy_id_only.status_code == 403 + + +def test_platform_admin_roster_and_final_admin_guard(tmp_path: Path) -> None: + app = create_app(make_settings(tmp_path)) + identity_repository = app.state.contract_repository.identity + second_admin = identity_repository.bootstrap_admin_credential( + tenant_id="default", + company_name="Platform Operations", + email="second-platform-admin@example.com", + display_name="Second Platform Admin", + password="SecondAdminPassword123", # pragma: allowlist secret + ) + assert second_admin.status == "created" + + client = TestClient(app) + proxy_headers = {"X-Contract-Proxy-Key": AUTH_VALUE} + first_admin_headers = { + **proxy_headers, + "X-Tenant-Id": "default", + "X-User-Id": "user-current-admin", + } + second_admin_headers = { + **proxy_headers, + "X-Tenant-Id": "default", + "X-User-Id": second_admin.user_id, + } + + roster = client.get( + "/api/v1/identity/platform-administrators", + headers=first_admin_headers, + ) + assert roster.status_code == 200 + assert roster.json()["total"] == 2 + assert roster.json()["active_total"] == 2 + assert {item["user_id"] for item in roster.json()["items"]} == { + "user-current-admin", + second_admin.user_id, + } + assert all("password" not in item and "credential" not in item for item in roster.json()["items"]) + + tenant_admin_session = client.post( + "/api/v1/identity/register-company", + headers=proxy_headers, + json={ + "tenant_id": "tenant-platform-boundary", + "company_name": "Tenant Boundary Co", + "admin_email": "owner@tenant-boundary.example", + "admin_display_name": "Tenant Boundary Owner", + "admin_password": "TenantOwnerPassword123", + "agreement_acceptances": REGISTRATION_AGREEMENT_ACCEPTANCES, + }, + ) + assert tenant_admin_session.status_code == 200 + tenant_admin = tenant_admin_session.json()["user"] + tenant_admin_headers = { + **proxy_headers, + "X-Tenant-Id": "tenant-platform-boundary", + "X-User-Id": tenant_admin["id"], + } + assert client.get( + "/api/v1/identity/platform-administrators", + headers=tenant_admin_headers, + ).status_code == 403 + assert client.patch( + f"/api/v1/identity/users/{second_admin.user_id}", + headers=tenant_admin_headers, + json={"status": "disabled"}, + ).status_code == 403 + + first_login_before_rotation = client.post( + "/api/v1/identity/login", + headers=proxy_headers, + json={ + "tenant_id": "default", + "email": "admin@example.com", + "password": "admin654321", # pragma: allowlist secret + }, + ) + second_login_before_rotation = client.post( + "/api/v1/identity/login", + headers=proxy_headers, + json={ + "tenant_id": "default", + "email": "second-platform-admin@example.com", + "password": "SecondAdminPassword123", # pragma: allowlist secret + }, + ) + assert first_login_before_rotation.status_code == 200 + assert second_login_before_rotation.status_code == 200 + + rotated = identity_repository.bootstrap_admin_credential( + tenant_id="default", + company_name="Platform Operations", + email="second-platform-admin@example.com", + display_name="Second Platform Admin", + password="SecondAdminRotated123", # pragma: allowlist secret + force_rotate=True, + ) + assert rotated.status == "rotated" + assert client.post( + "/api/v1/identity/login", + headers=proxy_headers, + json={ + "tenant_id": "default", + "email": "second-platform-admin@example.com", + "password": "SecondAdminPassword123", # pragma: allowlist secret + }, + ).status_code == 401 + assert client.post( + "/api/v1/identity/login", + headers=proxy_headers, + json={ + "tenant_id": "default", + "email": "second-platform-admin@example.com", + "password": "SecondAdminRotated123", # pragma: allowlist secret + }, + ).status_code == 200 + assert client.post( + "/api/v1/identity/login", + headers=proxy_headers, + json={ + "tenant_id": "default", + "email": "admin@example.com", + "password": "admin654321", # pragma: allowlist secret + }, + ).status_code == 200 + + disabled = client.patch( + f"/api/v1/identity/users/{second_admin.user_id}", + headers=first_admin_headers, + json={"status": "disabled", "updated_by": "user-current-admin"}, + ) + assert disabled.status_code == 200 + assert disabled.json()["status"] == "disabled" + + lost_platform_access = client.post( + "/api/v1/identity/tenants", + headers=second_admin_headers, + json={"id": "tenant-disabled-admin", "name": "Disabled Admin Tenant"}, + ) + assert lost_platform_access.status_code == 403 + + final_admin_deactivation = client.patch( + "/api/v1/identity/users/user-current-admin", + headers=first_admin_headers, + json={"status": "disabled", "updated_by": "user-current-admin"}, + ) + assert final_admin_deactivation.status_code == 409 + assert final_admin_deactivation.json() == { + "detail": "The final active platform administrator cannot be disabled." + } + + final_roster = client.get( + "/api/v1/identity/platform-administrators", + headers=first_admin_headers, + ) + assert final_roster.status_code == 200 + assert final_roster.json()["total"] == 2 + assert final_roster.json()["active_total"] == 1 + statuses = {item["user_id"]: item["status"] for item in final_roster.json()["items"]} + assert statuses == { + "user-current-admin": "active", + second_admin.user_id: "disabled", + } + + +def test_in_memory_identity_repository_protects_final_platform_admin() -> None: + identity_repository = InMemoryIdentityRepository() + + roster = identity_repository.list_platform_administrators() + assert roster.total == roster.active_total == 1 + assert roster.items[0].user_id == "user-current-admin" + + with pytest.raises( + LastPlatformAdministratorError, + match="final active platform administrator", + ): + identity_repository.update_user( + "user-current-admin", + TenantUserUpdateRequest(status="disabled"), + ) + + assert identity_repository.has_platform_permission( + tenant_id="default", + user_id="user-current-admin", + ) diff --git a/yuqei-contract-platform/services/contract-api/tests/test_server_authorization.py b/yuqei-contract-platform/services/contract-api/tests/test_server_authorization.py new file mode 100644 index 00000000..8fd497be --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/tests/test_server_authorization.py @@ -0,0 +1,198 @@ +from pathlib import Path + +from fastapi.testclient import TestClient + +from yuqei_contract_api.config import ContractApiSettings +from yuqei_contract_api.main import create_app + + +PROXY_KEY = "p" * 48 + + +def make_settings(tmp_path: Path) -> ContractApiSettings: + return ContractApiSettings( + environment="staging", + database_url=f"sqlite+pysqlite:///{tmp_path / 'authorization.db'}", + database_auto_create=True, + seed_demo_data=False, + file_storage_dir=str(tmp_path / "files"), + web_proxy_shared_secret=PROXY_KEY, + require_web_proxy_auth=True, + ) + + +def _provision_users(client: TestClient) -> tuple[dict[str, str], dict[str, dict[str, str]]]: + proxy_headers = {"X-Contract-Proxy-Key": PROXY_KEY} + admin_headers = { + **proxy_headers, + "X-Tenant-Id": "default", + "X-User-Id": "user-current-admin", + } + users: dict[str, dict[str, str]] = {} + for role in ("business", "approver", "observer", "legal"): + created = client.post( + "/api/v1/identity/users", + headers=admin_headers, + json={ + "email": f"{role}@authorization.example", + "display_name": role.title(), + "role": role, + "status": "active", + }, + ) + assert created.status_code == 200 + users[role] = { + **proxy_headers, + "X-Tenant-Id": "default", + "X-User-Id": created.json()["id"], + } + return admin_headers, users + + +def test_authenticated_roles_cannot_cross_contract_write_boundaries(tmp_path: Path) -> None: + client = TestClient(create_app(make_settings(tmp_path))) + admin_headers, users = _provision_users(client) + created = client.post( + "/api/v1/contracts", + headers=admin_headers, + json={"title": "Server authorization contract"}, + ) + assert created.status_code == 200 + contract_id = created.json()["id"] + + business_approval = client.post( + f"/api/v1/contracts/{contract_id}/approval/decision", + headers=users["business"], + params={"role": "admin"}, + json={"decision": "approve", "actor_name": "Business"}, + ) + approver_edit = client.patch( + f"/api/v1/contracts/{contract_id}", + headers=users["approver"], + params={"role": "admin"}, + json={"title": "Unauthorized edit"}, + ) + observer_upload = client.post( + "/api/v1/files", + headers=users["observer"], + params={"role": "admin"}, + json={"file_name": "unauthorized.txt", "content_base64": "dGVzdA=="}, + ) + observer_review = client.post( + f"/api/v1/contracts/{contract_id}/review", + headers=users["observer"], + json={}, + ) + business_archive = client.post( + f"/api/v1/contracts/{contract_id}/archive", + headers=users["business"], + json={"archived_by": "Business"}, + ) + observer_download = client.get( + f"/api/v1/contracts/{contract_id}/download", + headers=users["observer"], + params={"role": "admin"}, + ) + + assert business_approval.status_code == 403 + assert approver_edit.status_code == 403 + assert observer_upload.status_code == 403 + assert observer_review.status_code == 403 + assert business_archive.status_code == 403 + assert observer_download.status_code == 403 + + +def test_authenticated_role_query_cannot_escalate_membership(tmp_path: Path) -> None: + client = TestClient(create_app(make_settings(tmp_path))) + admin_headers, users = _provision_users(client) + created = client.post( + "/api/v1/contracts", + headers=admin_headers, + json={"title": "Role spoofing contract"}, + ) + contract_id = created.json()["id"] + + detail = client.get( + f"/api/v1/contracts/{contract_id}", + headers=users["observer"], + params={"role": "admin"}, + ) + workspace = client.get( + "/api/v1/workspace", + headers=users["observer"], + params={"role": "admin"}, + ) + publish = client.post( + "/api/v1/templates/template-employment/publish", + headers=users["business"], + params={"role": "admin"}, + json={}, + ) + publish_rule = client.post( + "/api/v1/review-rules/review-rule-placeholder/publish", + headers=users["business"], + params={"role": "admin"}, + json={}, + ) + + assert detail.status_code == 200 + assert detail.json()["permissions"]["role"] == "observer" + assert detail.json()["permissions"]["can_edit"] is False + assert workspace.status_code == 200 + assert publish.status_code == 403 + assert publish_rule.status_code == 403 + + +def test_proxy_authenticated_business_routes_require_verifiable_user_context(tmp_path: Path) -> None: + client = TestClient(create_app(make_settings(tmp_path))) + proxy_headers = {"X-Contract-Proxy-Key": PROXY_KEY} + + missing_user = client.get("/api/v1/contracts", headers=proxy_headers) + unknown_user = client.get( + "/api/v1/contracts", + headers={**proxy_headers, "X-Tenant-Id": "default", "X-User-Id": "not-a-member"}, + ) + + assert missing_user.status_code == 401 + assert unknown_user.status_code == 403 + + +def test_restricted_operational_capabilities_are_enforced_by_role(tmp_path: Path) -> None: + client = TestClient(create_app(make_settings(tmp_path))) + _, users = _provision_users(client) + + attempts = [ + client.post( + "/api/v1/backup", + headers=users["legal"], + json={"backup_type": "full", "include_files": False}, + ), + client.post( + "/api/v1/review-worker/run-once", + headers=users["legal"], + json={"worker_name": "unauthorized-worker"}, + ), + client.post( + "/api/v1/approval-rules", + headers=users["business"], + json={ + "rule_type": "contract_type", + "rule_label": "Unauthorized rule", + "operator": "contains", + "value": "service", + "enabled": True, + }, + ), + client.post( + "/api/v1/contracts", + headers=users["approver"], + json={"title": "Unauthorized approver draft"}, + ), + client.post( + "/api/v1/templates/template-employment/publish", + headers=users["observer"], + json={}, + ), + ] + + assert [response.status_code for response in attempts] == [403, 403, 403, 403, 403] diff --git a/yuqei-infra/compose/.env.example b/yuqei-infra/compose/.env.example index 44bf3244..a298b05c 100644 --- a/yuqei-infra/compose/.env.example +++ b/yuqei-infra/compose/.env.example @@ -34,6 +34,14 @@ CONTRACT_API_READ_CACHE_TTL_SECONDS=2 CONTRACT_API_REVIEW_WORKER_NAME=contract-review-worker-1 CONTRACT_API_REVIEW_WORKER_POLL_INTERVAL_SECONDS=5 CONTRACT_API_REVIEW_WORKER_BATCH_LIMIT=5 +CONTRACT_API_ANTI_ABUSE_MODE=disabled +CONTRACT_API_ANTI_ABUSE_HASH_SECRET= +CONTRACT_API_ANTI_ABUSE_S3_RECORDS_DAILY_LIMIT=1000 +CONTRACT_API_ANTI_ABUSE_S4_RECORDS_DAILY_LIMIT=1000 +CONTRACT_API_ANTI_ABUSE_FILES_DAILY_LIMIT=50 +CONTRACT_API_ANTI_ABUSE_BYTES_DAILY_LIMIT=524288000 +CONTRACT_API_BATCH_DOWNLOAD_MAX_FILES=20 +CONTRACT_API_BATCH_DOWNLOAD_MAX_BYTES=104857600 CONTRACT_API_IDENTITY_EMAIL_DELIVERY_MODE=development CONTRACT_API_IDENTITY_SELF_SERVICE_ENABLED=true CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED=false @@ -54,6 +62,12 @@ AI_PLATFORM_PROVIDER_CONFIG_SOURCE=database AI_PLATFORM_PROVIDER_CALL_MODE=disabled AI_PLATFORM_PROVIDER_FALLBACK_ENABLED=true AI_PLATFORM_UVICORN_WORKERS=1 +AI_PLATFORM_ANTI_ABUSE_MODE=disabled +AI_PLATFORM_ANTI_ABUSE_HASH_SECRET= +AI_PLATFORM_ANTI_ABUSE_S3_RECORDS_DAILY_LIMIT=1000 +AI_PLATFORM_ANTI_ABUSE_S4_RECORDS_DAILY_LIMIT=1000 +AI_PLATFORM_ANTI_ABUSE_FILES_DAILY_LIMIT=50 +AI_PLATFORM_ANTI_ABUSE_BYTES_DAILY_LIMIT=524288000 YUQEI_WORKER_ENVIRONMENT=local YUQEI_WORKER_DATABASE_URL=postgresql+psycopg://yuqei:yuqei-local@postgres:5432/yuqei_ai diff --git a/yuqei-infra/compose/.env.production.example b/yuqei-infra/compose/.env.production.example index 20f947e5..c1908597 100644 --- a/yuqei-infra/compose/.env.production.example +++ b/yuqei-infra/compose/.env.production.example @@ -98,6 +98,14 @@ CONTRACT_API_IDENTITY_LOGIN_EMAIL_LIMIT=10 CONTRACT_API_IDENTITY_LOGIN_IP_LIMIT=50 CONTRACT_API_IDENTITY_ANTI_ENUMERATION_FLOOR_MS=120 CONTRACT_API_IDENTITY_ANTI_ENUMERATION_JITTER_MS=30 +CONTRACT_API_ANTI_ABUSE_MODE=observe +CONTRACT_API_ANTI_ABUSE_HASH_SECRET=CHANGE_ME_USE_A_DIFFERENT_RANDOM_32_BYTE_SECRET +CONTRACT_API_ANTI_ABUSE_S3_RECORDS_DAILY_LIMIT=1000 +CONTRACT_API_ANTI_ABUSE_S4_RECORDS_DAILY_LIMIT=1000 +CONTRACT_API_ANTI_ABUSE_FILES_DAILY_LIMIT=50 +CONTRACT_API_ANTI_ABUSE_BYTES_DAILY_LIMIT=524288000 +CONTRACT_API_BATCH_DOWNLOAD_MAX_FILES=20 +CONTRACT_API_BATCH_DOWNLOAD_MAX_BYTES=104857600 CONTRACT_REVIEW_WORKER_SEED_DEMO_DATA=false CONTRACT_WEB_SESSION_SECRET=CHANGE_ME_USE_A_DIFFERENT_RANDOM_32_BYTE_SECRET CONTRACT_WEB_SESSION_SECURE=true @@ -114,6 +122,12 @@ AI_PLATFORM_PROVIDER_CONFIG_SOURCE=database AI_PLATFORM_PROVIDER_CALL_MODE=enabled AI_PLATFORM_PROVIDER_FALLBACK_ENABLED=false AI_PLATFORM_UVICORN_WORKERS=1 +AI_PLATFORM_ANTI_ABUSE_MODE=observe +AI_PLATFORM_ANTI_ABUSE_HASH_SECRET=CHANGE_ME_USE_A_FOURTH_RANDOM_32_BYTE_SECRET +AI_PLATFORM_ANTI_ABUSE_S3_RECORDS_DAILY_LIMIT=1000 +AI_PLATFORM_ANTI_ABUSE_S4_RECORDS_DAILY_LIMIT=1000 +AI_PLATFORM_ANTI_ABUSE_FILES_DAILY_LIMIT=50 +AI_PLATFORM_ANTI_ABUSE_BYTES_DAILY_LIMIT=524288000 YUQEI_WORKER_ENVIRONMENT=production YUQEI_WORKER_DATABASE_URL=postgresql+psycopg://CHANGE_ME:CHANGE_ME@postgres:5432/yuqei_ai diff --git a/yuqei-infra/scripts/run-ai-provider-acceptance.py b/yuqei-infra/scripts/run-ai-provider-acceptance.py index 3dd6de37..d93ebe46 100644 --- a/yuqei-infra/scripts/run-ai-provider-acceptance.py +++ b/yuqei-infra/scripts/run-ai-provider-acceptance.py @@ -6,13 +6,17 @@ from __future__ import annotations import argparse import csv import json +import math +import re +import sys import time -from dataclasses import asdict, dataclass +from collections import Counter +from dataclasses import dataclass from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any from urllib.error import HTTPError, URLError -from urllib.parse import urlparse +from urllib.parse import quote, urlparse from urllib.request import Request, urlopen @@ -29,7 +33,12 @@ PHASE13_ROOT = ( ) QA_INVENTORY = PHASE13_ROOT / "inventories" / "kb-qa-golden-sample-inventory.csv" REVIEW_INVENTORY = PHASE13_ROOT / "inventories" / "review-golden-sample-inventory.csv" -DEFAULT_ARTIFACT_ROOT = REPO_ROOT / "project-docs" / "qa-artifacts" / "v2-ai-provider-acceptance" +KNOWLEDGE_CARD_INVENTORY = PHASE13_ROOT / "inventories" / "legal-knowledge-card-inventory.csv" +PHASE13_KNOWLEDGE_BASE_ID = "phase13-golden" +DEFAULT_ARTIFACT_ROOT = REPO_ROOT / ".runtime" / "v2-ai-provider-acceptance" +MAX_RESPONSE_BYTES = 5 * 1024 * 1024 +SAFE_TENANT_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +STABLE_SOURCE_ID = re.compile(r"LAW-[A-Z0-9]+(?:-[A-Z0-9]+)*") @dataclass(frozen=True) @@ -58,8 +67,21 @@ class CaseResult: class ApiClient: def __init__(self, base_url: str, *, service_key: str, tenant_id: str, timeout_seconds: float) -> None: parsed = urlparse(base_url) - if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password: - raise ValueError("AI Platform base URL must use HTTP(S), include a host, and omit credentials.") + if ( + parsed.scheme not in {"http", "https"} + or not parsed.hostname + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment + ): + raise ValueError("AI Platform base URL must use HTTP(S), include a host, and omit credentials/query/fragment.") + if not service_key or "\r" in service_key or "\n" in service_key: + raise ValueError("AI Platform service key is invalid.") + if not SAFE_TENANT_ID.fullmatch(tenant_id): + raise ValueError("Tenant ID contains unsupported characters.") + if not math.isfinite(timeout_seconds) or not 1.0 <= timeout_seconds <= 600.0: + raise ValueError("Timeout must be between 1 and 600 seconds.") self.base_url = base_url.rstrip("/") self.timeout_seconds = timeout_seconds self.headers = { @@ -81,13 +103,19 @@ class ApiClient: started = time.perf_counter() try: with urlopen(request, timeout=self.timeout_seconds) as response: # nosec B310 - raw = response.read().decode("utf-8") + encoded = response.read(MAX_RESPONSE_BYTES + 1) + if len(encoded) > MAX_RESPONSE_BYTES: + raise RuntimeError("response_too_large") + raw = encoded.decode("utf-8") result = json.loads(raw) except HTTPError as exc: - diagnostic = exc.read().decode("utf-8", errors="replace")[:1000] - raise RuntimeError(f"HTTP {exc.code}: {diagnostic}") from exc - except (URLError, TimeoutError, ValueError) as exc: - raise RuntimeError(f"{type(exc).__name__}: {exc}") from exc + raise RuntimeError(f"http_error_{exc.code}") from exc + except URLError as exc: + raise RuntimeError("connection_error") from exc + except TimeoutError as exc: + raise RuntimeError("request_timeout") from exc + except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: + raise RuntimeError("invalid_json_response") from exc return result, int((time.perf_counter() - started) * 1000) @@ -96,19 +124,93 @@ def load_rows(path: Path) -> list[dict[str, str]]: return [dict(row) for row in csv.DictReader(stream)] +def load_phase13_knowledge_assets(client: ApiClient) -> int: + """Idempotently load pending-review knowledge cards with stable external source IDs.""" + loaded = 0 + for row in load_rows(KNOWLEDGE_CARD_INVENTORY): + card_id = row["knowledge_card_id"].strip() + source_ids = ";".join(split_ids(row["source_ids"])) + search_path = ( + "/api/v1/knowledge-documents/search" + f"?keyword={quote(card_id)}&knowledge_base_id={quote(PHASE13_KNOWLEDGE_BASE_ID)}&limit=20" + ) + existing, _ = client.get(search_path) + if isinstance(existing, list) and any( + isinstance(item, dict) + and card_id in str(item.get("title", "")) + and item.get("reference") == source_ids + for item in existing + ): + continue + client.post( + "/api/v1/knowledge-documents", + { + "knowledge_base_id": PHASE13_KNOWLEDGE_BASE_ID, + "title": f"{card_id} {row['title'].strip()}", + "source_type": "v1_phase13_knowledge_card_pending_legal_review", + "reference": source_ids, + "content": _knowledge_card_content(row), + }, + ) + loaded += 1 + return loaded + + +def _knowledge_card_content(row: dict[str, str]) -> str: + return "\n".join( + ( + f"知识卡编号:{row['knowledge_card_id'].strip()}", + f"法律问题:{row['legal_question'].strip()}", + f"适用场景:{row['applicable_scenarios'].strip()}", + f"不适用场景:{row['non_applicable_scenarios'].strip()}", + f"依据范围:{row['original_reference'].strip()}", + f"回答边界:{row['answer_boundary'].strip()}", + f"复核状态:{row['status'].strip()} / {row['legal_reviewer'].strip()}", + "本知识卡不是法条原文,不代表法务批准;具体结论必须按回答边界人工复核。", + ) + ) + + def validate_runtime(client: ApiClient) -> tuple[dict[str, Any], dict[str, Any]]: schema, _ = client.get("/api/v1/runtime/schema-check") - if not isinstance(schema, dict) or schema.get("status") != "ok": + if not isinstance(schema, dict): + raise RuntimeError("AI Platform schema-check is invalid.") + checks = [item for item in schema.get("checks", []) if isinstance(item, dict)] + schema_status = schema.get("status") + memory_backend_check = next( + ( + item + for item in checks + if item.get("name") == "repository_backend" + and item.get("status") == "warning" + and isinstance(item.get("details"), dict) + and item["details"].get("repository_backend") == "memory" + ), + None, + ) + allowed_isolated_warnings = {"repository_backend", "anti_abuse_runtime"} + isolated_memory_schema = ( + schema_status == "warning" + and memory_backend_check is not None + and not any(item.get("status") == "error" for item in checks) + and all( + item.get("status") != "warning" or item.get("name") in allowed_isolated_warnings + for item in checks + ) + ) + if schema_status != "ok" and not isolated_memory_schema: raise RuntimeError("AI Platform schema-check is not ok.") provider_check = next( ( item - for item in schema.get("checks", []) + for item in checks if isinstance(item, dict) and item.get("name") == "provider_runtime" ), None, ) - if not isinstance(provider_check, dict) or provider_check.get("status") != "ok": + if not isolated_memory_schema and ( + not isinstance(provider_check, dict) or provider_check.get("status") != "ok" + ): raise RuntimeError("provider_runtime schema check is not ok.") configs, _ = client.get("/api/v1/model-configs") if not isinstance(configs, list): @@ -131,7 +233,7 @@ def run_qa_case(client: ApiClient, row: dict[str, str], seen_run_ids: set[str]) { "question": row["question"], "retrieval_strategy": "hybrid", - "knowledge_base_ids": [], + "knowledge_base_ids": [PHASE13_KNOWLEDGE_BASE_ID], }, ) audit = find_latest_audit(client, operation="legal_qa", started_at=started_at, seen_run_ids=seen_run_ids) @@ -156,7 +258,7 @@ def run_review_case(client: ApiClient, row: dict[str, str], seen_run_ids: set[st "contract_type": row.get("contract_type") or None, "review_focus": row.get("risk_categories") or None, "retrieval_strategy": "hybrid", - "knowledge_base_ids": [], + "knowledge_base_ids": [PHASE13_KNOWLEDGE_BASE_ID], }, ) run_id = response.get("audit_run_id") if isinstance(response, dict) else None @@ -243,11 +345,7 @@ def evaluate_case( if citation_required and not citation_rows: problems.append("missing_citations") expected_source_ids = split_ids(row.get("expected_source_ids") or row.get("expected_source_citations") or row.get("source_ids") or "") - observed_source_ids = { - str(item.get("document_id")) - for item in citation_rows - if isinstance(item, dict) and item.get("document_id") - } + observed_source_ids = _response_source_ids(citation_rows) matched_source_ids = sorted(set(expected_source_ids) & observed_source_ids) if expected_source_ids and not matched_source_ids: problems.append("expected_source_not_retrieved") @@ -257,6 +355,18 @@ def evaluate_case( risk_items = response_dict.get("risk_items", []) if not isinstance(risk_items, list) or not risk_items: problems.append("missing_risk_items") + risk_items = [] + risk_source_ids = { + source_id + for item in risk_items + if isinstance(item, dict) + for source_id in item.get("citation_source_ids", []) + if isinstance(source_id, str) + } + if risk_items and not risk_source_ids: + problems.append("missing_risk_item_citations") + if expected_source_ids and not (set(expected_source_ids) & risk_source_ids): + problems.append("expected_source_not_cited_by_risk") observed_severity = highest_severity( [str(item.get("severity", "")) for item in risk_items if isinstance(item, dict)] ) @@ -295,6 +405,29 @@ def split_ids(value: str) -> list[str]: return [item.strip() for item in value.split(";") if item.strip()] +def _response_source_ids(citations: list[Any]) -> set[str]: + observed: set[str] = set() + for item in citations: + if not isinstance(item, dict): + continue + source_ids = item.get("source_ids", []) + if isinstance(source_ids, list): + observed.update( + normalized + for source_id in source_ids + if source_id + for normalized in [str(source_id).upper()] + if STABLE_SOURCE_ID.fullmatch(normalized) + ) + reference = item.get("reference") + if isinstance(reference, str): + observed.update(STABLE_SOURCE_ID.findall(reference.upper())) + document_id = item.get("document_id") + if isinstance(document_id, str) and STABLE_SOURCE_ID.fullmatch(document_id.upper()): + observed.add(document_id.upper()) + return observed + + def severity_rank(value: str | None) -> int: return {"low": 1, "medium": 2, "high": 3, "critical": 4}.get((value or "").lower(), 0) @@ -313,24 +446,69 @@ def parse_datetime(value: Any) -> datetime | None: return parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed.astimezone(timezone.utc) -def write_markdown(path: Path, summary: dict[str, Any], cases: list[CaseResult]) -> None: +def build_evidence_summary( + *, + status: str, + provider: str | None, + model: str | None, + cases: list[CaseResult], + extra_problems: list[str] | None = None, +) -> dict[str, Any]: + problem_counts = Counter(problem for item in cases for problem in item.problems) + problem_counts.update(extra_problems or []) + technical_passed = sum(item.technical_status == "passed" for item in cases) + legal_approved = sum(item.legal_status == "approved" for item in cases) + return { + "provider": provider, + "model": model, + "status": status, + "counts": { + "total": len(cases), + "legal_qa": sum(item.suite == "legal_qa" for item in cases), + "contract_review": sum(item.suite == "contract_review" for item in cases), + "technical_passed": technical_passed, + "technical_failed": len(cases) - technical_passed, + "legal_approved": legal_approved, + "legal_review_pending": len(cases) - legal_approved, + "problem_count": sum(problem_counts.values()), + }, + "problems": [ + {"code": code, "count": count} + for code, count in sorted(problem_counts.items()) + ], + } + + +def write_evidence(artifact_dir: Path, summary: dict[str, Any]) -> None: + (artifact_dir / "ai-provider-acceptance-summary.json").write_text( + json.dumps(summary, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + write_markdown(artifact_dir / "ai-provider-acceptance-summary.md", summary) + + +def write_markdown(path: Path, summary: dict[str, Any]) -> None: + counts = summary["counts"] lines = [ "# AI Provider Acceptance", "", - f"- Status: `{summary['status']}`", - f"- Technical pass: `{summary['technical_passed']}/{summary['total_cases']}`", - f"- Legal approved: `{summary['legal_approved']}/{summary['total_cases']}`", f"- Provider: `{summary['provider']}`", f"- Model: `{summary['model']}`", + f"- Status: `{summary['status']}`", + f"- Total: `{counts['total']}`", + f"- Legal QA: `{counts['legal_qa']}`", + f"- Contract review: `{counts['contract_review']}`", + f"- Technical pass: `{counts['technical_passed']}/{counts['total']}`", + f"- Legal approved: `{counts['legal_approved']}/{counts['total']}`", "", - "| Sample | Suite | Technical | Legal | Latency ms | Citations | Problems |", - "| --- | --- | --- | --- | ---: | ---: | --- |", + "| Problem | Count |", + "| --- | ---: |", ] - for item in cases: - lines.append( - f"| {item.sample_id} | {item.suite} | {item.technical_status} | {item.legal_status} | " - f"{item.latency_ms} | {item.citation_count} | {', '.join(item.problems) or '-'} |" - ) + if summary["problems"]: + for problem in summary["problems"]: + lines.append(f"| {problem['code']} | {problem['count']} |") + else: + lines.append("| - | 0 |") lines.extend( ( "", @@ -345,37 +523,67 @@ def _parse_args() -> argparse.Namespace: parser.add_argument("--base-url", required=True, help="AI Platform base URL, including /ai when using the production edge.") parser.add_argument("--service-key-file", type=Path, required=True) parser.add_argument("--tenant-id", default="default") - parser.add_argument("--timeout-seconds", type=float, default=180.0) - parser.add_argument("--max-qa-cases", type=int, default=0, help="Zero runs all QA samples.") - parser.add_argument("--max-review-cases", type=int, default=0, help="Zero runs all review samples.") + parser.add_argument("--timeout-seconds", type=_bounded_timeout, default=180.0) + parser.add_argument("--max-qa-cases", type=_nonnegative_int, default=0, help="Zero runs all QA samples.") + parser.add_argument("--max-review-cases", type=_nonnegative_int, default=0, help="Zero runs all review samples.") parser.add_argument("--artifact-dir", type=Path) return parser.parse_args() +def _bounded_timeout(value: str) -> float: + parsed = float(value) + if not math.isfinite(parsed) or not 1.0 <= parsed <= 600.0: + raise argparse.ArgumentTypeError("timeout must be between 1 and 600 seconds") + return parsed + + +def _nonnegative_int(value: str) -> int: + parsed = int(value) + if parsed < 0: + raise argparse.ArgumentTypeError("case limit must be zero or greater") + return parsed + + +def read_service_key(path: Path) -> str: + resolved = path.resolve(strict=True) + if not resolved.is_file() or resolved.stat().st_size > 64 * 1024: + raise OSError("service key file is invalid") + value = resolved.read_text(encoding="utf-8").strip() + if len(value) < 32 or "\r" in value or "\n" in value: + raise ValueError("service key is invalid") + return value + + def main() -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") args = _parse_args() try: - service_key = args.service_key_file.read_text(encoding="utf-8").strip() - except OSError: - print("AI_PROVIDER_ACCEPTANCE_FAIL service_key_file_unreadable") - return 2 - if len(service_key) < 32: - print("AI_PROVIDER_ACCEPTANCE_FAIL service_key_too_short") + service_key = read_service_key(args.service_key_file) + except (OSError, ValueError): + print(json.dumps(build_evidence_summary( + status="failed", + provider=None, + model=None, + cases=[], + extra_problems=["service_key_unavailable"], + ), ensure_ascii=False)) return 2 checked_at = datetime.now(timezone.utc).astimezone() artifact_dir = (args.artifact_dir or DEFAULT_ARTIFACT_ROOT / checked_at.strftime("%Y-%m-%dT%H-%M-%S")).resolve() artifact_dir.mkdir(parents=True, exist_ok=True) - client = ApiClient( - args.base_url, - service_key=service_key, - tenant_id=args.tenant_id, - timeout_seconds=args.timeout_seconds, - ) cases: list[CaseResult] = [] - responses: dict[str, Any] = {} seen_run_ids: set[str] = set() + provider_config: dict[str, Any] | None = None try: - schema, provider_config = validate_runtime(client) + client = ApiClient( + args.base_url, + service_key=service_key, + tenant_id=args.tenant_id, + timeout_seconds=args.timeout_seconds, + ) + _schema, provider_config = validate_runtime(client) + load_phase13_knowledge_assets(client) qa_rows = load_rows(QA_INVENTORY) review_rows = load_rows(REVIEW_INVENTORY) if args.max_qa_cases > 0: @@ -383,26 +591,21 @@ def main() -> int: if args.max_review_cases > 0: review_rows = review_rows[: args.max_review_cases] for row in qa_rows: - result, response = run_qa_case(client, row, seen_run_ids) + result, _ = run_qa_case(client, row, seen_run_ids) cases.append(result) - responses[result.sample_id] = response for row in review_rows: - result, response = run_review_case(client, row, seen_run_ids) + result, _ = run_review_case(client, row, seen_run_ids) cases.append(result) - responses[result.sample_id] = response except Exception as exc: - failure = { - "status": "failed", - "checked_at": checked_at.isoformat(), - "error_type": type(exc).__name__, - "error": str(exc)[:2000], - "completed_cases": [asdict(item) for item in cases], - } - (artifact_dir / "ai-provider-acceptance-summary.json").write_text( - json.dumps(failure, ensure_ascii=False, indent=2) + "\n", - encoding="utf-8", + failure = build_evidence_summary( + status="failed", + provider=provider_config.get("provider") if provider_config else None, + model=provider_config.get("model") if provider_config else None, + cases=cases, + extra_problems=[f"runtime_{type(exc).__name__.lower()}"], ) - print(f"AI_PROVIDER_ACCEPTANCE_FAIL error={type(exc).__name__} artifact={artifact_dir}") + write_evidence(artifact_dir, failure) + print(json.dumps(failure, ensure_ascii=False, separators=(",", ":"))) return 1 technical_passed = sum(item.technical_status == "passed" for item in cases) @@ -413,33 +616,14 @@ def main() -> int: status = "needs_legal_review" else: status = "passed" - summary = { - "status": status, - "checked_at": checked_at.isoformat(), - "tenant_id": args.tenant_id, - "provider": provider_config.get("provider"), - "model": provider_config.get("model"), - "provider_config_id": provider_config.get("id"), - "schema_status": schema.get("status"), - "total_cases": len(cases), - "technical_passed": technical_passed, - "legal_approved": legal_approved, - "total_latency_ms": sum(item.latency_ms for item in cases), - "cases": [asdict(item) for item in cases], - } - (artifact_dir / "ai-provider-acceptance-summary.json").write_text( - json.dumps(summary, ensure_ascii=False, indent=2) + "\n", - encoding="utf-8", - ) - (artifact_dir / "ai-provider-acceptance-responses.json").write_text( - json.dumps(responses, ensure_ascii=False, indent=2) + "\n", - encoding="utf-8", - ) - write_markdown(artifact_dir / "ai-provider-acceptance-summary.md", summary, cases) - print( - f"AI_PROVIDER_ACCEPTANCE_{status.upper()} technical={technical_passed}/{len(cases)} " - f"legal={legal_approved}/{len(cases)} artifact={artifact_dir}" + summary = build_evidence_summary( + status=status, + provider=provider_config.get("provider"), + model=provider_config.get("model"), + cases=cases, ) + write_evidence(artifact_dir, summary) + print(json.dumps(summary, ensure_ascii=False, separators=(",", ":"))) return 1 if status == "failed" else 0 diff --git a/yuqei-infra/tests/test_production_release_tools.py b/yuqei-infra/tests/test_production_release_tools.py index c5bdce52..c148d0af 100644 --- a/yuqei-infra/tests/test_production_release_tools.py +++ b/yuqei-infra/tests/test_production_release_tools.py @@ -6,9 +6,14 @@ import io import json import sys import tarfile +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +from threading import Thread from types import ModuleType +import pytest + REPO_ROOT = Path(__file__).resolve().parents[2] @@ -50,8 +55,10 @@ def production_values() -> dict[str, str]: "MINIO_ROOT_PASSWORD": "minio-secret-that-is-long-and-unique-002", "CONTRACT_API_WEB_PROXY_SHARED_SECRET": "proxy-secret-that-is-long-and-unique-003", "CONTRACT_API_IDENTITY_RATE_LIMIT_SECRET": "identity-secret-that-is-long-and-unique-004", + "CONTRACT_API_ANTI_ABUSE_HASH_SECRET": "contract-anti-abuse-secret-that-is-long-004a", "CONTRACT_WEB_SESSION_SECRET": "session-secret-that-is-long-and-unique-005", "AI_PLATFORM_SERVICE_SHARED_SECRET": "ai-service-secret-that-is-long-and-unique-006", + "AI_PLATFORM_ANTI_ABUSE_HASH_SECRET": "ai-anti-abuse-secret-that-is-long-and-unique-006a", "CONTRACT_API_DATABASE_URL": "postgresql+psycopg://yuqei_prod:encoded-password@postgres:5432/yuqei_contract", "AI_PLATFORM_DATABASE_URL": "postgresql+psycopg://yuqei_prod:encoded-password@postgres:5432/yuqei_ai", "YUQEI_WORKER_DATABASE_URL": "postgresql+psycopg://yuqei_prod:encoded-password@postgres:5432/yuqei_ai", @@ -173,6 +180,324 @@ def test_provider_acceptance_rejects_fallback_audit() -> None: assert "fallback_or_provider_failure_label" in result.problems +def test_provider_acceptance_requires_explicit_stable_source_ids() -> None: + observed = provider_acceptance._response_source_ids( + [ + {"document_id": "doc-LAW-CIVIL-001-random"}, + {"document_id": "doc-random", "reference": "LAW-CONTRACT-001 article 1"}, + {"document_id": "doc-random-2", "source_ids": ["LAW-CIVIL-001", "not-stable"]}, + ] + ) + + assert observed == {"LAW-CIVIL-001", "LAW-CONTRACT-001"} + + +def test_provider_acceptance_loads_phase13_knowledge_cards_idempotently() -> None: + documents: list[dict[str, object]] = [] + + class Client: + def get(self, path: str): + assert path.startswith("/api/v1/knowledge-documents/search?") + return documents, 1 + + def post(self, path: str, payload: dict[str, object]): + assert path == "/api/v1/knowledge-documents" + documents.append({"id": f"doc-{len(documents)}", **payload}) + return documents[-1], 1 + + client = Client() + + assert provider_acceptance.load_phase13_knowledge_assets(client) == 10 + assert provider_acceptance.load_phase13_knowledge_assets(client) == 0 + assert len(documents) == 10 + assert all(str(item["reference"]).startswith("LAW-") for item in documents) + assert all("不代表法务批准" in str(item["content"]) for item in documents) + + +def test_provider_acceptance_evidence_excludes_responses_and_identifiers(tmp_path: Path) -> None: + result = provider_acceptance.evaluate_case( + row={ + "sample_id": "sensitive-sample-id", + "expected_citation_required": "true", + "expected_source_ids": "LAW-SECRET-001", + "production_ready": "false", + }, + suite="legal_qa", + response={ + "answer": "sensitive-provider-answer", + "citations": [{"document_id": "LAW-SECRET-001"}], + }, + audit={ + "id": "sensitive-audit-run-id", + "status": "succeeded", + "provider_status": "succeeded", + "provider": "deepseek", + "model": "deepseek-chat", + "quality_labels": [], + }, + latency_ms=100, + expected_boundary="sensitive-legal-boundary", + expected_severity=None, + ) + + summary = provider_acceptance.build_evidence_summary( + status="needs_legal_review", + provider="deepseek", + model="deepseek-chat", + cases=[result], + ) + provider_acceptance.write_evidence(tmp_path, summary) + + assert set(summary) == {"provider", "model", "status", "counts", "problems"} + serialized = json.dumps(summary, ensure_ascii=False) + for sensitive_value in ( + "sensitive-sample-id", + "sensitive-provider-answer", + "LAW-SECRET-001", + "sensitive-audit-run-id", + "sensitive-legal-boundary", + ): + assert sensitive_value not in serialized + assert not (tmp_path / "ai-provider-acceptance-responses.json").exists() + assert set(path.name for path in tmp_path.iterdir()) == { + "ai-provider-acceptance-summary.json", + "ai-provider-acceptance-summary.md", + } + + +def test_provider_acceptance_validates_safe_runtime_parameters() -> None: + valid_key = "service-key-that-is-long-enough-for-testing" + + with pytest.raises(ValueError, match="query/fragment"): + provider_acceptance.ApiClient( + "https://api.example.test/ai?key=unsafe", + service_key=valid_key, + tenant_id="default", + timeout_seconds=30, + ) + with pytest.raises(ValueError, match="service key"): + provider_acceptance.ApiClient( + "https://api.example.test/ai", + service_key=f"{valid_key}\r\nInjected: true", + tenant_id="default", + timeout_seconds=30, + ) + with pytest.raises(ValueError, match="Tenant ID"): + provider_acceptance.ApiClient( + "https://api.example.test/ai", + service_key=valid_key, + tenant_id="default\r\nInjected: true", + timeout_seconds=30, + ) + with pytest.raises(ValueError, match="Timeout"): + provider_acceptance.ApiClient( + "https://api.example.test/ai", + service_key=valid_key, + tenant_id="default", + timeout_seconds=0, + ) + + +def test_provider_acceptance_allows_explicit_isolated_memory_schema_warning() -> None: + class Client: + def get(self, path: str): + if path == "/api/v1/runtime/schema-check": + return { + "status": "warning", + "checks": [ + { + "name": "repository_backend", + "status": "warning", + "details": {"repository_backend": "memory"}, + }, + {"name": "anti_abuse_runtime", "status": "warning", "details": {}}, + ], + }, 1 + if path == "/api/v1/model-configs": + return [ + { + "provider": "isolated-provider", + "model": "isolated-model", + "enabled": True, + "is_default": True, + "base_url": "https://provider.example.test", + "api_key_set": True, + } + ], 1 + raise AssertionError(path) + + schema, provider_config = provider_acceptance.validate_runtime(Client()) + + assert schema["status"] == "warning" + assert provider_config["provider"] == "isolated-provider" + + +def test_provider_acceptance_encodes_chinese_request_as_utf8(monkeypatch) -> None: + captured: dict[str, object] = {} + + class Response: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self, _limit: int) -> bytes: + return b'{"status":"ok"}' + + def fake_urlopen(request, *, timeout): + captured["body"] = request.data + captured["timeout"] = timeout + return Response() + + monkeypatch.setattr(provider_acceptance, "urlopen", fake_urlopen) + client = provider_acceptance.ApiClient( + "https://api.example.test/ai", + service_key="service-key-that-is-long-enough-for-testing", + tenant_id="default", + timeout_seconds=30, + ) + + payload, _ = client.post("/api/v1/legal/qa", {"question": "电子邮件能否作为合同证据?"}) + + assert payload == {"status": "ok"} + assert json.loads(captured["body"].decode("utf-8"))["question"] == "电子邮件能否作为合同证据?" + assert captured["timeout"] == 30 + + +def test_provider_acceptance_runs_all_phase13_cases_against_isolated_http_service() -> None: + qa_rows = provider_acceptance.load_rows(provider_acceptance.QA_INVENTORY) + review_rows = provider_acceptance.load_rows(provider_acceptance.REVIEW_INVENTORY) + qa_by_question = {row["question"]: row for row in qa_rows} + review_by_id = {row["sample_id"]: row for row in review_rows} + audits: dict[str, dict[str, object]] = {} + + class Handler(BaseHTTPRequestHandler): + def send_json(self, payload: object) -> None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self) -> None: # noqa: N802 + path = self.path.split("?", 1)[0] + if path == "/api/v1/runtime/schema-check": + self.send_json( + { + "status": "ok", + "checks": [{"name": "provider_runtime", "status": "ok"}], + } + ) + return + if path == "/api/v1/model-configs": + self.send_json( + [ + { + "id": "provider-isolated", + "provider": "isolated-provider", + "model": "isolated-model", + "enabled": True, + "is_default": True, + "base_url": "https://provider.invalid/v1", + "api_key_set": True, + } + ] + ) + return + if path == "/api/v1/audit/ai-runs": + self.send_json(list(reversed(list(audits.values())))) + return + run_id = path.removeprefix("/api/v1/audit/ai-runs/") + if run_id in audits: + self.send_json(audits[run_id]) + return + self.send_error(404) + + def do_POST(self) -> None: # noqa: N802 + content_length = int(self.headers.get("Content-Length", "0")) + payload = json.loads(self.rfile.read(content_length).decode("utf-8")) + run_id = f"isolated-run-{len(audits) + 1}" + if self.path == "/api/v1/legal/qa": + row = qa_by_question[payload["question"]] + citations = [ + {"document_id": f"doc-{index}", "source_ids": [source_id]} + for index, source_id in enumerate(provider_acceptance.split_ids(row["expected_source_ids"])) + ] + operation = "legal_qa" + response = {"answer": "基于检索依据给出边界明确的答复。", "citations": citations} + elif self.path == "/api/v1/legal/contract-review": + row = review_by_id[payload["contract_title"]] + citations = [ + {"document_id": f"doc-{index}", "source_ids": [source_id]} + for index, source_id in enumerate(provider_acceptance.split_ids(row["expected_source_citations"])) + ] + cited_source_ids = provider_acceptance.split_ids(row["expected_source_citations"]) + operation = "contract_review" + response = { + "summary": "已按约定审查重点完成审查。", + "citations": citations, + "risk_items": [ + { + "severity": row["expected_severity"], + "citation_source_ids": cited_source_ids, + } + ], + "audit_run_id": run_id, + } + else: + self.send_error(404) + return + audits[run_id] = { + "id": run_id, + "operation": operation, + "status": "succeeded", + "provider_status": "succeeded", + "provider": "isolated-provider", + "model": "isolated-model", + "quality_labels": [], + "created_at": datetime.now(timezone.utc).isoformat(), + } + self.send_json(response) + + def log_message(self, _format: str, *_args: object) -> None: + return + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + client = provider_acceptance.ApiClient( + f"http://127.0.0.1:{server.server_port}", + service_key="isolated-non-secret-service-key-for-tests", + tenant_id="default", + timeout_seconds=10, + ) + schema, provider_config = provider_acceptance.validate_runtime(client) + seen_run_ids: set[str] = set() + results = [ + provider_acceptance.run_qa_case(client, row, seen_run_ids)[0] + for row in qa_rows + ] + results.extend( + provider_acceptance.run_review_case(client, row, seen_run_ids)[0] + for row in review_rows + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert len(qa_rows) == 10 + assert len(review_rows) == 8 + assert len(results) == 18 + assert schema["status"] == "ok" + assert provider_config["provider"] == "isolated-provider" + assert all(result.technical_status == "passed" for result in results) + assert all(result.legal_status == "needs_legal_review" for result in results) + + def test_production_tool_bandit_suppressions_are_exactly_allowlisted() -> None: expected = { "yuqei-infra/backup/run-production-backup.py": ["B404", "B603"],