From 7bc74f06f28ddf1b23e6de3a41b77f1bbf207bea Mon Sep 17 00:00:00 2001 From: yuqei Date: Mon, 20 Jul 2026 18:39:27 +0800 Subject: [PATCH] release: freeze V2 production candidate --- .github/workflows/build-test-quality-gate.yml | 99 +++ .github/workflows/security-quality-gate.yml | 4 + project-docs/V2-deploy-runbook-2026-06-26.md | 17 +- ...-operations-launch-readiness-2026-07-13.md | 48 +- ...tion-identity-email-security-2026-07-13.md | 126 ++++ ...tructure-provider-acceptance-2026-07-16.md | 66 ++ project-docs/V2-rc-freeze-2026-07-20.md | 87 +++ ...-release-readiness-checklist-2026-07-10.md | 31 +- .../src/yuqei_ai_platform_api/secret_refs.py | 37 +- .../tests/test_platform_data.py | 8 +- .../ai-platform-api/tests/test_secret_refs.py | 28 + .../src/yuqei_sdk/ai_platform.py | 2 + yuqei-ai-sdk-python/tests/test_ai_platform.py | 10 + .../src/app/admin/identity/page.tsx | 46 +- .../src/app/api/auth/capabilities/route.ts | 14 + .../src/app/api/auth/login/route.ts | 4 +- .../api/auth/password-reset/request/route.ts | 4 +- .../identity/[...path]/route.ts | 4 +- .../apps/contract-web/src/app/login/page.tsx | 26 +- .../src/app/password-reset/page.tsx | 32 +- .../contract-web/src/app/register/page.tsx | 30 +- .../contract-web/src/lib/ai-platform-api.ts | 26 + .../apps/contract-web/src/lib/contract-api.ts | 11 + .../src/lib/identity-capabilities.ts | 16 + .../services/contract-api/.env.example | 29 + .../services/contract-api/README.md | 19 +- ...260713_0031_add_identity_email_security.py | 109 ++++ .../services/contract-api/pyproject.toml | 1 + .../src/yuqei_contract_api/api/identity.py | 417 +++++++++++- .../src/yuqei_contract_api/api/review.py | 1 + .../src/yuqei_contract_api/config.py | 170 ++++- .../src/yuqei_contract_api/identity.py | 228 ++++++- .../yuqei_contract_api/identity_bootstrap.py | 58 ++ .../identity_email_delivery.py | 218 +++++++ .../yuqei_contract_api/identity_rate_limit.py | 34 + .../yuqei_contract_api/identity_security.py | 3 + .../persistence/identity.py | 520 +++++++++++---- .../yuqei_contract_api/persistence/models.py | 26 + .../src/yuqei_contract_api/schema_check.py | 70 ++ .../sqlalchemy_contract_repository.py | 1 + .../sqlalchemy_query_orchestration.py | 46 +- .../tests/test_contract_workflow.py | 134 +++- .../contract-api/tests/test_health.py | 4 +- .../tests/test_identity_management.py | 5 +- .../tests/test_identity_security.py | 610 ++++++++++++++++++ yuqei-infra/README.md | 7 +- yuqei-infra/backup/README.md | 66 ++ yuqei-infra/backup/run-production-backup.py | 324 ++++++++++ .../backup/verify-production-backup.py | 272 ++++++++ .../backup/yuqei-production-backup.service | 20 + .../backup/yuqei-production-backup.timer | 10 + yuqei-infra/compose/.env.example | 5 + yuqei-infra/compose/.env.production.example | 68 +- .../compose/docker-compose.production.yml | 133 ++++ yuqei-infra/compose/docker-compose.yml | 43 +- yuqei-infra/deploy/README.md | 74 +++ yuqei-infra/deploy/preflight-production.py | 296 +++++++++ .../nginx/templates/default.conf.template | 78 +++ yuqei-infra/observability/README.md | 27 + .../observability/check-production-health.py | 246 +++++++ .../yuqei-production-health.service | 20 + .../yuqei-production-health.timer | 11 + .../scripts/deploy-v2-smoke-docker.ps1 | 15 +- .../scripts/run-ai-provider-acceptance.py | 447 +++++++++++++ .../scripts/verify-core-user-flows.cjs | 79 ++- .../tests/test_production_release_tools.py | 192 ++++++ 66 files changed, 5633 insertions(+), 279 deletions(-) create mode 100644 .github/workflows/build-test-quality-gate.yml create mode 100644 project-docs/V2-production-identity-email-security-2026-07-13.md create mode 100644 project-docs/V2-production-infrastructure-provider-acceptance-2026-07-16.md create mode 100644 project-docs/V2-rc-freeze-2026-07-20.md create mode 100644 yuqei-ai-platform/services/ai-platform-api/tests/test_secret_refs.py create mode 100644 yuqei-contract-platform/apps/contract-web/src/app/api/auth/capabilities/route.ts create mode 100644 yuqei-contract-platform/apps/contract-web/src/lib/identity-capabilities.ts create mode 100644 yuqei-contract-platform/services/contract-api/migrations/versions/20260713_0031_add_identity_email_security.py create mode 100644 yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/identity_bootstrap.py create mode 100644 yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/identity_email_delivery.py create mode 100644 yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/identity_rate_limit.py create mode 100644 yuqei-contract-platform/services/contract-api/tests/test_identity_security.py create mode 100644 yuqei-infra/backup/README.md create mode 100644 yuqei-infra/backup/run-production-backup.py create mode 100644 yuqei-infra/backup/verify-production-backup.py create mode 100644 yuqei-infra/backup/yuqei-production-backup.service create mode 100644 yuqei-infra/backup/yuqei-production-backup.timer create mode 100644 yuqei-infra/compose/docker-compose.production.yml create mode 100644 yuqei-infra/deploy/README.md create mode 100644 yuqei-infra/deploy/preflight-production.py create mode 100644 yuqei-infra/nginx/templates/default.conf.template create mode 100644 yuqei-infra/observability/README.md create mode 100644 yuqei-infra/observability/check-production-health.py create mode 100644 yuqei-infra/observability/yuqei-production-health.service create mode 100644 yuqei-infra/observability/yuqei-production-health.timer create mode 100644 yuqei-infra/scripts/run-ai-provider-acceptance.py create mode 100644 yuqei-infra/tests/test_production_release_tools.py diff --git a/.github/workflows/build-test-quality-gate.yml b/.github/workflows/build-test-quality-gate.yml new file mode 100644 index 00000000..1d5dbbde --- /dev/null +++ b/.github/workflows/build-test-quality-gate.yml @@ -0,0 +1,99 @@ +name: Build and Test Quality Gate + +on: + pull_request: + push: + branches: + - main + - master + workflow_dispatch: + +permissions: + contents: read + +jobs: + web: + name: Contract Web typecheck and build + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + + - name: Install workspace dependencies + run: | + npm install -g npm@10.9.7 + npm ci + + - name: Typecheck Contract Web + run: npm run typecheck --workspace @yuqei/contract-web + + - name: Build Contract Web + run: npm run build --workspace @yuqei/contract-web + + python: + name: Python tests and Contract API migration + runs-on: ubuntu-latest + timeout-minutes: 25 + env: + CONTRACT_API_ENVIRONMENT: test + CONTRACT_API_DATABASE_URL: "sqlite+pysqlite:///:memory:" + CONTRACT_API_DATABASE_AUTO_CREATE: "true" + CONTRACT_API_SEED_DEMO_DATA: "false" + AI_PLATFORM_ENVIRONMENT: test + AI_PLATFORM_DATABASE_URL: "sqlite+pysqlite:///:memory:" + AI_PLATFORM_DATABASE_AUTO_CREATE: "true" + AI_PLATFORM_REPOSITORY_BACKEND: sqlalchemy + YUQEI_WORKER_ENVIRONMENT: test + YUQEI_WORKER_DATABASE_URL: "sqlite+pysqlite:///:memory:" + YUQEI_WORKER_DATABASE_AUTO_CREATE: "true" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install Python projects and test runner + run: | + python -m pip install --upgrade pip + python -m pip install pytest \ + -e yuqei-contract-platform/services/contract-api \ + -e yuqei-ai-platform/services/ai-platform-api \ + -e yuqei-ai-platform/services/worker \ + -e yuqei-ai-sdk-python + + - name: Test Contract API + run: python -m pytest yuqei-contract-platform/services/contract-api/tests -q + + - name: Test AI Platform API + run: python -m pytest yuqei-ai-platform/services/ai-platform-api/tests -q + + - name: Test AI worker + run: python -m pytest yuqei-ai-platform/services/worker/tests -q + + - name: Test Python SDK + run: python -m pytest yuqei-ai-sdk-python/tests -q + + - name: Test production release tools + run: python -m pytest yuqei-infra/tests -q + + - name: Verify Contract API empty-database migrations + working-directory: yuqei-contract-platform/services/contract-api + env: + CONTRACT_API_DATABASE_URL: sqlite+pysqlite:////tmp/yuqei-contract-api-ci.sqlite3 + CONTRACT_API_DATABASE_AUTO_CREATE: "false" + run: | + python -m alembic upgrade head + python -m alembic downgrade 20260710_0030 + python -m alembic upgrade head + python -m alembic current | grep -F "20260713_0031 (head)" diff --git a/.github/workflows/security-quality-gate.yml b/.github/workflows/security-quality-gate.yml index 5295bfdb..8cf123ed 100644 --- a/.github/workflows/security-quality-gate.yml +++ b/.github/workflows/security-quality-gate.yml @@ -71,6 +71,10 @@ jobs: yuqei-ai-platform/services/ai-platform-api/src \ yuqei-ai-platform/services/worker/src \ yuqei-ai-sdk-python/src \ + yuqei-infra/deploy \ + yuqei-infra/backup \ + yuqei-infra/observability \ + yuqei-infra/scripts/run-ai-provider-acceptance.py \ -f json \ -o security-artifacts/bandit-project.json diff --git a/project-docs/V2-deploy-runbook-2026-06-26.md b/project-docs/V2-deploy-runbook-2026-06-26.md index 95da79e0..bf697ed3 100644 --- a/project-docs/V2-deploy-runbook-2026-06-26.md +++ b/project-docs/V2-deploy-runbook-2026-06-26.md @@ -60,7 +60,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File yuqei-infra\scripts\depl 最新一次已通过的 smoke 产物: -- `project-docs/qa-artifacts/v2-smoke-docker-deploy/2026-07-12T13-21-24` +- `project-docs/qa-artifacts/v2-smoke-docker-deploy/2026-07-16T11-12-47` - Web: `http://192.168.3.90:13000` - Contract API: `http://192.168.3.90:18100` - AI Platform API: `http://192.168.3.90:18101` @@ -68,11 +68,13 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File yuqei-infra\scripts\depl - AI golden sample asset gate:通过,`90` samples、`6/6` suites、`16/16` checks。 - Contract review worker smoke:通过。 - 远端数据库基线:`yuqei_contract`、`yuqei_ai`、`yuqei_ops` 均存在。 -- 发布质量门:`project-docs/qa-artifacts/v2-release-quality-gate/2026-07-12T13-28-00`,带 `--run-benchmark` 复跑通过。 +- 发布质量门:`project-docs/qa-artifacts/v2-release-quality-gate/2026-07-16T11-18-52`,带 `--run-benchmark` 复跑通过。 - 安全扫描:`project-docs/qa-artifacts/v2-security-scan/2026-07-12T13-08-00`,npm、Bandit、pip-audit 均为 0。 - 源码 secret 扫描:`project-docs/qa-artifacts/v2-source-secret-scan/2026-07-12T15-24-56`,覆盖冻结索引的 821 个文件;16 个候选均为已审计样例,新增未审计命中和陈旧基线均为 0。 - 接口压测:`project-docs/qa-artifacts/v2-interface-benchmark/2026-07-12T13-24-00`,7 个场景全部通过,错误率均为 0。 - Contract API:单 Uvicorn worker + 2 秒短读缓存;所有写请求前后清缓存。 +- 无邮箱身份 smoke:公开注册/重置/邮箱验证关闭,管理员邀请与员工设密登录通过。 +- 部署归档明确排除 `.env`、`.env.local`、`.env.production`、`.env.smoke`,本机 secret 不进入远端构建上下文。 ## 生产基线 @@ -84,6 +86,10 @@ CONTRACT_API_DATABASE_AUTO_CREATE=false CONTRACT_API_SEED_DEMO_DATA=false CONTRACT_API_UVICORN_WORKERS=1 CONTRACT_API_READ_CACHE_TTL_SECONDS=2 +CONTRACT_API_IDENTITY_EMAIL_DELIVERY_MODE=disabled +CONTRACT_API_IDENTITY_SELF_SERVICE_ENABLED=false +CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED=true +CONTRACT_API_IDENTITY_RATE_LIMIT_SECRET=LOAD_A_STABLE_RANDOM_SECRET_OF_AT_LEAST_32_CHARACTERS AI_PLATFORM_ENVIRONMENT=production AI_PLATFORM_DATABASE_AUTO_CREATE=false AI_PLATFORM_UVICORN_WORKERS=1 @@ -95,6 +101,11 @@ YUQEI_WORKER_DATABASE_AUTO_CREATE=false - 连接串从 `yuqei-infra/compose/.env.production.example` 开始替换。 - 生产不允许依赖自动建表。 - 发布前先迁移,再替换容器。 +- 身份安全迁移的预期 Contract API head 为 `20260713_0031`;部署后必须验证 `identity_security_columns=ok`。 +- 首次生产部署在迁移后、开放入口前运行一次 `python -m yuqei_contract_api.identity_bootstrap --tenant-id default --email <平台管理员邮箱> --password-file <只读 secret 文件>`。命令默认不覆盖已有凭据,轮换必须显式增加 `--force-rotate`。 +- 当前首发采用管理员线下开通:关闭公开注册、密码重置和邮箱验证;管理员在后台生成一次性邀请链接并通过可信渠道交付。 +- 以后启用 SMTP 时,必须将 `IDENTITY_MANUAL_INVITATION_ENABLED` 改为 `false`,SMTP 凭据只能来自受保护环境或只读 secret 文件。 +- 互联网反向代理必须覆盖客户端传入的 `X-Real-IP` / `X-Forwarded-For`,避免伪造 IP 绕过验证码限流。 - 当前短读缓存是进程内缓存;当 `CONTRACT_API_UVICORN_WORKERS>1` 时会自动禁用。生产扩容到多 worker 前,应先接入支持跨进程失效的共享缓存。 ## 回滚策略 @@ -109,3 +120,5 @@ YUQEI_WORKER_DATABASE_AUTO_CREATE=false - Trace governance 的 24 小时 smoke 观察窗口列为上线前检查项,production 暂不开启,不阻塞当前交付收口。 - 代码与依赖审计已经可以在本机或 CI 独立完成,不依赖 Docker。 - 源码 secret 门禁使用 `detect-secrets==1.5.0` 和已审计基线;新增未审计命中会阻断 CI,扫描 artifact 写入 `project-docs/qa-artifacts/v2-source-secret-scan`。 +- 身份线下开通、SMTP 与验证码安全的配置、验证和回滚说明见 `project-docs/V2-production-identity-email-security-2026-07-13.md`。 +- backend-only production compose、HTTPS edge、只读 preflight、异地备份/恢复验证和真实 Provider 18 样本验收见 `project-docs/V2-production-infrastructure-provider-acceptance-2026-07-16.md` 与 `yuqei-infra/deploy/README.md`。这些入口默认不切换流量。 diff --git a/project-docs/V2-operations-launch-readiness-2026-07-13.md b/project-docs/V2-operations-launch-readiness-2026-07-13.md index f8d1eb62..392ecf4c 100644 --- a/project-docs/V2-operations-launch-readiness-2026-07-13.md +++ b/project-docs/V2-operations-launch-readiness-2026-07-13.md @@ -1,11 +1,12 @@ # AI 合同平台 V2 运营上线评估 日期:2026-07-13 +最新更新:2026-07-16 ## 结论 - **内部 UAT / 受控单租户试点:可以进入。** 当前候选版本已部署到 `192.168.3.90` smoke,并通过远端身份边界、核心页面、资产计数、schema-check 和 Docker smoke 验证。 -- **公开多企业 SaaS 生产:当前 No-Go。** 阻断项不是模板或 Prompt 数量,而是真实邮件交付、真实 AI Provider 评估、内容法务签批和生产运行保障。 +- **公开多企业 SaaS 生产:当前 No-Go。** 无企业邮箱已通过“管理员线下开通”规避,不再是当前阻断项;剩余阻断项是真实 AI Provider 评估、内容法务签批、生产运行保障,以及不可变 RC/CI。 - 不建议继续横向增加模板数量。下一阶段应优先完成内容治理、真实模型质量评估和生产运行保障。 ## 当前能力 @@ -17,7 +18,7 @@ | Prompt | V1 / 打包资产 / smoke 已发布资产均为 46 | 资产齐套;本轮已修正法律问答和行业审查 Prompt 选择,但尚未在真实模型上验收 | | 法律全文库 | smoke 运行时 789,867 条,已有版本表和法规影响扫描 | 规模达标;重点转为时效性、来源、失效标记和抽样准确性 | | 合同主链路 | 起草、审批、审查、条款修复、版本、归档、Word/PDF 报告、文件仓库已闭环 | 可进入 UAT | -| 租户与账号 | 注册公司、邀请、登录、密码重置和管理页已具备 | 邮件仍是开发交付模式;SSO 仅有配置存储,不是完整登录流程 | +| 租户与账号 | 登录、管理员建租户、线下一次性邀请、SMTP、验证码多维限流和管理页已具备 | 首发关闭公开注册/找回/邮箱验证;无邮箱模式已通过远端 smoke,SSO 仅有配置存储 | | AI 运行 | smoke Provider 调用关闭并启用 fallback | 只能验证工程链路,不能作为生产 AI 质量证据 | 资产精确核对证据: @@ -38,11 +39,25 @@ 4. AI Platform 增加服务密钥;33 个 Web AI 代理路由统一从签名会话注入租户和用户身份。 5. Contract API、审查 worker 和 Python SDK 支持向 AI Platform 传递服务密钥。 6. 生产响应不再返回邀请原始 token 和邮箱开发验证码。 +7. 新增 SMTP 验证码/密码重置/邀请邮件交付,登录与密码重置统一防枚举响应。 +8. 验证码增加邮箱、IP、租户限流、重发冷却和可持久化的 5 次失败锁定。 +9. AI 管理代理增加服务端角色校验,普通已登录用户不能直接访问模型、Prompt、runtime、知识库、质量和 Trace 管理接口。 -这些变更已通过本地测试、生产构建和远端 smoke 验证。 +`20260713_0031` 身份安全迁移和无邮箱模式已通过本地回归、远端 PostgreSQL smoke 和接口实测。 ## 远端验证证据 +2026-07-16 最新候选: + +- Docker smoke:`project-docs/qa-artifacts/v2-smoke-docker-deploy/2026-07-16T11-12-47`,全部必选检查通过。 +- 发布质量门:`project-docs/qa-artifacts/v2-release-quality-gate/2026-07-16T11-18-52`,Contract API schema 为 `20260713_0031 (head)`,AI Platform 仅保留允许的 `provider_runtime` warning。 +- 无邮箱身份闭环:公开注册、密码重置、邮箱验证均为 `403`;管理员登录、线下邀请、员工设密、员工登录均为 `200`;数据库邀请状态为 `manual|accepted`。 +- 接口压测:7 个场景错误数均为 0;合同列表 p95 `95.54ms`,统一搜索 p95 `133.27ms`,AI Trace p95 `402.13ms`。 +- 本地门禁:Contract API 全量测试、AI Platform API 测试、worker 测试、Python SDK 测试、前端 production build 全部通过;npm audit、pip-audit、Bandit、tracked-source secret scan 均为 0 新问题。 +- 远端根卷使用率约 `85%`,剩余约 `29GB`;生产切流前必须配置磁盘告警和镜像/日志清理策略。 + +此前 2026-07-13 基线证据: + - Docker smoke:`project-docs/qa-artifacts/v2-smoke-docker-deploy/2026-07-13T13-18-14/v2-smoke-docker-deploy-summary.md`,8 个长期服务健康,迁移、Prompt 导入、黄金样本和审查 worker smoke 通过。 - 鉴权边界:Contract API 与 AI Platform 数据接口缺失或使用错误服务密钥时返回 `401`,使用当前 smoke 服务密钥时返回 `200`;健康端点保持可用。 - Schema:Contract API `status=ok`,Alembic `20260710_0030`;AI Platform Alembic `20260703_0021`,仅 `provider_runtime=warning`,原因是 smoke 明确关闭真实 Provider 并启用 fallback。 @@ -56,13 +71,13 @@ ## 生产阻断项 -### P0-1 远端发布候选验证(已完成) +### P0-1 远端验证已完成,RC 冻结待完成 - [x] 部署当前代码到 `192.168.3.90` smoke。 - [x] 验证无代理密钥直连 Contract API / AI Platform 数据接口返回 `401`。 - [x] 验证 Web 登录后合同、模板、文件、AI Trace、知识库均正常。 - [x] 复跑两套 schema-check、资产精确核对和 Docker smoke。 -- [x] 当前提交 CI 全绿,并复跑带最新候选代码的接口压测与安全质量门,作为生产审批证据。 +- [ ] 将当前 dirty worktree 固化为不可变提交/RC tag,并让新增 build-test、security、release 三套 CI 对该提交实际全绿。 ### P0-2 真实 AI Provider 与质量验收 @@ -72,13 +87,20 @@ - 用真实模型复跑 generation / review / legal QA / clause recommendation 黄金样本。 - 每个高风险模板至少由法务抽样一次,检查风险命中、引用准确、建议可执行和幻觉。 -### P0-3 邮件与账号安全 +### P0-3 身份与账号安全 -- 接入真实邮件服务,发送验证码、密码重置和邀请链接。 -- 增加验证码错误次数、发送频率、IP / 账号限流和通用防枚举响应。 -- 当前 runtime config 在 production 会明确报错 `identity_email_delivery`,不能通过改一个布尔配置假装完成。 +- [x] 代码已接入 SMTP,覆盖验证码、密码重置和邀请链接。 +- [x] 已增加验证码失败锁定、重发冷却、邮箱/IP/租户限流和通用防枚举响应。 +- [x] production runtime config 支持 SMTP 或显式的管理员线下开通模式,并继续要求独立限流 secret。 +- [x] 无邮箱首发关闭公开注册、密码重置、邮箱验证;租户管理员可线下交付一次性邀请链接。 +- [x] 增加幂等的生产平台管理员 bootstrap;密码从标准输入/只读文件读取,轮换需显式确认。 +- [x] 在 `192.168.3.90` smoke 运行迁移 `20260713_0031`,完成管理员登录、线下邀请、员工设密登录实测。 +- [ ] 确认反向代理覆盖客户端伪造的 `X-Forwarded-For`。 +- SMTP 实发和失败告警推迟到企业邮箱具备后,不阻塞当前受控首发,但启用邮件前必须单独验收。 - SSO 未实现实际 SAML/OIDC 发起、回调和账号绑定。生产首发可禁用 SSO,但不能对外宣称已支持。 +实现与 smoke 验收步骤见 `project-docs/V2-production-identity-email-security-2026-07-13.md`。 + ### P0-4 内容法务签批 30 个模板不需要继续扩容,需补齐运营治理信息: @@ -96,6 +118,8 @@ - PostgreSQL、文件仓库备份恢复和应用回滚再演练一次。 - API 错误率、p95/p99、数据库连接、worker 心跳、AI 失败率和备份结果接入告警。 - 明确隐私政策、数据保留期、客户数据删除流程和人工支持责任人。 +- `yuqei-infra/backup`、`observability`、`nginx`、`deploy` 已补 production compose、HTTPS edge、严格 preflight、基础设施备份/恢复校验和最小健康告警脚本;当前仅本地实现并通过脚本测试,尚未在 3.90 production 环境以真实域名、异地挂载和 digest 镜像部署演练。应用级 backup API 仍不能替代该基础设施闭环。 +- smoke 使用 `staging + seed_demo_data=true`,其中有 12,000+ 合同和测试账号,不能直接改名作为生产库。应保留独立 smoke 库,并建立唯一的生产权威库及数据库注册记录。 ## 模板与 Prompt 优化策略 @@ -121,10 +145,12 @@ 满足以下条件后,才建议创建新的不可变 RC tag 并进入生产发布审批: -1. 本轮身份 / AI 服务鉴权代码已提交、CI 全绿并完成远端 smoke。 +1. 本轮代码已形成不可变提交/RC tag,新建 build-test CI、security CI 和 release gate 全绿。 2. 真实 AI Provider 黄金样本通过,fallback 关闭。 -3. 真实邮件发送、限流和防枚举回归通过。 +3. 无邮箱模式在 smoke 完成管理员建租户、线下邀请、员工设密登录,公开自助入口保持关闭;未来启用 SMTP 时另行验收。 4. 30 个模板和关键 Prompt 有具名法务签批记录。 5. 安全扫描、接口压测、备份恢复、回滚和监控告警均有最新证据。 +生产基础设施和 Provider 验收实现说明见 `project-docs/V2-production-infrastructure-provider-acceptance-2026-07-16.md`。 + 在这些条件完成前,系统可以用于内部验收和受控试点,但不应开放公开多企业注册和正式付费承诺。 diff --git a/project-docs/V2-production-identity-email-security-2026-07-13.md b/project-docs/V2-production-identity-email-security-2026-07-13.md new file mode 100644 index 00000000..e34fee45 --- /dev/null +++ b/project-docs/V2-production-identity-email-security-2026-07-13.md @@ -0,0 +1,126 @@ +# AI 合同平台 V2 生产身份交付与验证码安全闭环 + +日期:2026-07-13 + +## 本片结论 + +本片完成 SMTP、管理员线下开通、验证码限流和账号防枚举的代码闭环。当前首发不依赖企业邮箱: + +- 本地实现与回归:完成。 +- 数据库迁移:`20260713_0031`,空库升级、降级到 `20260710_0030`、再次升级均通过。 +- `192.168.3.90` smoke 部署:已完成;迁移、无邮箱模式、管理员线下邀请、员工设密和登录闭环均已实测通过。 +- 首发身份方式:`disabled + self-service=false + manual-invitation=true`。 +- SMTP:代码保留,待取得企业邮箱后另行配置和实发验收,不阻塞当前受控上线。 + +### 无邮箱首发模式 + +- 登录保持可用。 +- 平台管理员在 `/admin/identity` 创建公司租户和首个管理员。 +- 租户管理员创建员工邀请,后台仅在创建时返回一次性邀请链接,并记录 `delivery_status=manual`。 +- 公开公司注册、在线密码重置和邮箱验证关闭;前端隐藏入口,后端接口同时返回 `403`。 +- 员工通过一次性邀请链接设置密码。账号访问问题暂由所属公司管理员线下处理。 +- 首个生产平台管理员通过一次性 bootstrap 命令创建,密码只从标准输入或只读文件读取,不放进镜像、仓库或命令参数。 + +## 已实现能力 + +### 邮件交付 + +- 支持 SMTP、STARTTLS、SMTP over SSL、认证、连接超时、发件人名称和地址。 +- SMTP 密码支持受保护环境变量,也支持容器挂载文件引用。 +- 邮箱验证和密码重置验证码通过 SMTP 发送。 +- 企业邀请链接通过 SMTP 发送;生产响应继续隐藏原始邀请 token。 +- SMTP 发送在 HTTP 响应后执行,密码重置接口不会因已知账号需要等待网络发送而产生明显枚举时延。 +- challenge 和 invitation 均记录 `queued / sent / failed / development / manual` 交付状态、尝试时间、成功时间和脱敏错误类型。 + +### 验证码安全 + +- 验证码仍只保存 SHA-256 哈希,生产响应不返回 `dev_code`。 +- challenge 有效期限制为 5 至 30 分钟。 +- 第 5 次错误输入立即落库为 `locked`;修复了旧实现异常回滚导致失败次数未持久化的问题。 +- 重发冷却默认 60 秒。 +- 默认 1 小时窗口:单邮箱 5 次、单 IP 20 次、单租户 100 次。 +- 限流事件只保存使用独立 secret 计算的 HMAC 哈希,不保存原始 IP、邮箱或租户标识。 +- PostgreSQL 使用按限流维度排序的事务级 advisory lock,避免多容器并发绕过计数。 + +### 防枚举与登录保护 + +- 已知邮箱、未知邮箱和未知租户的密码重置请求均返回同形 `200` 结果。 +- 生产密码重置成功响应设置 120ms 基础时延和最多 30ms 随机抖动,进一步降低数据库路径差异带来的时序枚举信号。 +- 登录的未知账号、错误密码、多租户歧义和禁用账号统一返回 `401` 与相同文案。 +- 不存在账号和无密码凭据账号执行 dummy PBKDF2 校验,降低密码校验时延差异。 +- 旧的 `legacy_email_session` 无密码登录路径已关闭;缺少凭据的历史账号需走密码重置。 +- 登录默认 15 分钟窗口:单邮箱 10 次、单 IP 50 次;成功登录会清除该邮箱失败窗口,IP 窗口继续保留。 + +## 生产变量 + +当前无邮箱首发配置: + +```text +CONTRACT_API_IDENTITY_EMAIL_DELIVERY_MODE=disabled +CONTRACT_API_IDENTITY_SELF_SERVICE_ENABLED=false +CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED=true +CONTRACT_API_IDENTITY_RATE_LIMIT_SECRET=LOAD_A_STABLE_RANDOM_SECRET_OF_AT_LEAST_32_CHARACTERS +``` + +以后启用企业邮箱时切换为: + +```text +CONTRACT_API_IDENTITY_EMAIL_DELIVERY_MODE=smtp +CONTRACT_API_IDENTITY_SELF_SERVICE_ENABLED=true +CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED=false +CONTRACT_API_IDENTITY_SMTP_HOST=smtp.example.com +CONTRACT_API_IDENTITY_SMTP_PORT=587 +CONTRACT_API_IDENTITY_SMTP_USERNAME=mailer@example.com +CONTRACT_API_IDENTITY_SMTP_PASSWORD=LOAD_FROM_PROTECTED_ENV +CONTRACT_API_IDENTITY_SMTP_FROM_EMAIL=mailer@example.com +CONTRACT_API_IDENTITY_SMTP_FROM_NAME=AI 合同平台 +CONTRACT_API_IDENTITY_SMTP_USE_STARTTLS=true +CONTRACT_API_IDENTITY_SMTP_USE_SSL=false +CONTRACT_API_IDENTITY_SMTP_TIMEOUT_SECONDS=10 +CONTRACT_API_IDENTITY_PUBLIC_WEB_BASE_URL=https://contracts.example.com +CONTRACT_API_IDENTITY_RATE_LIMIT_SECRET=LOAD_A_STABLE_RANDOM_SECRET_OF_AT_LEAST_32_CHARACTERS +``` + +也可用 `CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE` 指向容器内只读 secret 文件。不要同时开启 STARTTLS 和 SSL。`IDENTITY_RATE_LIMIT_SECRET` 必须跨重启保持稳定,且不得与 smoke 共用。 + +完整默认限流变量见: + +- `yuqei-contract-platform/services/contract-api/.env.example` +- `yuqei-infra/compose/.env.production.example` + +## 反向代理要求 + +- 互联网入口必须覆盖客户端传入的 `X-Real-IP` / `X-Forwarded-For`,不能原样信任客户端伪造值。 +- Contract Web 从受控入口头提取客户端 IP,并通过已认证的内部请求头 `X-Identity-Client-IP` 传给 Contract API。 +- Contract API 仅在 `CONTRACT_API_REQUIRE_WEB_PROXY_AUTH=true` 时信任该内部头。 + +## 验证证据 + +- Contract API 全量测试:通过。 +- 身份专项测试覆盖 SMTP 成功/失败、邀请投递、未知邮箱同形响应、邮箱/IP/租户限流、重发冷却、第五次错误锁定和生产验证码隐藏。 +- Frontend `npm run typecheck`:通过。 +- Frontend `npm run build`:通过,83 个路由生成成功。 +- Bandit Contract API 全量扫描:0 告警。 +- tracked-source detect-secrets:`new=0`。 +- 新增身份文件 detect-secrets 单独扫描:0 候选。 + +## Smoke 验收清单 + +最新通过证据: + +- Docker smoke:`project-docs/qa-artifacts/v2-smoke-docker-deploy/2026-07-16T11-12-47` +- 发布质量门:`project-docs/qa-artifacts/v2-release-quality-gate/2026-07-16T11-18-52` +- Contract API Alembic head:`20260713_0031` +- 无邮箱身份闭环:公开注册/找回/邮箱验证均为 `403`;管理员登录、线下邀请、员工设密和登录均通过。 + +1. 备份 `yuqei_contract`,部署候选镜像并运行 Alembic 到 `20260713_0031`。 +2. `/api/v1/runtime/schema-check` 中 `identity_security_columns=ok`。 +3. `/api/v1/runtime/config-check` 中 `identity_email_delivery=ok`、`identity_rate_limit=ok`。 +4. 用只读 secret 文件运行管理员 bootstrap,并确认重复执行返回 `already_configured`。 +5. 验证 `/identity/capabilities` 显示注册、密码重置和邮箱验证关闭,手工邀请开启。 +6. 验证公开注册、密码重置和邮箱验证接口返回 `403`。 +7. 平台管理员创建测试租户,租户管理员生成一次性邀请链接。 +8. 用该链接设置员工密码并完成登录,确认邀请记录为 `manual` 且 token 只在创建响应出现。 +9. 检查日志无验证码、邀请 token 或完整凭据泄露。SMTP 上线时再执行三类邮件实发和失败告警专项验收。 + +完成无邮箱 smoke 闭环后,当前身份阻断项可关闭;SMTP 验收作为未来启用邮件功能的独立准入项。 diff --git a/project-docs/V2-production-infrastructure-provider-acceptance-2026-07-16.md b/project-docs/V2-production-infrastructure-provider-acceptance-2026-07-16.md new file mode 100644 index 00000000..dc599d21 --- /dev/null +++ b/project-docs/V2-production-infrastructure-provider-acceptance-2026-07-16.md @@ -0,0 +1,66 @@ +# AI 合同平台 V2 生产基础设施与真实 Provider 验收 + +日期:2026-07-16 + +## 本片边界 + +本片只准备生产基础设施并建立真实模型验收门,不部署生产数据、不启动 production compose、不修改 DNS、不切换阿里云前端流量。 + +## 已实现 + +1. 新增 backend-only production compose override:应用镜像必须使用 digest,生产卷名稳定,Contract Web 默认不在 3.90 启动。 +2. PostgreSQL、Redis、MinIO、Contract API、AI Platform API 的宿主端口在生产只绑定 `127.0.0.1`。 +3. 新增 HTTPS Nginx edge,统一提供 `/contract/` 和 `/ai/`,并覆盖客户端伪造的 `X-Forwarded-For`。 +4. Provider key 使用 Docker secret 文件;secret ref 新增受限 `file://`,只允许读取配置的 `/run/secrets` 根目录。 +5. `env:` secret ref 只允许 AI Provider/OCR 专用变量前缀,不能再读取数据库连接等任意进程环境变量。 +6. 新增只读 production preflight,阻断 smoke/test 库、demo seed、fallback、公开内部端口、默认口令、可变镜像和缺失备份/TLS/Provider secret。 +7. 新增 PostgreSQL、合同文件、MinIO 备份脚本和 SHA-256 manifest。 +8. 新增只读完整性校验和一次性 `yuqei_restore_check_*` 数据库恢复演练。 +9. 新增最小生产健康探针和 systemd timer,覆盖 schema、Provider、worker、备份新鲜度和磁盘水位。 +10. 新增真实 Provider 验收脚本,覆盖 V1 Phase13 的 10 条法律问答和 8 条合同审查样本。 +11. Web AI 管理代理增加服务端角色门禁:模型、Prompt、runtime 仅 `admin`;知识库、质量和 Trace 仅 `admin/legal`,普通已登录用户直接调用返回 `403`。 + +## Provider 验收判定 + +脚本 `yuqei-infra/scripts/run-ai-provider-acceptance.py` 分开输出: + +- `failed`:Provider 调用失败、出现 fallback、输出为空、缺引用、未命中任何预期法律来源或审查严重度低于样本边界。 +- `needs_legal_review`:技术调用全部成功,但样本尚未由具名法务复核并标记 `production_ready=true`。 +- `passed`:技术门通过,且全部运行样本均已有具名法务签批。 + +当前 Phase13 清单仍是 `LEGAL_REVIEW`、`production_ready=false`,因此即使真实模型技术调用全绿,也只能进入 `needs_legal_review`,不能自动解除生产阻断。 + +模板生成和条款推荐当前是模板/发布快照/规则驱动的确定性链路,不调用模型;本轮真实 Provider 验收只覆盖运行时确实调用模型的法律问答和审查摘要。合同审查的结构化风险项仍由规则层生成,Provider 当前只参与摘要。上线文案不能把确定性能力描述为模型生成;如未来改为模型结构化风险抽取,需要新增独立 JSON schema、幻觉和风险召回验收。 + +运行命令: + +```bash +python3 yuqei-infra/scripts/run-ai-provider-acceptance.py \ + --base-url https:///ai \ + --service-key-file /etc/yuqei/secrets/ai-platform-service-key \ + --tenant-id default +``` + +报告写入 `project-docs/qa-artifacts/v2-ai-provider-acceptance/`,不记录 Provider key。 + +## 尚未完成 + +1. 尚未取得真实 Provider 的 base URL、模型名、价格和 API key,因此不能执行真实模型验收。 +2. 尚未提供正式后端域名和 TLS 证书,因此不能验证 HTTPS edge。 +3. 尚未建立镜像仓库并发布三个 digest 镜像。 +4. 尚未在 3.90 挂载异地备份目录,不能执行真实备份/恢复演练。 +5. 模板、Prompt 和 18 条 Provider 样本仍需具名法务签批。 + +在上述外部配置具备前,production preflight 应继续失败,系统保持 smoke/UAT,不切生产流量。 + +## 本轮验证 + +- `python -m pytest yuqei-infra/tests -q`:6 项通过。 +- AI Platform API 全量测试:通过。 +- Contract Web typecheck 与 production build:通过,83 个路由生成成功。 +- production example preflight template mode:通过。 +- Bandit:0 findings;8 个固定 suppression 已由精确 allowlist 测试锁定。 +- `git diff --check`:通过。 +- 3.90 Docker Compose v2.27:base + production override 普通与 `ops` profile 均通过 `config --quiet`;未启动任何 production 容器。 +- 远端渲染结果:Contract Web 未激活;PostgreSQL、Redis、MinIO、Contract API、AI Platform API 均只绑定 `127.0.0.1`;edge 仅暴露 80/443。 +- 当前 smoke `provider_runtime=warning`:调用关闭、fallback 开启、默认 Provider 无 base URL/secret ref,符合“尚未真实验收”的预期。 diff --git a/project-docs/V2-rc-freeze-2026-07-20.md b/project-docs/V2-rc-freeze-2026-07-20.md new file mode 100644 index 00000000..619ca193 --- /dev/null +++ b/project-docs/V2-rc-freeze-2026-07-20.md @@ -0,0 +1,87 @@ +# AI Contract Platform V2 RC Freeze - 2026-07-20 + +## Decision + +The current candidate is frozen for release review under tag `v2-rc-20260720`. +After the tag is created, only blocking bug fixes, tests, deployment configuration, +and release documentation may be added. New product features and schema changes are +outside this RC. + +This freeze does not authorize production traffic. The candidate is approved for +smoke and production-candidate validation only. + +## Candidate scope + +- Branch: `codex/v2-rc-20260712` +- Pre-freeze parent: `57fc8d35` +- Contract API migration head: `20260713_0031` +- AI Platform migration head: `20260703_0021` +- Authoritative smoke host: `192.168.3.90` +- Compose project: `yuqei-contract-v2-smoke` +- Remote directory: `/opt/apps/ai-contract-platform-v2-smoke` + +The final candidate includes the production identity-security controls, production +deployment/backup/health-check tooling, AI Provider acceptance tooling, authenticated +review-worker integration, ambient-proxy isolation for internal AI Platform calls, +and per-key single-flight protection for Contract API read-cache misses. + +## Verification evidence + +| Gate | Result | Evidence | +| --- | --- | --- | +| Docker smoke | Pass | `project-docs/qa-artifacts/v2-smoke-docker-deploy/2026-07-20T17-47-25` | +| Contract API schema-check | Pass (`status=ok`) | `project-docs/qa-artifacts/v2-release-quality-gate/2026-07-20T18-20-08/contract_api-schema-check.json` | +| AI Platform schema-check | Pass; only the intentional smoke `provider_runtime` warning | `project-docs/qa-artifacts/v2-release-quality-gate/2026-07-20T18-20-08/ai_platform_api-schema-check.json` | +| Release quality gate | Pass | `project-docs/qa-artifacts/v2-release-quality-gate/2026-07-20T18-20-08` | +| Remote core user flow | Pass; browser console errors 0 | `project-docs/qa-artifacts/v2-core-regression/2026-07-20-smoke-pass2/core-user-flow-summary.json` | +| Source secret scan | Pass; 15 reviewed candidates, 0 new findings | `project-docs/qa-artifacts/v2-source-secret-scan/2026-07-20T18-40-00/source-secret-scan-summary.json` | +| Security scan | Pass; npm 0, Bandit 0, pip-audit 0 | `project-docs/qa-artifacts/v2-security-scan/2026-07-20T18-45-00` | + +Local verification also passed the complete Contract API suite, AI Platform API, +worker, Python SDK and production release-tool tests. Contract Web production build +and typecheck passed with 83 generated routes. + +## Performance baseline + +The release gate ran 60 requests per scenario at concurrency 6 with zero HTTP errors. + +| Scenario | p95 | Target p95 | +| --- | ---: | ---: | +| Contract list | 35.35 ms | 500 ms | +| Contract detail | 33.36 ms | 500 ms | +| Approval todo | 39.58 ms | 500 ms | +| Unified search | 84.83 ms | 500 ms | +| AI Trace list | 222.83 ms | 1500 ms | +| Knowledge document search | 72.48 ms | 800 ms | +| Knowledge chunk hybrid search | 134.08 ms | 1500 ms | + +## Functional regression + +The post-deploy browser regression verified login, template drafting, Word download, +asynchronous review, 2 structured risks, 4 citations, PDF report delivery, file +preview/download/batch download, approval rejection/resubmission/approval, archive +evidence, archived filtering, and audit-chain integrity. + +## Source hygiene + +- The candidate contains no runtime database, logs, QA artifacts, local build output, + or `.env.local` file. +- The ignored Contract Web `.env.local` contains local runtime credentials and was + intentionally excluded from both Git and deployment archives. +- `git diff --cached --check` passed before the freeze commit. + +## Production blockers + +Production remains **No-Go** until all of the following have independent evidence: + +1. Real AI Provider acceptance with fallback disabled, including 10 legal-QA and 8 + review samples, followed by named legal approval. +2. Production-only secrets, immutable image digests, backend production compose, and + the Alibaba Cloud frontend HTTPS/domain release. +3. PostgreSQL and object/file storage off-host backup plus restore rehearsal. +4. Production monitoring and alert delivery for API, database, workers, files, AI + calls, and backups. +5. CI execution for the immutable RC commit and explicit production Go/No-Go approval. + +Trace-governance long-window observation remains a non-blocking pre-production check; +production enablement still requires separate approval. diff --git a/project-docs/V2-release-readiness-checklist-2026-07-10.md b/project-docs/V2-release-readiness-checklist-2026-07-10.md index 1a77baa0..1359693c 100644 --- a/project-docs/V2-release-readiness-checklist-2026-07-10.md +++ b/project-docs/V2-release-readiness-checklist-2026-07-10.md @@ -1,11 +1,13 @@ # AI 合同平台 V2 上线准入检查清单 日期:2026-07-10 -最新更新:2026-07-12 +最新更新:2026-07-16 ## 当前结论 -V2 当前候选版本已完成本地回归和 `192.168.3.90` PostgreSQL smoke 验收。Docker smoke、两个 schema-check、核心用户主链路、30 模板全量起草、安全扫描、接口压测和发布质量门均已通过。RC 源码已在 `codex/v2-rc-20260712` 分支冻结;当前仍不直接发布生产,剩余阻塞项是生产发布审批与最终回滚演练确认。 +V2 当前候选版本已完成本地回归和 `192.168.3.90` PostgreSQL smoke 验收。Docker smoke、两个 schema-check、无邮箱身份闭环、安全扫描、接口压测和发布质量门均已通过。 + +**当前仍是生产 No-Go。** 剩余阻断项包括:真实 AI Provider 与黄金样本验收、V2 阿里云前端域名/HTTPS 发布、正式后端 production 配置、PostgreSQL 与文件/MinIO 异地备份恢复、监控告警、内容法务签批,以及把当前 dirty worktree 固化为不可变 RC 并让 CI 实际通过。 本轮本地与远端核验: @@ -57,7 +59,7 @@ python yuqei-infra\scripts\compare-v1-v2-assets.py --contract-api-base http://19 ## Release Candidate 冻结 -当前状态:RC 候选已在 `codex/v2-rc-20260712` 分支冻结。 +当前状态:`codex/v2-rc-20260712` 分支存在,但 2026-07-16 身份安全和部署修复仍在 dirty worktree,尚未形成新的不可变 RC。 RC 基线要先钉死,冻结后只允许修 bug、补测试、改配置、改文档,不再继续加大功能。 @@ -70,11 +72,11 @@ RC 基线要先钉死,冻结后只允许修 bug、补测试、改配置、改 | smoke Contract API | `http://192.168.3.90:18100` | 合同后端验收地址 | | smoke AI Platform API | `http://192.168.3.90:18101` | AI 后端验收地址 | | smoke 目录 | `/opt/apps/ai-contract-platform-v2-smoke` | 独立部署目录 | -| smoke artifact | `project-docs/qa-artifacts/v2-smoke-docker-deploy/2026-07-12T13-21-24` | 最新通过的 Docker smoke | -| release gate artifact | `project-docs/qa-artifacts/v2-release-quality-gate/2026-07-12T13-28-00` | 最新通过的质量门 | +| smoke artifact | `project-docs/qa-artifacts/v2-smoke-docker-deploy/2026-07-16T11-12-47` | 最新通过的 Docker smoke | +| release gate artifact | `project-docs/qa-artifacts/v2-release-quality-gate/2026-07-16T11-18-52` | 最新通过的质量门 | | security scan artifact | `project-docs/qa-artifacts/v2-security-scan/2026-07-12T13-08-00` | 最新通过的安全扫描 | | source secret scan artifact | `project-docs/qa-artifacts/v2-source-secret-scan/2026-07-12T15-24-56` | 冻结索引 821 个文件,16 个已审计候选、0 个新增命中、0 个陈旧基线 | -| Contract API Alembic head | `20260710_0030` | 远端 smoke 当前头 | +| Contract API Alembic head | `20260713_0031` | 远端 smoke 当前头 | | AI Platform Alembic head | `20260703_0021` | 远端 smoke 当前头 | | 权威数据库 | `yuqei_contract` / `yuqei_ai` / `yuqei_ops` | 仅保留这一组 V2 数据库 | @@ -89,7 +91,7 @@ RC 基线要先钉死,冻结后只允许修 bug、补测试、改配置、改 ### 冻结记录 - RC 分支:`codex/v2-rc-20260712`。 -- 冻结提交:包含本清单的 RC 提交;以 Git 历史中的该分支 HEAD 为准。 +- 当前 HEAD `57fc8d35` 不包含本轮未提交身份修复;生产发布前必须提交、跑 CI 并创建新 RC tag。 - 本地日志、Playwright 临时快照、pytest 临时目录、运行时数据库和 QA artifact 均未进入源码提交。 - 本地、smoke、准生产服务与数据库边界已经记录。 - smoke、schema-check、security scan、source secret scan、interface benchmark 和 release gate artifact 已记录。 @@ -98,7 +100,7 @@ RC 基线要先钉死,冻结后只允许修 bug、补测试、改配置、改 | 模块 | 场景 | 入口 | 通过标准 | | --- | --- | --- | --- | -| 登录与租户 | 注册公司、创建租户、邀请员工、密码重置、邮箱验证码、SSO 入口 | `/register`、`/login`、`/invite`、`/password-reset`、`/admin/identity` | 新用户能进入正确租户空间,验证码/重置/邀请/禁用/过期都按预期拦截 | +| 登录与租户 | 管理员建租户、线下邀请、员工设密登录;公开注册/重置/邮箱验证关闭 | `/login`、`/invite/accept`、`/admin/identity` | 用户进入正确租户;关闭入口前后端同时拦截;邀请 token 只在管理员创建响应出现 | | 工作台与台账 | 待办、分页、归档筛选、状态筛选 | `/workspace`、`/contracts` | 列表、分页、筛选、归档状态展示一致,不能把非归档混进已归档视图 | | 统一搜索与数据看板 | 合同、模板、文件、法律资产统一搜索,数据看板总览 | `/search`、`/dashboard` | 搜索结果稳定返回,dashboard 非空且与当前数据一致 | | 模板起草 | 30 个模板全量生成草稿 | `/legal-documents` | 30/30 模板都能可见、可选、可生成草稿,字段顺序和正文可读性正常 | @@ -129,6 +131,8 @@ RC 基线要先钉死,冻结后只允许修 bug、补测试、改配置、改 - 安全扫描:Bandit、依赖漏洞、detect-secrets secret 扫描、越权接口抽样。 - 接口级压测:合同列表、详情、工作台、模板树、文件列表、统一搜索、AI Trace、法律问答。 - 备份恢复和回滚演练:PostgreSQL、文件仓库、Docker compose、前端发布包。 +- 真实 AI Provider:`enabled`、fallback 关闭,generation/review/legal QA/clause recommendation 黄金样本通过。 +- 生产域名与运行环境:阿里云 V2 前端 HTTPS、3.90 production compose、独立生产 secrets、`seed_demo_data=false`。 ### P1 建议上线前完成 @@ -139,10 +143,11 @@ RC 基线要先钉死,冻结后只允许修 bug、补测试、改配置、改 ## 下一步执行顺序 -1. 复核现有 PostgreSQL、文件仓库和 Docker compose 回滚证据,完成生产发布审批。 -2. 固化生产环境变量、域名/HTTPS、告警接收人和初始化管理员交接信息。 -3. 按已通过的 smoke artifact 发布生产,发布后立即执行相同 schema-check 与核心 canary。 -4. 保留 Trace governance 24 小时观察为上线后/上线前非阻塞观察项,生产开启仍需单独批准。 +1. 准备真实 production env、三个 digest 镜像、正式域名/TLS 和异地备份挂载,运行只读 production preflight 与 compose config。 +2. 在不切流量的 staging/production-candidate 环境配置真实 Provider,跑 10 条法律问答和 8 条审查样本并完成具名法务签批。 +3. 执行 PostgreSQL/合同文件/MinIO 备份,完成一次性 `yuqei_restore_check_*` 数据库恢复演练并验证最小健康告警。 +4. 固化不可变 RC 并让 build-test、security、release 三套 CI 全绿后,另行审批 edge/DNS 和阿里云前端切流。 +5. 保留 Trace governance 24 小时观察为上线后/上线前非阻塞观察项,production 开启仍需单独批准。 ## 本轮本地 smoke 数据 @@ -164,3 +169,5 @@ RC 基线要先钉死,冻结后只允许修 bug、补测试、改配置、改 - 2026-07-11:数据备份与恢复已在 192.168.3.90 smoke 复核通过,`POST /api/v1/backup`、`GET /api/v1/backup`、`GET /api/v1/backup/{id}`、`POST /api/v1/backup/{id}/validate` 均已验证。 - 2026-07-12:本地 RC 核心回归完成。登录、模板起草、Word、异步审查、结构化风险/引用、审查 PDF、文件单个/批量下载、合同编辑、审批退回重提、归档、审计完整性全部通过;30 个模板全量生成 30/30 通过;Contract API 全量测试 146 passed,前端 production build 通过。详情见 `project-docs/V2-rc-core-regression-2026-07-12.md`。 - 2026-07-12:当前候选版本已部署到 `192.168.3.90`。Contract API / AI Platform PostgreSQL schema-check 均为 `ok`;Docker smoke、远端核心浏览器主链路、30 模板全量起草、安全扫描、7 场景接口压测和发布质量门全部通过。最新 artifact 分别为 `v2-smoke-docker-deploy/2026-07-12T13-21-24`、`v2-security-scan/2026-07-12T13-08-00`、`v2-interface-benchmark/2026-07-12T13-24-00`、`v2-release-quality-gate/2026-07-12T13-28-00`。 +- 2026-07-16:无邮箱生产模式完成并部署 smoke。公开自助身份入口为 `403`,管理员线下邀请与员工登录闭环通过;Contract API migration head 为 `20260713_0031`,最新 Docker smoke 与 release gate 均通过。生产仍因 Provider、域名/HTTPS、备份监控、内容签批和不可变 RC 未完成而保持 No-Go。 +- 2026-07-16:production 基础设施第一片已在源码完成:backend-only compose、loopback 内部端口、HTTPS edge、Docker secret Provider key、严格 preflight、基础设施备份/恢复验证、最小健康监控和真实 Provider 18 样本验收脚本;尚未在 3.90 production 环境部署,不改变 No-Go 和不切流量结论。 diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/secret_refs.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/secret_refs.py index 4f5ae4d9..f7d8c7fd 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/secret_refs.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/secret_refs.py @@ -1,6 +1,11 @@ from __future__ import annotations import os +from pathlib import Path + + +ALLOWED_ENV_PREFIXES = ("AI_PROVIDER_", "AI_PLATFORM_PROVIDER_", "AI_PLATFORM_OCR_") +DEFAULT_PROTECTED_FILE_ROOT = "/run/secrets" def resolve_secret_ref(secret_ref: str | None) -> str | None: @@ -10,7 +15,35 @@ def resolve_secret_ref(secret_ref: str | None) -> str | None: if not normalized: return None if normalized.startswith("env://"): - return os.getenv(normalized.removeprefix("env://").strip()) + return _resolve_env_secret(normalized.removeprefix("env://").strip()) if normalized.startswith("env:"): - return os.getenv(normalized.removeprefix("env:").strip()) + return _resolve_env_secret(normalized.removeprefix("env:").strip()) + if normalized.startswith("file://"): + return _resolve_file_secret(normalized.removeprefix("file://").strip()) return None + + +def _resolve_env_secret(variable_name: str) -> str | None: + if not variable_name or not variable_name.startswith(ALLOWED_ENV_PREFIXES): + return None + value = os.getenv(variable_name) + return value.strip() if value and value.strip() else None + + +def _resolve_file_secret(path_value: str) -> str | None: + if not path_value: + return None + configured_root = os.getenv("AI_PLATFORM_SECRET_FILE_ROOT", DEFAULT_PROTECTED_FILE_ROOT) + try: + root = Path(configured_root).resolve(strict=True) + path = Path(path_value).resolve(strict=True) + path.relative_to(root) + except (OSError, ValueError): + return None + if not path.is_file() or path.stat().st_size > 64 * 1024: + return None + try: + value = path.read_text(encoding="utf-8").strip() + except (OSError, UnicodeError): + return None + return value or None diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py index cd736a1f..cc359fc9 100644 --- a/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py @@ -162,7 +162,7 @@ def test_runtime_health_ocr_config_and_object_storage_probe() -> None: "provider": "http", "display_name": "Test OCR", "endpoint_url": "https://ocr.example.test/health", - "api_key_secret_ref": "env:AI_PLATFORM_TEST_OCR_KEY", + "api_key_secret_ref": "env:AI_PLATFORM_OCR_TEST_KEY", "timeout_seconds": 5, "enabled": True, "changed_by": "admin", @@ -174,7 +174,7 @@ def test_runtime_health_ocr_config_and_object_storage_probe() -> None: assert ocr_config["display_name"] == "Test OCR" assert ocr_config["api_key_set"] is True assert ocr_config["api_key_ref_set"] is True - assert ocr_config["api_key_secret_ref"] == "env:AI_PLATFORM_TEST_OCR_KEY" + assert ocr_config["api_key_secret_ref"] == "env:AI_PLATFORM_OCR_TEST_KEY" assert "api_key" not in ocr_config ocr_get_response = client.get("/api/v1/runtime/ocr-provider") @@ -287,7 +287,7 @@ def test_sqlalchemy_knowledge_chunk_search_uses_single_flight_cache(monkeypatch, def test_ocr_provider_runtime_resolves_secret_ref(monkeypatch) -> None: - monkeypatch.setenv("AI_PLATFORM_TEST_OCR_KEY", "ocr-secret-ref-key") + monkeypatch.setenv("AI_PLATFORM_OCR_TEST_KEY", "ocr-secret-ref-key") repository = InMemoryAiPlatformRepository() masked = repository.upsert_ocr_provider_config( @@ -295,7 +295,7 @@ def test_ocr_provider_runtime_resolves_secret_ref(monkeypatch) -> None: provider="http", display_name="Secret Ref OCR", endpoint_url="https://ocr.example.test", - api_key_secret_ref="env:AI_PLATFORM_TEST_OCR_KEY", + api_key_secret_ref="env:AI_PLATFORM_OCR_TEST_KEY", enabled=True, ) ) diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_secret_refs.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_secret_refs.py new file mode 100644 index 00000000..8274c13c --- /dev/null +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_secret_refs.py @@ -0,0 +1,28 @@ +from pathlib import Path + +from yuqei_ai_platform_api.secret_refs import resolve_secret_ref + + +def test_resolve_secret_ref_accepts_only_ai_scoped_environment_variables(monkeypatch) -> None: + monkeypatch.setenv("AI_PROVIDER_PRIMARY_KEY", " provider-secret ") + monkeypatch.setenv("DATABASE_URL", "must-not-be-readable") + + assert resolve_secret_ref("env:AI_PROVIDER_PRIMARY_KEY") == "provider-secret" + assert resolve_secret_ref("env://DATABASE_URL") is None + + +def test_resolve_secret_ref_reads_only_files_under_configured_secret_root( + monkeypatch, + tmp_path: Path, +) -> None: + secret_root = tmp_path / "secrets" + secret_root.mkdir() + secret_file = secret_root / "provider-key" + secret_file.write_text(" file-secret\n", encoding="utf-8") + outside_file = tmp_path / "outside-secret" + outside_file.write_text("outside", encoding="utf-8") + monkeypatch.setenv("AI_PLATFORM_SECRET_FILE_ROOT", str(secret_root)) + + assert resolve_secret_ref(f"file://{secret_file}") == "file-secret" + assert resolve_secret_ref(f"file://{outside_file}") is None + assert resolve_secret_ref("file:///missing/provider-key") is None diff --git a/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py b/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py index 6276ea64..6b319681 100644 --- a/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py +++ b/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py @@ -629,6 +629,7 @@ class AiPlatformClient: timeout: float = 30.0, transport: httpx.BaseTransport | None = None, service_key: str | None = None, + trust_env: bool = False, ) -> None: self._api_prefix = api_prefix.rstrip("/") self._client = httpx.Client( @@ -636,6 +637,7 @@ class AiPlatformClient: timeout=timeout, transport=transport, headers={"X-AI-Platform-Service-Key": service_key} if service_key else None, + trust_env=trust_env, ) def close(self) -> None: diff --git a/yuqei-ai-sdk-python/tests/test_ai_platform.py b/yuqei-ai-sdk-python/tests/test_ai_platform.py index bbb73d6a..74530851 100644 --- a/yuqei-ai-sdk-python/tests/test_ai_platform.py +++ b/yuqei-ai-sdk-python/tests/test_ai_platform.py @@ -24,6 +24,16 @@ from yuqei_sdk import ( ) +def test_ai_platform_client_ignores_ambient_proxy_by_default() -> None: + client = AiPlatformClient( + "http://ai-platform.test", + transport=httpx.MockTransport(lambda _request: httpx.Response(200, json={})), + ) + + assert client._client._trust_env is False # noqa: SLF001 + client.close() + + def test_ai_platform_client_posts_legal_qa_with_context_headers() -> None: captured_request: httpx.Request | None = None diff --git a/yuqei-contract-platform/apps/contract-web/src/app/admin/identity/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/admin/identity/page.tsx index 115d6921..f6fc4e6a 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/admin/identity/page.tsx +++ b/yuqei-contract-platform/apps/contract-web/src/app/admin/identity/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { Building2, MailCheck, Plus, Save, Settings, ShieldCheck, UsersRound } from "lucide-react"; +import { Building2, Copy, MailCheck, Plus, Save, Settings, ShieldCheck, UsersRound } from "lucide-react"; import { type AccountSettings, type RolePermissionsPage, @@ -15,6 +15,7 @@ import { getIdentityUsers, } from "@/services/contracts-client"; import { getRoleLabel, type UserRole } from "@/lib/access-control"; +import { getIdentityCapabilities, type IdentityCapabilities } from "@/lib/identity-capabilities"; type TenantStatus = TenantInfo["status"]; type TenantUserStatus = TenantUserItem["status"]; @@ -93,6 +94,7 @@ export default function IdentityAdminPage() { const [saving, setSaving] = useState(null); const [message, setMessage] = useState(null); const [inviteLink, setInviteLink] = useState(null); + const [capabilities, setCapabilities] = useState(null); const [tenantDraft, setTenantDraft] = useState({ id: "", name: "", @@ -157,11 +159,13 @@ export default function IdentityAdminPage() { async function loadInitialData() { setLoading(true); try { - const [tenantPage, rolePage, accountSettings] = await Promise.all([ + const [tenantPage, rolePage, accountSettings, identityCapabilities] = await Promise.all([ getIdentityTenants(), getIdentityRoles(), getAccountSettings(), + getIdentityCapabilities().catch(() => null), ]); + setCapabilities(identityCapabilities); setTenants(tenantPage.items); setRoles(rolePage); setAccount(accountSettings); @@ -321,8 +325,11 @@ export default function IdentityAdminPage() { setUserDraft({ email: "", display_name: "", role: "business", status: "invited", department: "", title: "" }); if (invitation.invite_token) { setInviteLink(`${window.location.origin}/invite/accept?token=${encodeURIComponent(invitation.invite_token)}`); + setMessage("邀请已创建,请把邀请链接发送给员工完成密码设置。"); + } else { + setInviteLink(null); + setMessage("邀请已创建,系统正在发送邀请邮件。"); } - setMessage("邀请已创建,请把邀请链接发送给员工完成密码设置。"); } catch (error) { setMessage(error instanceof Error ? error.message : "用户创建失败。"); } finally { @@ -330,6 +337,18 @@ export default function IdentityAdminPage() { } } + async function copyInviteLink() { + if (!inviteLink) { + return; + } + try { + await navigator.clipboard.writeText(inviteLink); + setMessage("邀请链接已复制,请通过可信渠道发送给员工。"); + } catch { + setMessage("浏览器未允许自动复制,请手动复制邀请链接。"); + } + } + async function updateUser(user: TenantUserItem, patch: Partial>) { setSaving(`user-${user.id}`); setMessage(null); @@ -406,7 +425,7 @@ export default function IdentityAdminPage() { if (challenge.dev_code) { setEmailDraft((draft) => ({ ...draft, code: challenge.dev_code ?? "" })); } - setMessage("邮箱验证码已生成。接入邮件网关前,开发环境会直接显示验证码。"); + setMessage("邮箱验证码请求已受理。生产环境将发送邮件,开发环境会直接显示验证码。"); } catch (error) { setMessage(error instanceof Error ? error.message : "邮箱验证码发送失败。"); } finally { @@ -513,6 +532,15 @@ export default function IdentityAdminPage() { {inviteLink && (
邀请链接:{inviteLink} +
)} @@ -669,7 +697,7 @@ export default function IdentityAdminPage() { ))} -
+ {capabilities?.invitation_enabled &&
新增用户 @@ -723,7 +751,7 @@ export default function IdentityAdminPage() { 添加用户 - + }
@@ -794,8 +822,8 @@ export default function IdentityAdminPage() { -
-
+
+ {capabilities?.email_verification_enabled &&

Email verification

@@ -851,7 +879,7 @@ export default function IdentityAdminPage() { {saving === "email-verification-confirm" ? "正在确认..." : "确认邮箱"} -
+
}
diff --git a/yuqei-contract-platform/apps/contract-web/src/app/api/auth/capabilities/route.ts b/yuqei-contract-platform/apps/contract-web/src/app/api/auth/capabilities/route.ts new file mode 100644 index 00000000..e06f9a26 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/app/api/auth/capabilities/route.ts @@ -0,0 +1,14 @@ +import { buildContractApiUrl, fetchContractApi } from "@/lib/contract-api"; + +export async function GET() { + try { + const response = await fetchContractApi(buildContractApiUrl("/api/v1/identity/capabilities")); + 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/login/route.ts b/yuqei-contract-platform/apps/contract-web/src/app/api/auth/login/route.ts index cbbfc861..83eb7b98 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,4 +1,4 @@ -import { buildContractApiUrl, fetchContractApi } from "@/lib/contract-api"; +import { buildContractApiUrl, buildIdentityProxyHeaders, fetchContractApi } from "@/lib/contract-api"; import { setContractWebSession } from "@/lib/contract-session"; type IdentitySessionPayload = { @@ -18,7 +18,7 @@ export async function POST(request: Request) { const body = await request.text(); const response = await fetchContractApi(buildContractApiUrl("/api/v1/identity/login"), { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: buildIdentityProxyHeaders(request, { "Content-Type": "application/json" }), body, cache: "no-store", }); diff --git a/yuqei-contract-platform/apps/contract-web/src/app/api/auth/password-reset/request/route.ts b/yuqei-contract-platform/apps/contract-web/src/app/api/auth/password-reset/request/route.ts index bc00c563..0fa0fcaf 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/api/auth/password-reset/request/route.ts +++ b/yuqei-contract-platform/apps/contract-web/src/app/api/auth/password-reset/request/route.ts @@ -1,10 +1,10 @@ -import { buildContractApiUrl, fetchContractApi } from "@/lib/contract-api"; +import { buildContractApiUrl, buildIdentityProxyHeaders, fetchContractApi } from "@/lib/contract-api"; export async function POST(request: Request) { const body = await request.text(); const response = await fetchContractApi(buildContractApiUrl("/api/v1/identity/password-reset/request"), { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: buildIdentityProxyHeaders(request, { "Content-Type": "application/json" }), body, }); const payload = await response.json().catch(() => null); diff --git a/yuqei-contract-platform/apps/contract-web/src/app/api/contract-platform/identity/[...path]/route.ts b/yuqei-contract-platform/apps/contract-web/src/app/api/contract-platform/identity/[...path]/route.ts index 10cdd462..cb033d96 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/api/contract-platform/identity/[...path]/route.ts +++ b/yuqei-contract-platform/apps/contract-web/src/app/api/contract-platform/identity/[...path]/route.ts @@ -1,4 +1,4 @@ -import { buildContractApiUrl, proxyContractApiResponse } from "@/lib/contract-api"; +import { buildContractApiUrl, buildIdentityProxyHeaders, proxyContractApiResponse } from "@/lib/contract-api"; type RouteContext = { params: Promise<{ path?: string[] }>; @@ -24,7 +24,7 @@ export async function POST(request: Request, { params }: RouteContext) { return proxyContractApiResponse(buildIdentityTargetUrl(request, path), { method: "POST", body, - headers: { "Content-Type": "application/json" }, + headers: buildIdentityProxyHeaders(request, { "Content-Type": "application/json" }), }); } 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 94e8f1c5..a8fb5b6a 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 @@ -1,20 +1,26 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { Building2, LogIn, UserPlus } from "lucide-react"; +import { getIdentityCapabilities, type IdentityCapabilities } from "@/lib/identity-capabilities"; export default function LoginPage() { const router = useRouter(); const [saving, setSaving] = useState(false); const [message, setMessage] = useState(null); + const [capabilities, setCapabilities] = useState(null); const [draft, setDraft] = useState({ email: "", password: "", tenant_id: "", }); + useEffect(() => { + getIdentityCapabilities().then(setCapabilities).catch(() => setCapabilities(null)); + }, []); + async function submit(event: React.FormEvent) { event.preventDefault(); setSaving(true); @@ -49,10 +55,12 @@ export default function LoginPage() {

登录公司空间

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

- - - 注册公司 - + {capabilities?.registration_enabled && ( + + + 注册公司 + + )}
{message &&
{message}
} @@ -80,9 +88,11 @@ export default function LoginPage() { type="password" required /> -
- 忘记密码? -
+ {capabilities?.password_reset_enabled && ( +
+ 忘记密码? +
+ )} setDraft((value) => ({ ...value, tenant_id: event.target.value }))} 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 367f1ad6..596085e3 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 @@ -1,9 +1,10 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; 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"; type ChallengeResponse = { id: string; @@ -18,6 +19,7 @@ export default function PasswordResetPage() { const [saving, setSaving] = useState(null); const [message, setMessage] = useState(null); const [challenge, setChallenge] = useState(null); + const [capabilities, setCapabilities] = useState(); const [draft, setDraft] = useState({ email: "", tenant_id: "", @@ -26,6 +28,32 @@ export default function PasswordResetPage() { confirm_password: "", }); + useEffect(() => { + getIdentityCapabilities().then(setCapabilities).catch(() => setCapabilities(null)); + }, []); + + if (capabilities === undefined) { + return
正在读取账号策略...
; + } + + if (!capabilities?.password_reset_enabled) { + return ( +
+
+
+

Password reset

+

在线密码重置未开放

+

请联系所属公司的管理员处理账号访问问题。

+
+ + + 返回登录 + +
+
+ ); + } + async function requestCode(event: React.FormEvent) { event.preventDefault(); setSaving("request"); @@ -47,7 +75,7 @@ export default function PasswordResetPage() { if (payload.dev_code) { setDraft((value) => ({ ...value, code: payload.dev_code ?? "" })); } - setMessage("验证码已生成。接入邮件网关前,开发环境会直接显示验证码。"); + setMessage("如果该账号存在,验证码将发送到对应邮箱。开发环境会直接显示验证码。"); } catch (error) { setMessage(error instanceof Error ? error.message : "验证码发送失败。"); } finally { diff --git a/yuqei-contract-platform/apps/contract-web/src/app/register/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/register/page.tsx index ac42d979..2b0b62b8 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/register/page.tsx +++ b/yuqei-contract-platform/apps/contract-web/src/app/register/page.tsx @@ -1,14 +1,16 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { Building2, LogIn, UserPlus } from "lucide-react"; +import { getIdentityCapabilities, type IdentityCapabilities } from "@/lib/identity-capabilities"; export default function RegisterPage() { const router = useRouter(); const [saving, setSaving] = useState(false); const [message, setMessage] = useState(null); + const [capabilities, setCapabilities] = useState(); const [draft, setDraft] = useState({ company_name: "", tenant_id: "", @@ -19,6 +21,32 @@ export default function RegisterPage() { admin_title: "", }); + useEffect(() => { + getIdentityCapabilities().then(setCapabilities).catch(() => setCapabilities(null)); + }, []); + + if (capabilities === undefined) { + return
正在读取注册策略...
; + } + + if (!capabilities?.registration_enabled) { + return ( +
+
+
+

Tenant onboarding

+

公司注册未开放

+

当前由平台管理员创建公司空间和首个管理员账号。

+
+ + + 返回登录 + +
+
+ ); + } + async function submit(event: React.FormEvent) { event.preventDefault(); setSaving(true); 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 ccc6cd09..d59e0e4c 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 @@ -1,5 +1,7 @@ import { getContractWebSession } from "@/lib/contract-session"; +type AiPlatformAdminRole = "admin" | "legal"; + const aiPlatformBaseUrl = process.env.AI_PLATFORM_API_BASE_URL ?? process.env.NEXT_PUBLIC_AI_PLATFORM_API_BASE_URL ?? @@ -15,6 +17,11 @@ export async function fetchAiPlatform(targetUrl: URL, init?: RequestInit) { return Response.json({ message: "请先登录。" }, { status: 401 }); } + const allowedRoles = aiPlatformRolesForPath(targetUrl.pathname); + if (!allowedRoles.includes(session.role.toLowerCase() as AiPlatformAdminRole)) { + return Response.json({ message: "当前账号无权访问该 AI 管理接口。" }, { status: 403 }); + } + const headers = new Headers(init?.headers); headers.delete("X-Tenant-Id"); headers.delete("X-User-Id"); @@ -26,3 +33,22 @@ export async function fetchAiPlatform(targetUrl: URL, init?: RequestInit) { } return fetch(targetUrl, { cache: "no-store", ...init, headers }); } + +export function aiPlatformRolesForPath(pathname: string): readonly AiPlatformAdminRole[] { + if ( + pathname.startsWith("/api/v1/model-configs") || + pathname.startsWith("/api/v1/prompt-templates") || + pathname.startsWith("/api/v1/runtime/") + ) { + return ["admin"]; + } + if ( + pathname.startsWith("/api/v1/audit/") || + pathname.startsWith("/api/v1/knowledge-") || + pathname.startsWith("/api/v1/knowledge-bases/") || + pathname.startsWith("/api/v1/quality") + ) { + return ["admin", "legal"]; + } + return ["admin"]; +} 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 dde694ce..d101d0cb 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 @@ -29,6 +29,17 @@ export async function fetchContractApi(targetUrl: URL, init?: RequestInit) { return fetch(targetUrl, { cache: "no-store", ...init, headers }); } +export function buildIdentityProxyHeaders(request: Request, initial?: HeadersInit) { + const headers = new Headers(initial); + const realIp = request.headers.get("x-real-ip")?.trim(); + const forwardedIp = request.headers.get("x-forwarded-for")?.split(",", 1)[0]?.trim(); + const clientIp = realIp || forwardedIp; + if (clientIp && clientIp.length <= 64) { + headers.set("X-Identity-Client-IP", clientIp); + } + return headers; +} + export async function proxyContractApiResponse(targetUrl: URL, init?: RequestInit) { try { const session = await getContractWebSession(); diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/identity-capabilities.ts b/yuqei-contract-platform/apps/contract-web/src/lib/identity-capabilities.ts new file mode 100644 index 00000000..5a50e7f9 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/lib/identity-capabilities.ts @@ -0,0 +1,16 @@ +export type IdentityCapabilities = { + email_delivery_mode: "disabled" | "development" | "smtp"; + registration_enabled: boolean; + password_reset_enabled: boolean; + email_verification_enabled: boolean; + invitation_enabled: boolean; + manual_invitation_enabled: boolean; +}; + +export async function getIdentityCapabilities(): Promise { + const response = await fetch("/api/auth/capabilities", { cache: "no-store" }); + if (!response.ok) { + throw new Error("身份功能配置暂时不可用。"); + } + return response.json() as Promise; +} diff --git a/yuqei-contract-platform/services/contract-api/.env.example b/yuqei-contract-platform/services/contract-api/.env.example index 606d1785..1414bb07 100644 --- a/yuqei-contract-platform/services/contract-api/.env.example +++ b/yuqei-contract-platform/services/contract-api/.env.example @@ -11,6 +11,19 @@ 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_IDENTITY_EMAIL_DELIVERY_MODE=development +CONTRACT_API_IDENTITY_SELF_SERVICE_ENABLED=true +CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED=false +CONTRACT_API_IDENTITY_CHALLENGE_RESEND_COOLDOWN_SECONDS=60 +CONTRACT_API_IDENTITY_CHALLENGE_RATE_WINDOW_SECONDS=3600 +CONTRACT_API_IDENTITY_CHALLENGE_EMAIL_LIMIT=5 +CONTRACT_API_IDENTITY_CHALLENGE_IP_LIMIT=20 +CONTRACT_API_IDENTITY_CHALLENGE_TENANT_LIMIT=100 +CONTRACT_API_IDENTITY_LOGIN_RATE_WINDOW_SECONDS=900 +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 # Production baseline: # CONTRACT_API_ENVIRONMENT=production @@ -20,3 +33,19 @@ CONTRACT_API_REVIEW_WORKER_BATCH_LIMIT=5 # CONTRACT_API_FILE_STORAGE_DIR=/data/contract-files # CONTRACT_API_UVICORN_WORKERS=1 # CONTRACT_API_READ_CACHE_TTL_SECONDS=2 +# 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 +# CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED=true +# To enable email later, switch the mode to smtp and set manual invitations to false. +# CONTRACT_API_IDENTITY_SMTP_HOST=smtp.example.com +# CONTRACT_API_IDENTITY_SMTP_PORT=587 +# CONTRACT_API_IDENTITY_SMTP_USERNAME=mailer@example.com +# CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE=/run/secrets/contract_smtp_password +# CONTRACT_API_IDENTITY_SMTP_FROM_EMAIL=mailer@example.com +# CONTRACT_API_IDENTITY_SMTP_FROM_NAME=AI 合同平台 +# CONTRACT_API_IDENTITY_SMTP_USE_STARTTLS=true +# CONTRACT_API_IDENTITY_SMTP_USE_SSL=false +# CONTRACT_API_IDENTITY_SMTP_TIMEOUT_SECONDS=10 +# CONTRACT_API_IDENTITY_PUBLIC_WEB_BASE_URL=https://contracts.example.com +# CONTRACT_API_IDENTITY_RATE_LIMIT_SECRET=CHANGE_ME_USE_A_RANDOM_32_BYTE_SECRET diff --git a/yuqei-contract-platform/services/contract-api/README.md b/yuqei-contract-platform/services/contract-api/README.md index ddc70dff..d73451fd 100644 --- a/yuqei-contract-platform/services/contract-api/README.md +++ b/yuqei-contract-platform/services/contract-api/README.md @@ -54,7 +54,7 @@ Health endpoints: - `GET /api/v1/runtime/config-check` - `python -m yuqei_contract_api.review_worker --health` -`/api/v1/runtime/config-check` is the deployment guardrail. It flags unsafe runtime settings such as production SQLite, production auto table creation, enabled demo seed data, invalid AI Platform URL, invalid worker intervals, or an empty file storage directory. +`/api/v1/runtime/config-check` is the deployment guardrail. It flags unsafe runtime settings such as production SQLite, production auto table creation, enabled demo seed data, invalid AI Platform URL, invalid worker intervals, inconsistent identity delivery mode, missing identity rate-limit secrets, or an empty file storage directory. Contract review worker production variables: @@ -70,6 +70,23 @@ 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_IDENTITY_EMAIL_DELIVERY_MODE=disabled +CONTRACT_API_IDENTITY_SELF_SERVICE_ENABLED=false +CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED=true +CONTRACT_API_IDENTITY_RATE_LIMIT_SECRET=LOAD_A_STABLE_RANDOM_SECRET_OF_AT_LEAST_32_CHARACTERS +``` + +The disabled mode keeps login and administrator-managed one-time invitation links available while public company registration, password reset, and email verification remain off. When enterprise email becomes available, switch to `smtp`, set manual invitations to `false`, and configure the SMTP variables from `.env.example`. The production web proxy must overwrite public `X-Real-IP` / `X-Forwarded-For` headers before Contract Web forwards the validated client IP to Contract API for rate limiting. + +After migrations, initialize the first production administrator from a protected password file. The command is idempotent and will not replace an existing credential unless `--force-rotate` is supplied: + +```powershell +python -m yuqei_contract_api.identity_bootstrap ` + --tenant-id default ` + --company-name "平台租户" ` + --email platform-admin@example.com ` + --display-name "平台管理员" ` + --password-file C:\run\secrets\bootstrap-admin-password ``` After deployment, verify the end-to-end review path from repository root: diff --git a/yuqei-contract-platform/services/contract-api/migrations/versions/20260713_0031_add_identity_email_security.py b/yuqei-contract-platform/services/contract-api/migrations/versions/20260713_0031_add_identity_email_security.py new file mode 100644 index 00000000..3dd3f983 --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/migrations/versions/20260713_0031_add_identity_email_security.py @@ -0,0 +1,109 @@ +"""add identity email delivery and rate-limit security + +Revision ID: 20260713_0031 +Revises: 20260710_0030 +Create Date: 2026-07-13 +""" + +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260713_0031" +down_revision: Union[str, None] = "20260710_0030" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "tenant_invitations", + sa.Column("delivery_status", sa.String(length=32), nullable=False, server_default="queued"), + ) + op.add_column( + "tenant_invitations", + sa.Column("delivery_attempted_at", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "tenant_invitations", + sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "tenant_invitations", + sa.Column("delivery_error_code", sa.String(length=128), nullable=True), + ) + op.create_index( + op.f("ix_tenant_invitations_delivery_status"), + "tenant_invitations", + ["delivery_status"], + ) + op.execute("update tenant_invitations set delivery_status = 'legacy'") + + op.add_column( + "tenant_email_challenges", + sa.Column("delivery_status", sa.String(length=32), nullable=False, server_default="queued"), + ) + op.add_column( + "tenant_email_challenges", + sa.Column("delivery_attempted_at", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "tenant_email_challenges", + sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "tenant_email_challenges", + sa.Column("delivery_error_code", sa.String(length=128), nullable=True), + ) + op.create_index( + op.f("ix_tenant_email_challenges_delivery_status"), + "tenant_email_challenges", + ["delivery_status"], + ) + op.execute("update tenant_email_challenges set delivery_status = 'legacy'") + + op.create_table( + "identity_rate_limit_events", + sa.Column("id", sa.String(length=64), nullable=False), + sa.Column("event_type", sa.String(length=64), nullable=False), + sa.Column("tenant_hash", sa.String(length=64), nullable=True), + sa.Column("email_hash", sa.String(length=64), nullable=True), + sa.Column("ip_hash", sa.String(length=64), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_identity_rate_limit_events_event_type"), "identity_rate_limit_events", ["event_type"]) + op.create_index(op.f("ix_identity_rate_limit_events_created_at"), "identity_rate_limit_events", ["created_at"]) + op.create_index(op.f("ix_identity_rate_limit_events_expires_at"), "identity_rate_limit_events", ["expires_at"]) + op.create_index("ix_identity_rate_events_type_created", "identity_rate_limit_events", ["event_type", "created_at"]) + op.create_index("ix_identity_rate_events_email_created", "identity_rate_limit_events", ["email_hash", "created_at"]) + op.create_index("ix_identity_rate_events_ip_created", "identity_rate_limit_events", ["ip_hash", "created_at"]) + op.create_index("ix_identity_rate_events_tenant_created", "identity_rate_limit_events", ["tenant_hash", "created_at"]) + + +def downgrade() -> None: + op.drop_index("ix_identity_rate_events_tenant_created", table_name="identity_rate_limit_events") + op.drop_index("ix_identity_rate_events_ip_created", table_name="identity_rate_limit_events") + op.drop_index("ix_identity_rate_events_email_created", table_name="identity_rate_limit_events") + op.drop_index("ix_identity_rate_events_type_created", table_name="identity_rate_limit_events") + op.drop_index(op.f("ix_identity_rate_limit_events_expires_at"), table_name="identity_rate_limit_events") + op.drop_index(op.f("ix_identity_rate_limit_events_created_at"), table_name="identity_rate_limit_events") + op.drop_index(op.f("ix_identity_rate_limit_events_event_type"), table_name="identity_rate_limit_events") + op.drop_table("identity_rate_limit_events") + + op.drop_index(op.f("ix_tenant_email_challenges_delivery_status"), table_name="tenant_email_challenges") + op.drop_column("tenant_email_challenges", "delivery_error_code") + op.drop_column("tenant_email_challenges", "delivered_at") + op.drop_column("tenant_email_challenges", "delivery_attempted_at") + op.drop_column("tenant_email_challenges", "delivery_status") + + op.drop_index(op.f("ix_tenant_invitations_delivery_status"), table_name="tenant_invitations") + op.drop_column("tenant_invitations", "delivery_error_code") + op.drop_column("tenant_invitations", "delivered_at") + op.drop_column("tenant_invitations", "delivery_attempted_at") + op.drop_column("tenant_invitations", "delivery_status") diff --git a/yuqei-contract-platform/services/contract-api/pyproject.toml b/yuqei-contract-platform/services/contract-api/pyproject.toml index 08a1c4f4..838ff627 100644 --- a/yuqei-contract-platform/services/contract-api/pyproject.toml +++ b/yuqei-contract-platform/services/contract-api/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ [project.scripts] yuqei-import-v1-legal-fulltext = "yuqei_contract_api.v1_legal_fulltext_import:main" +yuqei-bootstrap-identity-admin = "yuqei_contract_api.identity_bootstrap:main" [build-system] requires = ["hatchling"] 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 b9d05693..87da1204 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 @@ -1,9 +1,21 @@ from __future__ import annotations -from fastapi import FastAPI, HTTPException +import secrets +import time +from datetime import UTC, datetime, timedelta +from ipaddress import ip_address +from uuid import uuid4 + +from fastapi import BackgroundTasks, FastAPI, HTTPException, Request from yuqei_contract_api.config import ContractApiSettings from yuqei_contract_api.domain import RolePermissionsPage +from yuqei_contract_api.identity_email_delivery import ( + IdentityEmailDelivery, + IdentityEmailDeliveryError, + build_identity_email_delivery, +) +from yuqei_contract_api.identity_rate_limit import IdentityRateLimitExceeded from yuqei_contract_api.identity import ( AccountSettings, AccountSettingsUpdateRequest, @@ -11,6 +23,7 @@ from yuqei_contract_api.identity import ( EmailVerificationConfirmRequest, EmailVerificationResult, EmailVerificationSendRequest, + IdentityCapabilities, IdentityConflictError, IdentitySession, InMemoryIdentityRepository, @@ -36,6 +49,7 @@ from yuqei_contract_api.identity import ( TenantUserUpdateRequest, ) from yuqei_contract_api.persistence.models import DEFAULT_TENANT_ID +from yuqei_contract_api.logging import get_logger from yuqei_contract_api.repository_base import ContractRepository from yuqei_contract_api.tenant_context import get_tenant_context, get_user_context @@ -47,6 +61,25 @@ def register_identity_routes( repository: ContractRepository, ) -> None: identity_repository = getattr(repository, "identity", None) or InMemoryIdentityRepository() + email_delivery = build_identity_email_delivery(settings) + logger = get_logger("yuqei_contract_api.identity") + + @app.get( + f"{settings.api_prefix}/identity/capabilities", + response_model=IdentityCapabilities, + tags=["identity"], + ) + def get_identity_capabilities() -> IdentityCapabilities: + email_enabled = settings.identity_email_delivery_mode != "disabled" + invitation_enabled = email_enabled or settings.identity_manual_invitation_enabled + return IdentityCapabilities( + email_delivery_mode=settings.identity_email_delivery_mode, + registration_enabled=settings.identity_self_service_enabled, + password_reset_enabled=settings.identity_self_service_enabled and email_enabled, + email_verification_enabled=email_enabled, + invitation_enabled=invitation_enabled, + manual_invitation_enabled=settings.identity_manual_invitation_enabled, + ) @app.get( f"{settings.api_prefix}/identity/tenants", @@ -88,6 +121,7 @@ def register_identity_routes( tags=["identity"], ) def register_company(request: TenantRegistrationRequest) -> IdentitySession: + _require_self_service_or_platform_admin(settings) try: return identity_repository.register_tenant_admin(request) except IdentityConflictError as exc: @@ -100,29 +134,55 @@ def register_identity_routes( response_model=IdentitySession, tags=["identity"], ) - def login(request: TenantLoginRequest) -> IdentitySession: + def login(request: TenantLoginRequest, http_request: Request) -> IdentitySession: try: - return identity_repository.login_by_email(request) - except IdentityConflictError as exc: - raise HTTPException(status_code=409, detail=str(exc)) from exc - except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc + identity_repository.consume_identity_rate_limit( + event_type="login_attempt", + tenant_id=request.tenant_id, + email=request.email, + client_ip=_identity_client_ip(http_request, settings), + secret=_identity_rate_limit_secret(settings), + window_seconds=settings.identity_login_rate_window_seconds, + email_limit=settings.identity_login_email_limit, + ip_limit=settings.identity_login_ip_limit, + ) + session = identity_repository.login_by_email(request) + identity_repository.reset_identity_rate_limit( + event_type="login_attempt", + tenant_id=request.tenant_id, + email=request.email, + secret=_identity_rate_limit_secret(settings), + ) + return session + except IdentityRateLimitExceeded as exc: + raise _rate_limit_http_exception(exc) from exc + except (IdentityConflictError, ValueError) as exc: + raise HTTPException(status_code=401, detail="Unable to sign in with the supplied credentials.") from exc @app.post( f"{settings.api_prefix}/identity/invitations", response_model=TenantInvitationItem, tags=["identity"], ) - def create_invitation(request: TenantInvitationCreateRequest) -> TenantInvitationItem: + def create_invitation( + request: TenantInvitationCreateRequest, + background_tasks: BackgroundTasks, + ) -> TenantInvitationItem: _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)} ) try: - return _redact_invitation( - identity_repository.create_invitation(request, tenant_id=_current_tenant_id()), - settings, + invitation = identity_repository.create_invitation(request, tenant_id=_current_tenant_id()) + _queue_or_record_invitation_delivery( + invitation, + background_tasks=background_tasks, + identity_repository=identity_repository, + email_delivery=email_delivery, + logger=logger, ) + return _redact_invitation(invitation, settings) except IdentityConflictError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc except ValueError as exc: @@ -134,6 +194,7 @@ def register_identity_routes( tags=["identity"], ) def accept_invitation(request: TenantInvitationAcceptRequest) -> IdentitySession: + _require_invitation_delivery(settings) try: return identity_repository.accept_invitation(request) except ValueError as exc: @@ -144,20 +205,47 @@ def register_identity_routes( response_model=EmailChallengeItem, tags=["identity"], ) - def send_email_verification(request: EmailVerificationSendRequest) -> EmailChallengeItem: + def send_email_verification( + request: EmailVerificationSendRequest, + http_request: Request, + background_tasks: BackgroundTasks, + ) -> EmailChallengeItem: _require_tenant_admin(identity_repository, settings) + _require_email_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), + "purpose": "email_verification", + } ) try: - return _redact_email_challenge( - identity_repository.send_email_challenge( - request, - tenant_id=_current_tenant_id(), - purpose=request.purpose, - ), - settings, + identity_repository.consume_identity_rate_limit( + event_type="email_verification_request", + tenant_id=request.tenant_id, + email=request.email, + client_ip=_identity_client_ip(http_request, settings), + secret=_identity_rate_limit_secret(settings), + window_seconds=settings.identity_challenge_rate_window_seconds, + email_limit=settings.identity_challenge_email_limit, + ip_limit=settings.identity_challenge_ip_limit, + tenant_limit=settings.identity_challenge_tenant_limit, + cooldown_seconds=settings.identity_challenge_resend_cooldown_seconds, ) + challenge = identity_repository.send_email_challenge( + request, + tenant_id=_current_tenant_id(), + purpose=request.purpose, + ) + _queue_or_record_challenge_delivery( + challenge, + background_tasks=background_tasks, + identity_repository=identity_repository, + email_delivery=email_delivery, + logger=logger, + ) + return _redact_email_challenge(challenge, settings) + except IdentityRateLimitExceeded as exc: + raise _rate_limit_http_exception(exc) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -167,28 +255,72 @@ def register_identity_routes( tags=["identity"], ) def confirm_email_verification(request: EmailVerificationConfirmRequest) -> EmailVerificationResult: + _require_email_delivery(settings) try: return _redact_email_result(identity_repository.confirm_email_challenge(request), settings) except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc + raise HTTPException(status_code=400, detail="Verification code is invalid or expired.") from exc @app.post( f"{settings.api_prefix}/identity/password-reset/request", response_model=EmailChallengeItem, tags=["identity"], ) - def request_password_reset(request: PasswordResetRequest) -> EmailChallengeItem: + def request_password_reset( + request: PasswordResetRequest, + http_request: Request, + background_tasks: BackgroundTasks, + ) -> EmailChallengeItem: + _require_self_service(settings) + _require_email_delivery(settings) + started_at = time.monotonic() try: - return _redact_email_challenge( - identity_repository.send_email_challenge( - request, - tenant_id=_current_tenant_id(), - purpose="password_reset", - ), - settings, + identity_repository.consume_identity_rate_limit( + event_type="password_reset_request", + tenant_id=request.tenant_id, + email=request.email, + client_ip=_identity_client_ip(http_request, settings), + secret=_identity_rate_limit_secret(settings), + window_seconds=settings.identity_challenge_rate_window_seconds, + email_limit=settings.identity_challenge_email_limit, + ip_limit=settings.identity_challenge_ip_limit, + tenant_limit=settings.identity_challenge_tenant_limit, + cooldown_seconds=settings.identity_challenge_resend_cooldown_seconds, ) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc + except IdentityRateLimitExceeded as exc: + raise _rate_limit_http_exception(exc) from exc + except ValueError: + return _finish_password_reset_response( + _generic_password_reset_challenge(request), + started_at=started_at, + settings=settings, + ) + + try: + challenge = identity_repository.send_email_challenge( + request, + tenant_id=request.tenant_id, + purpose="password_reset", + ) + except (IdentityConflictError, ValueError): + return _finish_password_reset_response( + _generic_password_reset_challenge(request), + started_at=started_at, + settings=settings, + ) + + _queue_or_record_challenge_delivery( + challenge, + background_tasks=background_tasks, + identity_repository=identity_repository, + email_delivery=email_delivery, + logger=logger, + ) + return _finish_password_reset_response( + _redact_password_reset_challenge(challenge, request=request, settings=settings), + started_at=started_at, + settings=settings, + ) @app.post( f"{settings.api_prefix}/identity/password-reset/confirm", @@ -196,10 +328,12 @@ def register_identity_routes( tags=["identity"], ) def confirm_password_reset(request: PasswordResetConfirmRequest) -> IdentitySession: + _require_self_service(settings) + _require_email_delivery(settings) try: return identity_repository.confirm_password_reset(request) except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc + raise HTTPException(status_code=400, detail="Verification code is invalid or expired.") from exc @app.get( f"{settings.api_prefix}/identity/sso-config", @@ -351,6 +485,32 @@ def _require_platform_admin(settings: ContractApiSettings) -> None: raise HTTPException(status_code=403, detail="Platform administrator access is required.") +def _require_self_service_or_platform_admin(settings: ContractApiSettings) -> 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) + + +def _require_self_service(settings: ContractApiSettings) -> None: + if not settings.identity_self_service_enabled: + raise HTTPException(status_code=403, detail="Self-service identity operations are disabled.") + + +def _require_email_delivery(settings: ContractApiSettings) -> None: + if settings.identity_email_delivery_mode == "disabled": + raise HTTPException(status_code=403, detail="Email-based identity operations are disabled.") + + +def _require_invitation_delivery(settings: ContractApiSettings) -> None: + if ( + settings.identity_email_delivery_mode == "disabled" + and not settings.identity_manual_invitation_enabled + ): + raise HTTPException(status_code=403, detail="User invitation delivery is disabled.") + + def _resolve_tenant_id(requested_tenant_id: str | None, *, settings: ContractApiSettings) -> str: current_tenant_id = _current_tenant_id() resolved_tenant_id = requested_tenant_id or current_tenant_id @@ -383,7 +543,11 @@ def _require_self_or_platform_admin(user_id: str, settings: ContractApiSettings) def _redact_invitation(item: TenantInvitationItem, settings: ContractApiSettings) -> TenantInvitationItem: - if settings.environment == "production": + manual_delivery = ( + settings.identity_email_delivery_mode == "disabled" + and settings.identity_manual_invitation_enabled + ) + if settings.environment == "production" and not manual_delivery: payload = item.model_dump(exclude={"invite_token"}) return TenantInvitationItem.model_validate(payload) return item @@ -397,3 +561,190 @@ def _redact_email_challenge(item: EmailChallengeItem, settings: ContractApiSetti def _redact_email_result(result: EmailVerificationResult, settings: ContractApiSettings) -> EmailVerificationResult: return result.model_copy(update={"challenge": _redact_email_challenge(result.challenge, settings)}) + + +def _redact_password_reset_challenge( + item: EmailChallengeItem, + *, + request: PasswordResetRequest, + settings: ContractApiSettings, +) -> EmailChallengeItem: + return item.model_copy( + update={ + "tenant_id": request.tenant_id or "", + "user_id": None, + "email": request.email.strip().lower(), + "dev_code": None if settings.environment == "production" else item.dev_code, + } + ) + + +def _generic_password_reset_challenge(request: PasswordResetRequest) -> EmailChallengeItem: + now = datetime.now(UTC) + return EmailChallengeItem( + id=f"email-code-{uuid4().hex[:12]}", + tenant_id=request.tenant_id or "", + user_id=None, + email=request.email.strip().lower(), + purpose="password_reset", + status="pending", + expires_at=now + timedelta(minutes=request.expires_minutes), + sent_at=now, + verified_at=None, + dev_code=None, + created_at=now, + updated_at=now, + ) + + +def _queue_or_record_challenge_delivery( + challenge: EmailChallengeItem, + *, + background_tasks: BackgroundTasks, + identity_repository, + email_delivery: IdentityEmailDelivery, + logger, +) -> None: + if email_delivery.mode == "smtp": + background_tasks.add_task( + _deliver_email_challenge, + challenge, + identity_repository=identity_repository, + email_delivery=email_delivery, + logger=logger, + ) + return + identity_repository.record_email_challenge_delivery(challenge.id, status="development") + + +def _deliver_email_challenge( + challenge: EmailChallengeItem, + *, + identity_repository, + email_delivery: IdentityEmailDelivery, + logger, +) -> None: + try: + email_delivery.send_challenge(challenge) + identity_repository.record_email_challenge_delivery(challenge.id, status="sent") + except IdentityEmailDeliveryError as exc: + identity_repository.record_email_challenge_delivery( + challenge.id, + status="failed", + error_code=type(exc.__cause__ or exc).__name__, + ) + logger.error( + "Identity email delivery failed.", + extra={"extra_data": {"challenge_id": challenge.id, "error_type": type(exc).__name__}}, + ) + except Exception as exc: # pragma: no cover - protects the response background task boundary. + identity_repository.record_email_challenge_delivery( + challenge.id, + status="failed", + error_code=type(exc).__name__, + ) + logger.error( + "Unexpected identity email delivery failure.", + extra={"extra_data": {"challenge_id": challenge.id, "error_type": type(exc).__name__}}, + ) + + +def _queue_or_record_invitation_delivery( + invitation: TenantInvitationItem, + *, + background_tasks: BackgroundTasks, + identity_repository, + email_delivery: IdentityEmailDelivery, + logger, +) -> None: + if email_delivery.mode == "smtp": + background_tasks.add_task( + _deliver_invitation, + invitation, + identity_repository=identity_repository, + email_delivery=email_delivery, + logger=logger, + ) + return + identity_repository.record_invitation_delivery( + invitation.id, + status="manual" if email_delivery.mode == "disabled" else "development", + ) + + +def _deliver_invitation( + invitation: TenantInvitationItem, + *, + identity_repository, + email_delivery: IdentityEmailDelivery, + logger, +) -> None: + try: + email_delivery.send_invitation(invitation) + identity_repository.record_invitation_delivery(invitation.id, status="sent") + except IdentityEmailDeliveryError as exc: + identity_repository.record_invitation_delivery( + invitation.id, + status="failed", + error_code=type(exc.__cause__ or exc).__name__, + ) + logger.error( + "Identity invitation delivery failed.", + extra={"extra_data": {"invitation_id": invitation.id, "error_type": type(exc).__name__}}, + ) + except Exception as exc: # pragma: no cover - protects the response background task boundary. + identity_repository.record_invitation_delivery( + invitation.id, + status="failed", + error_code=type(exc).__name__, + ) + logger.error( + "Unexpected identity invitation delivery failure.", + extra={"extra_data": {"invitation_id": invitation.id, "error_type": type(exc).__name__}}, + ) + + +def _identity_client_ip(request: Request, settings: ContractApiSettings) -> str | None: + candidate = None + if settings.require_web_proxy_auth: + candidate = request.headers.get("X-Identity-Client-IP") + if not candidate and request.client: + candidate = request.client.host + if not candidate: + return None + try: + return ip_address(candidate.strip()).compressed + except ValueError: + return None + + +def _identity_rate_limit_secret(settings: ContractApiSettings) -> str: + return ( + settings.identity_rate_limit_secret.strip() + or settings.web_proxy_shared_secret.strip() + or "local-identity-rate-limit-secret-not-for-production" + ) + + +def _rate_limit_http_exception(exc: IdentityRateLimitExceeded) -> HTTPException: + return HTTPException( + status_code=429, + detail="Too many requests. Please try again later.", + headers={"Retry-After": str(exc.retry_after_seconds)}, + ) + + +def _finish_password_reset_response( + item: EmailChallengeItem, + *, + started_at: float, + settings: ContractApiSettings, +) -> EmailChallengeItem: + if settings.environment != "production": + return item + jitter_ms = secrets.randbelow(settings.identity_anti_enumeration_jitter_ms + 1) + target_seconds = (settings.identity_anti_enumeration_floor_ms + jitter_ms) / 1000 + remaining = target_seconds - (time.monotonic() - started_at) + if remaining > 0: + time.sleep(remaining) + return item 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 5be0f233..7bc4a44a 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 @@ -190,6 +190,7 @@ def register_review_routes( return run_review_worker_once( repository=repository, ai_platform_base_url=settings.ai_platform_base_url, + ai_platform_service_key=settings.ai_platform_service_key, options=ReviewWorkerOptions( worker_name=payload.worker_name or settings.review_worker_name, limit=payload.limit, 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 88060263..8ed0459e 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 @@ -1,8 +1,9 @@ from functools import lru_cache +from pathlib import Path from typing import Literal from urllib.parse import urlparse -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, SecretStr from pydantic_settings import BaseSettings, SettingsConfigDict @@ -23,6 +24,31 @@ class ContractApiSettings(BaseSettings): review_worker_name: str = "contract-review-worker-1" review_worker_poll_interval_seconds: float = 5.0 review_worker_batch_limit: int = 5 + identity_email_delivery_mode: Literal["disabled", "development", "smtp"] = "development" + identity_self_service_enabled: bool = True + identity_manual_invitation_enabled: bool = False + identity_smtp_host: str = "" + identity_smtp_port: int = 587 + identity_smtp_username: str = "" + identity_smtp_password: SecretStr = SecretStr("") + identity_smtp_password_file: str = "" + identity_smtp_from_email: str = "" + identity_smtp_from_name: str = "AI 合同平台" + identity_smtp_use_starttls: bool = True + identity_smtp_use_ssl: bool = False + identity_smtp_timeout_seconds: float = 10.0 + identity_public_web_base_url: str = "http://localhost:3000" + identity_rate_limit_secret: str = "" + identity_challenge_resend_cooldown_seconds: int = 60 + identity_challenge_rate_window_seconds: int = 3600 + identity_challenge_email_limit: int = 5 + identity_challenge_ip_limit: int = 20 + identity_challenge_tenant_limit: int = 100 + identity_login_rate_window_seconds: int = 900 + identity_login_email_limit: int = 10 + identity_login_ip_limit: int = 50 + identity_anti_enumeration_floor_ms: int = 120 + identity_anti_enumeration_jitter_ms: int = 30 model_config = SettingsConfigDict( env_file=".env", @@ -50,6 +76,7 @@ def check_runtime_config(settings: ContractApiSettings) -> RuntimeConfigStatus: _check_ai_platform_config(settings), _check_web_proxy_auth_config(settings), _check_identity_delivery_config(settings), + _check_identity_rate_limit_config(settings), _check_worker_config(settings), _check_file_storage_config(settings), ] @@ -200,18 +227,151 @@ def _check_file_storage_config(settings: ContractApiSettings) -> RuntimeConfigCh def _check_identity_delivery_config(settings: ContractApiSettings) -> RuntimeConfigCheck: - if settings.environment == "production": + mode = settings.identity_email_delivery_mode + self_service_enabled = settings.identity_self_service_enabled + manual_invitation_enabled = settings.identity_manual_invitation_enabled + details = { + "delivery_mode": mode, + "self_service_enabled": self_service_enabled, + "manual_invitation_enabled": manual_invitation_enabled, + } + if mode == "development" and settings.environment == "production": return RuntimeConfigCheck( name="identity_email_delivery", status="error", - message="Production identity email delivery is not implemented yet.", - details={"delivery_mode": "development_only"}, + message="Production must use SMTP delivery or the explicit administrator-managed disabled mode.", + details=details, + ) + if mode == "disabled": + invalid_fields: list[str] = [] + if self_service_enabled: + invalid_fields.append("identity_self_service_enabled") + if settings.environment == "production" and not manual_invitation_enabled: + invalid_fields.append("identity_manual_invitation_enabled") + if invalid_fields: + return RuntimeConfigCheck( + name="identity_email_delivery", + status="error", + message="Disabled email delivery requires self-service off and production manual invitations on.", + details={**details, "invalid_fields": ",".join(invalid_fields)}, + ) + return RuntimeConfigCheck( + name="identity_email_delivery", + status="ok", + message="Administrator-managed offline identity onboarding is configured.", + details=details, + ) + if manual_invitation_enabled: + return RuntimeConfigCheck( + name="identity_email_delivery", + status="error", + message="Manual invitation token delivery is only allowed when email delivery is disabled.", + details={**details, "invalid_fields": "identity_manual_invitation_enabled"}, + ) + if mode == "smtp": + password_file = settings.identity_smtp_password_file.strip() + errors: list[str] = [] + if not settings.identity_smtp_host.strip(): + errors.append("smtp_host") + if not settings.identity_smtp_from_email.strip() or "@" not in settings.identity_smtp_from_email: + errors.append("smtp_from_email") + if not 1 <= settings.identity_smtp_port <= 65535: + errors.append("smtp_port") + if settings.identity_smtp_timeout_seconds <= 0: + errors.append("smtp_timeout_seconds") + if settings.identity_smtp_use_starttls and settings.identity_smtp_use_ssl: + errors.append("smtp_tls_mode") + if settings.identity_smtp_username.strip() and not ( + settings.identity_smtp_password.get_secret_value() or password_file + ): + errors.append("smtp_password") + if password_file and not Path(password_file).is_file(): + errors.append("smtp_password_file") + public_url = urlparse(settings.identity_public_web_base_url) + if public_url.scheme not in {"http", "https"} or not public_url.netloc: + errors.append("public_web_base_url") + if settings.environment == "production" and ( + public_url.scheme != "https" or public_url.hostname in {"localhost", "127.0.0.1", "::1"} + ): + errors.append("public_web_base_url") + if errors: + return RuntimeConfigCheck( + name="identity_email_delivery", + status="error", + message="SMTP identity email delivery configuration is incomplete or invalid.", + details={**details, "invalid_fields": ",".join(errors)}, + ) + return RuntimeConfigCheck( + name="identity_email_delivery", + status="ok", + message="SMTP identity email delivery is configured.", + details={ + **details, + "smtp_host": settings.identity_smtp_host, + "smtp_port": settings.identity_smtp_port, + "smtp_auth_configured": bool(settings.identity_smtp_username.strip()), + "smtp_password_file_configured": bool(password_file), + "smtp_starttls": settings.identity_smtp_use_starttls, + "smtp_ssl": settings.identity_smtp_use_ssl, + "public_web_host": public_url.netloc, + }, ) return RuntimeConfigCheck( name="identity_email_delivery", status="warning", message="Email verification and password reset use development delivery behavior.", - details={"delivery_mode": "development_only"}, + details=details, + ) + + +def _check_identity_rate_limit_config(settings: ContractApiSettings) -> RuntimeConfigCheck: + secret_length = len(settings.identity_rate_limit_secret.strip()) + values = { + "challenge_resend_cooldown_seconds": settings.identity_challenge_resend_cooldown_seconds, + "challenge_rate_window_seconds": settings.identity_challenge_rate_window_seconds, + "challenge_email_limit": settings.identity_challenge_email_limit, + "challenge_ip_limit": settings.identity_challenge_ip_limit, + "challenge_tenant_limit": settings.identity_challenge_tenant_limit, + "login_rate_window_seconds": settings.identity_login_rate_window_seconds, + "login_email_limit": settings.identity_login_email_limit, + "login_ip_limit": settings.identity_login_ip_limit, + } + invalid_fields = [name for name, value in values.items() if value < 1] + if invalid_fields: + return RuntimeConfigCheck( + name="identity_rate_limit", + status="error", + message="Identity rate-limit values must all be positive.", + details={"invalid_fields": ",".join(invalid_fields)}, + ) + if settings.identity_anti_enumeration_floor_ms < 0 or settings.identity_anti_enumeration_jitter_ms < 0: + return RuntimeConfigCheck( + name="identity_rate_limit", + status="error", + message="Identity anti-enumeration timing values must not be negative.", + details={"invalid_fields": "anti_enumeration_timing"}, + ) + if settings.environment == "production" and secret_length < 32: + return RuntimeConfigCheck( + name="identity_rate_limit", + status="error", + message="Production identity rate limiting requires a dedicated secret of at least 32 characters.", + details={"secret_configured": bool(secret_length)}, + ) + return RuntimeConfigCheck( + name="identity_rate_limit", + status="ok" if secret_length >= 32 else "warning", + message=( + "Identity rate limiting is configured." + if secret_length >= 32 + else "Identity rate limiting uses a development-only fallback secret." + ), + details={ + "secret_configured": bool(secret_length), + "anti_enumeration_floor_ms": settings.identity_anti_enumeration_floor_ms, + "anti_enumeration_jitter_ms": settings.identity_anti_enumeration_jitter_ms, + **values, + }, ) 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 ad2171e4..92d450a3 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,6 +1,7 @@ from __future__ import annotations from datetime import UTC, datetime, timedelta +from threading import Lock from typing import Literal from uuid import uuid4 @@ -8,6 +9,7 @@ from pydantic import BaseModel, Field from yuqei_contract_api.domain import UserRole, normalize_role from yuqei_contract_api.identity_security import ( + DUMMY_CREDENTIAL_HASH, create_email_code, create_invitation_token, hash_email_code, @@ -16,6 +18,11 @@ from yuqei_contract_api.identity_security import ( verify_email_code, verify_password, ) +from yuqei_contract_api.identity_rate_limit import ( + IdentityRateLimitEvent, + IdentityRateLimitExceeded, + hash_identity_dimension, +) from yuqei_contract_api.persistence.models import DEFAULT_TENANT_ID @@ -31,6 +38,22 @@ class IdentityConflictError(ValueError): """Raised when an identity management record violates a unique boundary.""" +class IdentityCapabilities(BaseModel): + email_delivery_mode: Literal["disabled", "development", "smtp"] + registration_enabled: bool + password_reset_enabled: bool + email_verification_enabled: bool + invitation_enabled: bool + manual_invitation_enabled: bool + + +class IdentityBootstrapResult(BaseModel): + status: Literal["created", "rotated", "already_configured"] + tenant_id: str + user_id: str + email: str + + class TenantInfo(BaseModel): id: str name: str @@ -207,7 +230,7 @@ class EmailVerificationSendRequest(BaseModel): email: str tenant_id: str | None = None purpose: EmailChallengePurpose = "email_verification" - expires_minutes: int = 10 + expires_minutes: int = Field(default=10, ge=5, le=30) created_by: str | None = None @@ -228,7 +251,7 @@ class EmailChallengeItem(BaseModel): class EmailVerificationConfirmRequest(BaseModel): challenge_id: str - code: str + code: str = Field(min_length=6, max_length=6, pattern=r"^\d{6}$") class EmailVerificationResult(BaseModel): @@ -240,13 +263,13 @@ class EmailVerificationResult(BaseModel): class PasswordResetRequest(BaseModel): email: str tenant_id: str | None = None - expires_minutes: int = 10 + expires_minutes: int = Field(default=10, ge=5, le=30) created_by: str | None = "password-reset" class PasswordResetConfirmRequest(BaseModel): challenge_id: str - code: str + code: str = Field(min_length=6, max_length=6, pattern=r"^\d{6}$") new_password: str @@ -343,6 +366,9 @@ class InMemoryIdentityRepository: self._credentials: dict[str, str] = {"user-current-admin": hash_password("admin654321")} self._invitations: dict[str, tuple[TenantInvitationItem, str]] = {} self._email_challenges: dict[str, tuple[EmailChallengeItem, str]] = {} + self._email_challenge_attempts: dict[str, int] = {} + self._identity_rate_limit_events: list[IdentityRateLimitEvent] = [] + self._identity_rate_limit_lock = Lock() self._sso_configs: dict[tuple[str, SsoProvider], TenantSsoConfig] = {} def list_tenants(self, *, status: TenantStatus | None = None) -> TenantPage: @@ -463,20 +489,22 @@ class InMemoryIdentityRepository: 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) - auth_mode: Literal["password", "legacy_email_session"] = "legacy_email_session" - if credential is not None: - if not request.password or not verify_password(request.password, credential): - raise ValueError("Invalid email or password.") - auth_mode = "password" + 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): + raise ValueError("Invalid email or password.") 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=auth_mode) + return IdentitySession(tenant=tenant, user=user, account=account, auth_mode="password") def create_invitation( self, @@ -566,23 +594,33 @@ class InMemoryIdentityRepository: self, request: EmailVerificationSendRequest | PasswordResetRequest, *, - tenant_id: str = DEFAULT_TENANT_ID, + tenant_id: str | None = DEFAULT_TENANT_ID, purpose: EmailChallengePurpose | None = None, ) -> EmailChallengeItem: now = datetime.now(UTC) - resolved_tenant_id = request.tenant_id or tenant_id + scoped_tenant_id = request.tenant_id or tenant_id resolved_purpose: EmailChallengePurpose = purpose or getattr(request, "purpose", "email_verification") email = _normalize_email(request.email) - user = next( - ( - item - for item in self._users.values() - if item.tenant_id == resolved_tenant_id and item.email == email and item.status != "disabled" - ), - None, - ) - if user is None: + matches = [ + item + for item in self._users.values() + if item.email == email + and item.status != "disabled" + and (scoped_tenant_id is None or item.tenant_id == scoped_tenant_id) + ] + if len(matches) != 1: raise ValueError("User not found or disabled.") + user = matches[0] + resolved_tenant_id = user.tenant_id + for challenge_id, (existing, existing_hash) in list(self._email_challenges.items()): + if ( + existing.tenant_id == resolved_tenant_id + and existing.email == email + and existing.purpose == resolved_purpose + and existing.status == "pending" + ): + expired = existing.model_copy(update={"status": "expired", "dev_code": None, "updated_at": now}) + self._email_challenges[challenge_id] = (expired, existing_hash) code = create_email_code() challenge = EmailChallengeItem( id=f"email-code-{uuid4().hex[:12]}", @@ -599,28 +637,157 @@ class InMemoryIdentityRepository: updated_at=now, ) self._email_challenges[challenge.id] = (challenge, hash_email_code(code)) + self._email_challenge_attempts[challenge.id] = 0 return challenge + def consume_identity_rate_limit( + self, + *, + event_type: str, + tenant_id: str | None, + email: str, + client_ip: str | None, + secret: str, + window_seconds: int, + email_limit: int, + ip_limit: int, + tenant_limit: int = 0, + cooldown_seconds: int = 0, + ) -> None: + now = datetime.now(UTC) + tenant_hash = hash_identity_dimension("tenant", tenant_id, secret=secret) if tenant_id else None + email_hash = hash_identity_dimension("email", _normalize_email(email), secret=secret) + ip_hash = hash_identity_dimension("ip", client_ip, secret=secret) if client_ip else None + with self._identity_rate_limit_lock: + self._identity_rate_limit_events = [ + event for event in self._identity_rate_limit_events if event.expires_at > now + ] + matching = [event for event in self._identity_rate_limit_events if event.event_type == event_type] + if cooldown_seconds: + email_events = [ + event + for event in matching + if event.email_hash == email_hash + and (tenant_hash is None or event.tenant_hash == tenant_hash) + ] + if email_events: + latest = max(event.created_at for event in email_events) + remaining = cooldown_seconds - int((now - latest).total_seconds()) + if remaining > 0: + raise IdentityRateLimitExceeded(retry_after_seconds=remaining) + since = now - timedelta(seconds=window_seconds) + dimensions = ( + (email_hash, email_limit, "email_hash"), + (ip_hash, ip_limit, "ip_hash"), + (tenant_hash, tenant_limit, "tenant_hash"), + ) + for value, limit, field_name in dimensions: + if not value or limit < 1: + continue + count = sum( + 1 + for event in matching + if event.created_at >= since and getattr(event, field_name) == value + ) + if count >= limit: + raise IdentityRateLimitExceeded(retry_after_seconds=window_seconds) + self._identity_rate_limit_events.append( + IdentityRateLimitEvent( + event_type=event_type, + tenant_hash=tenant_hash, + email_hash=email_hash, + ip_hash=ip_hash, + created_at=now, + expires_at=now + timedelta(seconds=max(window_seconds, cooldown_seconds)), + ) + ) + + def record_email_challenge_delivery( + self, + challenge_id: str, + *, + status: str, + error_code: str | None = None, + ) -> None: + del challenge_id, status, error_code + + def reset_identity_rate_limit( + self, + *, + event_type: str, + tenant_id: str | None, + email: str, + secret: str, + ) -> None: + tenant_hash = hash_identity_dimension("tenant", tenant_id, secret=secret) if tenant_id else None + email_hash = hash_identity_dimension("email", _normalize_email(email), secret=secret) + with self._identity_rate_limit_lock: + self._identity_rate_limit_events = [ + event + for event in self._identity_rate_limit_events + if not ( + event.event_type == event_type + and event.email_hash == email_hash + and (tenant_hash is None or event.tenant_hash == tenant_hash) + ) + ] + + def record_invitation_delivery( + self, + invitation_id: str, + *, + status: str, + error_code: str | None = None, + ) -> None: + del invitation_id, status, error_code + def confirm_email_challenge(self, request: EmailVerificationConfirmRequest) -> 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 challenge.purpose != "email_verification": + raise ValueError("Email challenge purpose is invalid.") if challenge.status != "pending" or challenge.expires_at < now: raise ValueError("Email challenge is not available.") + attempts = self._email_challenge_attempts.get(challenge.id, 0) + if attempts >= 5: + raise ValueError("Email challenge is locked.") if not verify_email_code(request.code, code_hash): + attempts += 1 + self._email_challenge_attempts[challenge.id] = attempts + if attempts >= 5: + locked = challenge.model_copy(update={"status": "locked", "dev_code": None, "updated_at": now}) + self._email_challenges[locked.id] = (locked, code_hash) 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) return EmailVerificationResult(verified=True, challenge=updated) def confirm_password_reset(self, request: PasswordResetConfirmRequest) -> IdentitySession: - result = self.confirm_email_challenge( - EmailVerificationConfirmRequest(challenge_id=request.challenge_id, code=request.code) + now = datetime.now(UTC) + entry = self._email_challenges.get(request.challenge_id) + if entry is None or entry[0].purpose != "password_reset": + raise ValueError("Password reset challenge is not valid.") + challenge, code_hash = entry + if challenge.status != "pending" or challenge.expires_at < now: + raise ValueError("Password reset challenge is not available.") + attempts = self._email_challenge_attempts.get(challenge.id, 0) + if attempts >= 5: + raise ValueError("Password reset challenge is locked.") + if not verify_email_code(request.code, code_hash): + attempts += 1 + self._email_challenge_attempts[challenge.id] = attempts + if attempts >= 5: + locked = challenge.model_copy(update={"status": "locked", "dev_code": None, "updated_at": now}) + self._email_challenges[locked.id] = (locked, code_hash) + raise ValueError("Invalid email code.") + challenge = challenge.model_copy( + update={"status": "verified", "verified_at": now, "dev_code": None, "updated_at": now} ) - challenge = result.challenge - if challenge.purpose != "password_reset" or not challenge.user_id: + self._email_challenges[challenge.id] = (challenge, code_hash) + if not challenge.user_id: raise ValueError("Password reset challenge is not valid.") user = self._users.get(challenge.user_id) if user is None: @@ -708,8 +875,15 @@ class InMemoryIdentityRepository: def _normalize_email(email: str) -> str: normalized = email.strip().lower() - if "@" not in normalized: - raise ValueError("email must contain @") + if ( + len(normalized) > 254 + or normalized.count("@") != 1 + or any(character.isspace() for character in normalized) + ): + raise ValueError("email must be a valid address") + local_part, domain = normalized.rsplit("@", 1) + if not local_part or not domain or domain.startswith(".") or domain.endswith("."): + raise ValueError("email must be a valid address") return normalized diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/identity_bootstrap.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/identity_bootstrap.py new file mode 100644 index 00000000..39d8f312 --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/identity_bootstrap.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from yuqei_contract_api.config import get_settings +from yuqei_contract_api.repository_factory import build_contract_repository + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Bootstrap or rotate a production tenant administrator credential.") + parser.add_argument("--tenant-id", default="default") + parser.add_argument("--company-name", default="平台租户") + parser.add_argument("--email", required=True) + parser.add_argument("--display-name", default="系统管理员") + password_source = parser.add_mutually_exclusive_group(required=True) + password_source.add_argument("--password-file") + password_source.add_argument("--password-stdin", action="store_true") + parser.add_argument("--force-rotate", action="store_true") + args = parser.parse_args(argv) + + password = _read_password(password_file=args.password_file, password_stdin=args.password_stdin) + settings = get_settings() + repository = build_contract_repository( + settings.database_url, + auto_create=False, + seed_demo_data=False, + file_storage_dir=settings.file_storage_dir, + ) + identity_repository = getattr(repository, "identity", None) + if identity_repository is None: + raise RuntimeError("Configured repository does not support identity bootstrap.") + result = identity_repository.bootstrap_admin_credential( + tenant_id=args.tenant_id, + company_name=args.company_name, + email=args.email, + display_name=args.display_name, + password=password, + force_rotate=args.force_rotate, + ) + print(result.model_dump_json()) + return 0 + + +def _read_password(*, password_file: str | None, password_stdin: bool) -> str: + if password_stdin: + return sys.stdin.read().strip() + if password_file: + try: + return Path(password_file).read_text(encoding="utf-8").strip() + except OSError as exc: + raise RuntimeError("Unable to read bootstrap password file.") from exc + raise RuntimeError("A bootstrap password source is required.") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/identity_email_delivery.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/identity_email_delivery.py new file mode 100644 index 00000000..ac8637f5 --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/identity_email_delivery.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import html +import smtplib +import ssl +from dataclasses import dataclass +from email.message import EmailMessage +from email.utils import formataddr +from pathlib import Path +from urllib.parse import quote + +from yuqei_contract_api.config import ContractApiSettings +from yuqei_contract_api.identity import EmailChallengeItem, TenantInvitationItem + + +class IdentityEmailDeliveryError(RuntimeError): + """Raised when an identity email cannot be handed to the configured SMTP server.""" + + +class IdentityEmailDelivery: + mode = "development" + + def send_challenge(self, challenge: EmailChallengeItem) -> None: + if not challenge.dev_code: + raise IdentityEmailDeliveryError("Email challenge code is unavailable for delivery.") + + def send_invitation(self, invitation: TenantInvitationItem) -> None: + if not invitation.invite_token: + raise IdentityEmailDeliveryError("Invitation token is unavailable for delivery.") + + +class DisabledIdentityEmailDelivery(IdentityEmailDelivery): + mode = "disabled" + + +@dataclass(frozen=True) +class SmtpIdentityEmailDelivery(IdentityEmailDelivery): + host: str + port: int + username: str + password: str + from_email: str + from_name: str + use_starttls: bool + use_ssl: bool + timeout_seconds: float + public_web_base_url: str + + mode = "smtp" + + def send_challenge(self, challenge: EmailChallengeItem) -> None: + code = challenge.dev_code + if not code: + raise IdentityEmailDeliveryError("Email challenge code is unavailable for delivery.") + + message = _build_challenge_message( + challenge, + code=code, + from_email=self.from_email, + from_name=self.from_name, + ) + try: + if self.use_ssl: + with smtplib.SMTP_SSL( + self.host, + self.port, + timeout=self.timeout_seconds, + context=ssl.create_default_context(), + ) as client: + self._authenticate_and_send(client, message) + return + + with smtplib.SMTP(self.host, self.port, timeout=self.timeout_seconds) as client: + client.ehlo() + if self.use_starttls: + client.starttls(context=ssl.create_default_context()) + client.ehlo() + self._authenticate_and_send(client, message) + except (OSError, smtplib.SMTPException) as exc: + raise IdentityEmailDeliveryError("SMTP delivery failed.") from exc + + def _authenticate_and_send(self, client: smtplib.SMTP, message: EmailMessage) -> None: + if self.username: + client.login(self.username, self.password) + client.send_message(message) + + def send_invitation(self, invitation: TenantInvitationItem) -> None: + token = invitation.invite_token + if not token: + raise IdentityEmailDeliveryError("Invitation token is unavailable for delivery.") + accept_url = ( + f"{self.public_web_base_url.rstrip('/')}/invite/accept" + f"?token={quote(token, safe='')}" + ) + message = _build_invitation_message( + invitation, + accept_url=accept_url, + from_email=self.from_email, + from_name=self.from_name, + ) + try: + if self.use_ssl: + with smtplib.SMTP_SSL( + self.host, + self.port, + timeout=self.timeout_seconds, + context=ssl.create_default_context(), + ) as client: + self._authenticate_and_send(client, message) + return + with smtplib.SMTP(self.host, self.port, timeout=self.timeout_seconds) as client: + client.ehlo() + if self.use_starttls: + client.starttls(context=ssl.create_default_context()) + client.ehlo() + self._authenticate_and_send(client, message) + except (OSError, smtplib.SMTPException) as exc: + raise IdentityEmailDeliveryError("SMTP delivery failed.") from exc + + +def build_identity_email_delivery(settings: ContractApiSettings) -> IdentityEmailDelivery: + if settings.identity_email_delivery_mode == "disabled": + return DisabledIdentityEmailDelivery() + if settings.identity_email_delivery_mode == "development": + return IdentityEmailDelivery() + return SmtpIdentityEmailDelivery( + host=settings.identity_smtp_host.strip(), + port=settings.identity_smtp_port, + username=settings.identity_smtp_username.strip(), + password=_resolve_smtp_password(settings), + from_email=settings.identity_smtp_from_email.strip(), + from_name=settings.identity_smtp_from_name.strip(), + use_starttls=settings.identity_smtp_use_starttls, + use_ssl=settings.identity_smtp_use_ssl, + timeout_seconds=settings.identity_smtp_timeout_seconds, + public_web_base_url=settings.identity_public_web_base_url.strip(), + ) + + +def _resolve_smtp_password(settings: ContractApiSettings) -> str: + secret_file = settings.identity_smtp_password_file.strip() + if secret_file: + try: + return Path(secret_file).read_text(encoding="utf-8").strip() + except OSError as exc: + raise IdentityEmailDeliveryError("Unable to read the SMTP password file.") from exc + return settings.identity_smtp_password.get_secret_value() + + +def _build_challenge_message( + challenge: EmailChallengeItem, + *, + code: str, + from_email: str, + from_name: str, +) -> EmailMessage: + is_password_reset = challenge.purpose == "password_reset" + action = "重置密码" if is_password_reset else "验证邮箱" + subject = f"AI 合同平台{action}验证码" + expires_minutes = max(1, round((challenge.expires_at - challenge.created_at).total_seconds() / 60)) + text = ( + f"您正在使用 AI 合同平台{action}。\n\n" + f"验证码:{code}\n" + f"有效期:{expires_minutes} 分钟\n\n" + "如非本人操作,请忽略本邮件。请勿向任何人提供验证码。" + ) + safe_code = html.escape(code) + html_body = f""" +
+

AI 合同平台{html.escape(action)}

+

请使用以下验证码完成操作:

+

{safe_code}

+

验证码将在 {expires_minutes} 分钟后失效。

+

如非本人操作,请忽略本邮件。请勿向任何人提供验证码。

+
+ """.strip() + + message = EmailMessage() + message["Subject"] = subject + message["From"] = formataddr((from_name, from_email)) if from_name else from_email + message["To"] = challenge.email + message["Auto-Submitted"] = "auto-generated" + message.set_content(text) + message.add_alternative(html_body, subtype="html") + return message + + +def _build_invitation_message( + invitation: TenantInvitationItem, + *, + accept_url: str, + from_email: str, + from_name: str, +) -> EmailMessage: + safe_url = html.escape(accept_url, quote=True) + text = ( + "您已被邀请加入 AI 合同平台。\n\n" + f"请打开以下链接设置密码并接受邀请:\n{accept_url}\n\n" + "如果您不认识邀请方,请忽略本邮件。" + ) + html_body = f""" +
+

加入 AI 合同平台

+

您已收到企业空间邀请,请设置密码并完成加入。

+

+ 接受邀请 +

+

链接将在邀请有效期结束后失效。如果您不认识邀请方,请忽略本邮件。

+
+ """.strip() + message = EmailMessage() + message["Subject"] = "AI 合同平台企业邀请" + message["From"] = formataddr((from_name, from_email)) if from_name else from_email + message["To"] = invitation.email + message["Auto-Submitted"] = "auto-generated" + message.set_content(text) + message.add_alternative(html_body, subtype="html") + return message diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/identity_rate_limit.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/identity_rate_limit.py new file mode 100644 index 00000000..7933f1a5 --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/identity_rate_limit.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import hashlib +import hmac +from dataclasses import dataclass +from datetime import datetime + + +class IdentityRateLimitExceeded(RuntimeError): + def __init__(self, *, retry_after_seconds: int) -> None: + super().__init__("Too many identity requests.") + self.retry_after_seconds = max(1, retry_after_seconds) + + +@dataclass(frozen=True) +class IdentityRateLimitEvent: + event_type: str + tenant_hash: str | None + email_hash: str | None + ip_hash: str | None + created_at: datetime + expires_at: datetime + + +def hash_identity_dimension(dimension: str, value: str, *, secret: str) -> str: + normalized = value.strip().lower() + key = secret.encode("utf-8") + payload = f"{dimension}:{normalized}".encode("utf-8") + return hmac.new(key, payload, hashlib.sha256).hexdigest() + + +def advisory_lock_id(value: str) -> int: + raw = bytes.fromhex(value[:16]) + return int.from_bytes(raw, byteorder="big", signed=True) diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/identity_security.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/identity_security.py index fa2c3b04..5680728f 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/identity_security.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/identity_security.py @@ -70,3 +70,6 @@ def _require_password(password: str) -> str: if len(normalized) < 8: raise ValueError("Password must be at least 8 characters.") return normalized + + +DUMMY_CREDENTIAL_HASH = hash_password(secrets.token_urlsafe(32)) 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 69177fe0..f5cb3e3d 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,12 +4,17 @@ from datetime import UTC, datetime, timedelta from typing import Literal from uuid import uuid4 -from sqlalchemy import func, select +from sqlalchemy import delete, func, select, text from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import sessionmaker from yuqei_contract_api.domain import normalize_role -from yuqei_contract_api.identity_security import HASH_SCHEME, hash_password +from yuqei_contract_api.identity_security import DUMMY_CREDENTIAL_HASH, HASH_SCHEME, hash_password +from yuqei_contract_api.identity_rate_limit import ( + IdentityRateLimitExceeded, + advisory_lock_id, + hash_identity_dimension, +) from yuqei_contract_api.identity import ( AccountSettings, AccountSettingsUpdateRequest, @@ -19,6 +24,7 @@ from yuqei_contract_api.identity import ( EmailVerificationResult, EmailVerificationSendRequest, IdentityConflictError, + IdentityBootstrapResult, IdentitySession, PasswordResetConfirmRequest, PasswordResetRequest, @@ -55,6 +61,7 @@ from yuqei_contract_api.identity_security import ( from yuqei_contract_api.persistence.models import ( DEFAULT_TENANT_ID, AccountSettingsModel, + IdentityRateLimitEventModel, TenantEmailChallengeModel, TenantInvitationModel, TenantModel, @@ -155,6 +162,122 @@ class IdentityRepository: ) ) + def bootstrap_admin_credential( + self, + *, + tenant_id: str, + company_name: str, + email: str, + display_name: str, + password: str, + force_rotate: bool = False, + actor: str = "identity-bootstrap", + ) -> IdentityBootstrapResult: + now = datetime.now(UTC) + normalized_email = _normalize_email(email) + password_hash = hash_password(password) + with self._session_factory.begin() as session: + tenant = session.get(TenantModel, tenant_id) + if tenant is None: + tenant = TenantModel( + id=tenant_id, + name=company_name.strip(), + status="active", + plan="enterprise", + region="cn", + settings={}, + created_by=actor, + updated_by=actor, + created_at=now, + updated_at=now, + ) + session.add(tenant) + elif tenant.deleted_at is not None or tenant.status != "active": + raise ValueError("Bootstrap tenant must be active.") + + user = session.execute( + select(TenantUserModel).where( + TenantUserModel.tenant_id == tenant_id, + TenantUserModel.email == normalized_email, + TenantUserModel.deleted_at.is_(None), + ) + ).scalar_one_or_none() + if user is None: + preferred_user_id = ( + "user-current-admin" + if tenant_id == DEFAULT_TENANT_ID + else f"user-{uuid4().hex[:12]}" + ) + if session.get(TenantUserModel, preferred_user_id) is not None: + preferred_user_id = f"user-{uuid4().hex[:12]}" + user = TenantUserModel( + id=preferred_user_id, + tenant_id=tenant_id, + email=normalized_email, + display_name=display_name.strip(), + role="admin", + status="active", + title="管理员", + department="平台运营", + phone=None, + settings={}, + invited_at=now, + last_login_at=None, + created_by=actor, + updated_by=actor, + created_at=now, + updated_at=now, + ) + session.add(user) + elif normalize_role(user.role) != "admin" or user.status != "active": + raise ValueError("Bootstrap account must be an active administrator.") + + credential = session.execute( + select(TenantUserCredentialModel).where( + TenantUserCredentialModel.tenant_id == tenant_id, + TenantUserCredentialModel.user_id == user.id, + TenantUserCredentialModel.deleted_at.is_(None), + ) + ).scalar_one_or_none() + if credential is not None and not force_rotate: + status = "already_configured" + elif credential is not None: + credential.email = normalized_email + credential.password_hash = password_hash + credential.password_scheme = HASH_SCHEME + credential.password_updated_at = now + credential.password_reset_required = False + credential.email_verified_at = now + credential.updated_by = actor + credential.updated_at = now + status = "rotated" + else: + session.add( + TenantUserCredentialModel( + id=f"credential-{uuid4().hex[:12]}", + tenant_id=tenant_id, + user_id=user.id, + email=normalized_email, + password_hash=password_hash, + password_scheme=HASH_SCHEME, + password_updated_at=now, + password_reset_required=False, + email_verified_at=now, + created_by=actor, + updated_by=actor, + created_at=now, + updated_at=now, + ) + ) + status = "created" + session.flush() + return IdentityBootstrapResult( + status=status, + tenant_id=tenant_id, + user_id=user.id, + email=normalized_email, + ) + def list_tenants(self, *, status: TenantStatus | None = None) -> TenantPage: with self._session_factory() as session: user_counts = dict( @@ -356,15 +479,12 @@ class IdentityRepository: statement = statement.where(TenantUserModel.tenant_id == request.tenant_id) users = session.execute(statement.order_by(TenantUserModel.updated_at.desc())).scalars().all() 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] - tenant = session.get(TenantModel, user.tenant_id) - if tenant is None or tenant.deleted_at is not None: - raise ValueError("Tenant not found.") - if tenant.status != "active": - raise ValueError("Tenant is not active.") credential = session.execute( select(TenantUserCredentialModel).where( TenantUserCredentialModel.tenant_id == user.tenant_id, @@ -372,11 +492,16 @@ class IdentityRepository: TenantUserCredentialModel.deleted_at.is_(None), ) ).scalar_one_or_none() - auth_mode: Literal["password", "legacy_email_session"] = "legacy_email_session" - if credential is not None: - if not request.password or not verify_password(request.password, credential.password_hash): - raise ValueError("Invalid email or password.") - auth_mode = "password" + 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): + raise ValueError("Invalid email or password.") + tenant = session.get(TenantModel, user.tenant_id) + if tenant is None or tenant.deleted_at is not None: + raise ValueError("Tenant not found.") + if tenant.status != "active": + raise ValueError("Tenant is not active.") user.last_login_at = datetime.now(UTC) user.updated_at = user.last_login_at account = session.execute( @@ -399,7 +524,7 @@ class IdentityRepository: tenant=_tenant_from_model(tenant, user_count=_count_users(session, tenant.id)), user=_user_from_model(user), account=_account_from_model(account), - auth_mode=auth_mode, + auth_mode="password", ) def create_invitation( @@ -447,6 +572,10 @@ class IdentityRepository: expires_at=now + timedelta(hours=max(1, request.expires_hours)), accepted_at=None, revoked_at=None, + delivery_status="queued", + delivery_attempted_at=None, + delivered_at=None, + delivery_error_code=None, created_by=request.created_by, updated_by=request.created_by, created_at=now, @@ -456,6 +585,7 @@ class IdentityRepository: with self._session_factory.begin() as session: self._ensure_tenant_exists(session, resolved_tenant_id, actor=request.created_by) session.add(user) + session.flush() session.add(account) session.add(invitation) session.flush() @@ -463,6 +593,24 @@ class IdentityRepository: except IntegrityError as exc: raise IdentityConflictError(f"User already exists in tenant: {user.email}") from exc + def record_invitation_delivery( + self, + invitation_id: str, + *, + status: str, + error_code: str | None = None, + ) -> None: + now = datetime.now(UTC) + with self._session_factory.begin() as session: + invitation = session.get(TenantInvitationModel, invitation_id) + if invitation is None or invitation.deleted_at is not None: + return + invitation.delivery_status = status + invitation.delivery_attempted_at = now + invitation.delivered_at = now if status in {"sent", "development"} else None + invitation.delivery_error_code = error_code[:128] if error_code else None + invitation.updated_at = now + def accept_invitation(self, request: TenantInvitationAcceptRequest) -> IdentitySession: now = datetime.now(UTC) token_hash = hash_invitation_token(request.token) @@ -553,24 +701,38 @@ class IdentityRepository: self, request: EmailVerificationSendRequest | PasswordResetRequest, *, - tenant_id: str = DEFAULT_TENANT_ID, + tenant_id: str | None = DEFAULT_TENANT_ID, purpose: EmailChallengePurpose | None = None, ) -> EmailChallengeItem: now = datetime.now(UTC) - resolved_tenant_id = request.tenant_id or tenant_id + scoped_tenant_id = request.tenant_id or tenant_id resolved_purpose: EmailChallengePurpose = purpose or getattr(request, "purpose", "email_verification") email = _normalize_email(request.email) with self._session_factory.begin() as session: - user = session.execute( - select(TenantUserModel).where( - TenantUserModel.tenant_id == resolved_tenant_id, - TenantUserModel.email == email, - TenantUserModel.status != "disabled", - TenantUserModel.deleted_at.is_(None), - ) - ).scalar_one_or_none() - if user is None: + statement = select(TenantUserModel).where( + TenantUserModel.email == email, + TenantUserModel.status != "disabled", + TenantUserModel.deleted_at.is_(None), + ) + if scoped_tenant_id is not None: + statement = statement.where(TenantUserModel.tenant_id == scoped_tenant_id) + users = session.execute(statement.order_by(TenantUserModel.updated_at.desc())).scalars().all() + if len(users) != 1: raise ValueError("User not found or disabled.") + user = users[0] + resolved_tenant_id = user.tenant_id + previous_challenges = session.execute( + select(TenantEmailChallengeModel).where( + TenantEmailChallengeModel.tenant_id == resolved_tenant_id, + TenantEmailChallengeModel.email == email, + TenantEmailChallengeModel.purpose == resolved_purpose, + TenantEmailChallengeModel.status == "pending", + TenantEmailChallengeModel.deleted_at.is_(None), + ) + ).scalars() + for previous in previous_challenges: + previous.status = "expired" + previous.updated_at = now code = create_email_code() model = TenantEmailChallengeModel( id=f"email-code-{uuid4().hex[:12]}", @@ -585,6 +747,10 @@ class IdentityRepository: expires_at=now + timedelta(minutes=max(1, request.expires_minutes)), sent_at=now, verified_at=None, + delivery_status="queued", + delivery_attempted_at=None, + delivered_at=None, + delivery_error_code=None, created_by=getattr(request, "created_by", None), updated_by=getattr(request, "created_by", None), created_at=now, @@ -594,48 +760,167 @@ class IdentityRepository: session.flush() return _email_challenge_from_model(model, dev_code=code) + def consume_identity_rate_limit( + self, + *, + event_type: str, + tenant_id: str | None, + email: str, + client_ip: str | None, + secret: str, + window_seconds: int, + email_limit: int, + ip_limit: int, + tenant_limit: int = 0, + cooldown_seconds: int = 0, + ) -> None: + now = datetime.now(UTC) + tenant_hash = hash_identity_dimension("tenant", tenant_id, secret=secret) if tenant_id else None + email_hash = hash_identity_dimension("email", _normalize_email(email), secret=secret) + ip_hash = hash_identity_dimension("ip", client_ip, secret=secret) if client_ip else None + hashes = [value for value in (tenant_hash, email_hash, ip_hash) if value] + with self._session_factory.begin() as session: + if session.get_bind().dialect.name == "postgresql": + for lock_id in sorted({advisory_lock_id(value) for value in hashes}): + session.execute(text("select pg_advisory_xact_lock(:lock_id)"), {"lock_id": lock_id}) + session.execute(delete(IdentityRateLimitEventModel).where(IdentityRateLimitEventModel.expires_at <= now)) + base_conditions = [ + IdentityRateLimitEventModel.event_type == event_type, + IdentityRateLimitEventModel.created_at >= now - timedelta(seconds=window_seconds), + ] + if cooldown_seconds: + cooldown_conditions = [ + IdentityRateLimitEventModel.event_type == event_type, + IdentityRateLimitEventModel.email_hash == email_hash, + ] + if tenant_hash: + cooldown_conditions.append(IdentityRateLimitEventModel.tenant_hash == tenant_hash) + latest = session.execute( + select(func.max(IdentityRateLimitEventModel.created_at)).where(*cooldown_conditions) + ).scalar_one() + if latest is not None: + remaining = cooldown_seconds - int((now - _as_utc(latest)).total_seconds()) + if remaining > 0: + raise IdentityRateLimitExceeded(retry_after_seconds=remaining) + dimensions = ( + (IdentityRateLimitEventModel.email_hash, email_hash, email_limit), + (IdentityRateLimitEventModel.ip_hash, ip_hash, ip_limit), + (IdentityRateLimitEventModel.tenant_hash, tenant_hash, tenant_limit), + ) + for column, value, limit in dimensions: + if not value or limit < 1: + continue + count = session.execute( + select(func.count()).select_from(IdentityRateLimitEventModel).where(*base_conditions, column == value) + ).scalar_one() + if count >= limit: + raise IdentityRateLimitExceeded(retry_after_seconds=window_seconds) + session.add( + IdentityRateLimitEventModel( + id=f"identity-rate-{uuid4().hex[:12]}", + event_type=event_type, + tenant_hash=tenant_hash, + email_hash=email_hash, + ip_hash=ip_hash, + created_at=now, + expires_at=now + timedelta(seconds=max(window_seconds, cooldown_seconds)), + ) + ) + + def record_email_challenge_delivery( + self, + challenge_id: str, + *, + status: str, + error_code: str | None = None, + ) -> None: + now = datetime.now(UTC) + with self._session_factory.begin() as session: + challenge = session.get(TenantEmailChallengeModel, challenge_id) + if challenge is None or challenge.deleted_at is not None: + return + challenge.delivery_status = status + challenge.delivery_attempted_at = now + challenge.delivered_at = now if status in {"sent", "development"} else None + challenge.delivery_error_code = error_code[:128] if error_code else None + challenge.updated_at = now + + def reset_identity_rate_limit( + self, + *, + event_type: str, + tenant_id: str | None, + email: str, + secret: str, + ) -> None: + tenant_hash = hash_identity_dimension("tenant", tenant_id, secret=secret) if tenant_id else None + email_hash = hash_identity_dimension("email", _normalize_email(email), secret=secret) + conditions = [ + IdentityRateLimitEventModel.event_type == event_type, + IdentityRateLimitEventModel.email_hash == email_hash, + ] + if tenant_hash: + conditions.append(IdentityRateLimitEventModel.tenant_hash == tenant_hash) + with self._session_factory.begin() as session: + session.execute(delete(IdentityRateLimitEventModel).where(*conditions)) + def confirm_email_challenge(self, request: EmailVerificationConfirmRequest) -> EmailVerificationResult: now = datetime.now(UTC) + error: str | None = None + result: EmailVerificationResult | None = None with self._session_factory.begin() as session: 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 challenge.purpose != "email_verification": + raise ValueError("Email challenge purpose is invalid.") if challenge.status != "pending" or _as_utc(challenge.expires_at) < now: challenge.status = "expired" if challenge.status == "pending" else challenge.status challenge.updated_at = now - raise ValueError("Email challenge is not available.") - if challenge.attempts >= challenge.max_attempts: + error = "Email challenge is not available." + elif challenge.attempts >= challenge.max_attempts: challenge.status = "locked" challenge.updated_at = now - raise ValueError("Email challenge is locked.") - challenge.attempts += 1 - if not verify_email_code(request.code, challenge.code_hash): - challenge.updated_at = now - raise ValueError("Invalid email code.") - challenge.status = "verified" - challenge.verified_at = now - challenge.updated_at = now - if challenge.purpose == "email_verification" and challenge.user_id: - credential = session.execute( - select(TenantUserCredentialModel).where( - TenantUserCredentialModel.tenant_id == challenge.tenant_id, - TenantUserCredentialModel.user_id == challenge.user_id, - TenantUserCredentialModel.deleted_at.is_(None), + error = "Email challenge is locked." + else: + challenge.attempts += 1 + if not verify_email_code(request.code, challenge.code_hash): + if challenge.attempts >= challenge.max_attempts: + challenge.status = "locked" + challenge.updated_at = now + error = "Invalid email code." + else: + challenge.status = "verified" + challenge.verified_at = now + challenge.updated_at = now + if challenge.purpose == "email_verification" and challenge.user_id: + credential = session.execute( + select(TenantUserCredentialModel).where( + TenantUserCredentialModel.tenant_id == challenge.tenant_id, + TenantUserCredentialModel.user_id == challenge.user_id, + TenantUserCredentialModel.deleted_at.is_(None), + ) + ).scalar_one_or_none() + if credential is not None: + credential.email_verified_at = now + credential.updated_by = "email-verification" + credential.updated_at = now + session.flush() + result = EmailVerificationResult( + verified=True, + challenge=_email_challenge_from_model(challenge), + session=None, ) - ).scalar_one_or_none() - if credential is not None: - credential.email_verified_at = now - credential.updated_by = "email-verification" - credential.updated_at = now - session.flush() - return EmailVerificationResult( - verified=True, - challenge=_email_challenge_from_model(challenge), - session=None, - ) + if error: + raise ValueError(error) + if result is None: # pragma: no cover - all persisted states above produce a result or error. + raise ValueError("Email challenge is not available.") + return result def confirm_password_reset(self, request: PasswordResetConfirmRequest) -> IdentitySession: now = datetime.now(UTC) + error: str | None = None + result: IdentitySession | None = None with self._session_factory.begin() as session: challenge = session.get(TenantEmailChallengeModel, request.challenge_id) if challenge is None or challenge.deleted_at is not None: @@ -645,68 +930,83 @@ class IdentityRepository: if challenge.status != "pending" or _as_utc(challenge.expires_at) < now: challenge.status = "expired" if challenge.status == "pending" else challenge.status challenge.updated_at = now - raise ValueError("Password reset challenge is not available.") - if challenge.attempts >= challenge.max_attempts: + error = "Password reset challenge is not available." + elif challenge.attempts >= challenge.max_attempts: challenge.status = "locked" challenge.updated_at = now - raise ValueError("Password reset challenge is locked.") - challenge.attempts += 1 - if not verify_email_code(request.code, challenge.code_hash): - challenge.updated_at = now - raise ValueError("Invalid email code.") - if not challenge.user_id: - raise ValueError("Password reset challenge has no user.") - user = session.get(TenantUserModel, challenge.user_id) - tenant = session.get(TenantModel, challenge.tenant_id) - if user is None or user.deleted_at is not None or tenant is None or tenant.deleted_at is not None: - raise ValueError("Password reset target not found.") - credential = session.execute( - select(TenantUserCredentialModel).where( - TenantUserCredentialModel.tenant_id == user.tenant_id, - TenantUserCredentialModel.user_id == user.id, - TenantUserCredentialModel.deleted_at.is_(None), - ) - ).scalar_one_or_none() - if credential is None: - credential = _credential_model_from_user_model( - user, - credential_id=f"credential-{uuid4().hex[:12]}", - password=request.new_password, - actor="password-reset", - now=now, - email_verified=True, - ) - session.add(credential) + error = "Password reset challenge is locked." else: - credential.password_hash = hash_password(request.new_password) - credential.password_updated_at = now - credential.password_reset_required = False - credential.email_verified_at = credential.email_verified_at or now - credential.updated_by = "password-reset" - credential.updated_at = now - user.status = "active" - user.last_login_at = now - user.updated_at = now - challenge.status = "verified" - challenge.verified_at = now - challenge.updated_at = now - account = session.execute( - select(AccountSettingsModel).where( - AccountSettingsModel.tenant_id == user.tenant_id, - AccountSettingsModel.user_id == user.id, - AccountSettingsModel.deleted_at.is_(None), - ) - ).scalar_one_or_none() - if account is None: - account = _account_model_from_user_model(user, account_id=f"account-{uuid4().hex[:12]}", actor="password-reset", now=now) - session.add(account) - 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="password", - ) + challenge.attempts += 1 + if not verify_email_code(request.code, challenge.code_hash): + if challenge.attempts >= challenge.max_attempts: + challenge.status = "locked" + challenge.updated_at = now + error = "Invalid email code." + elif not challenge.user_id: + error = "Password reset challenge has no user." + else: + user = session.get(TenantUserModel, challenge.user_id) + tenant = session.get(TenantModel, challenge.tenant_id) + if user is None or user.deleted_at is not None or tenant is None or tenant.deleted_at is not None: + error = "Password reset target not found." + else: + credential = session.execute( + select(TenantUserCredentialModel).where( + TenantUserCredentialModel.tenant_id == user.tenant_id, + TenantUserCredentialModel.user_id == user.id, + TenantUserCredentialModel.deleted_at.is_(None), + ) + ).scalar_one_or_none() + if credential is None: + credential = _credential_model_from_user_model( + user, + credential_id=f"credential-{uuid4().hex[:12]}", + password=request.new_password, + actor="password-reset", + now=now, + email_verified=True, + ) + session.add(credential) + else: + credential.password_hash = hash_password(request.new_password) + credential.password_updated_at = now + credential.password_reset_required = False + credential.email_verified_at = credential.email_verified_at or now + credential.updated_by = "password-reset" + credential.updated_at = now + user.status = "active" + user.last_login_at = now + user.updated_at = now + challenge.status = "verified" + challenge.verified_at = now + challenge.updated_at = now + account = session.execute( + select(AccountSettingsModel).where( + AccountSettingsModel.tenant_id == user.tenant_id, + AccountSettingsModel.user_id == user.id, + AccountSettingsModel.deleted_at.is_(None), + ) + ).scalar_one_or_none() + if account is None: + account = _account_model_from_user_model( + user, + account_id=f"account-{uuid4().hex[:12]}", + actor="password-reset", + now=now, + ) + session.add(account) + 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", + ) + if error: + raise ValueError(error) + if result is None: # pragma: no cover - all persisted states above produce a result or error. + raise ValueError("Password reset challenge is not available.") + return result def get_sso_config(self, *, tenant_id: str = DEFAULT_TENANT_ID, provider: SsoProvider = "saml") -> TenantSsoConfig: with self._session_factory() as session: 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 40f1f5f9..2f6095c8 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 @@ -115,6 +115,10 @@ class TenantInvitationModel(GovernanceMixin, Base): expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + delivery_status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued", index=True) + delivery_attempted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + delivery_error_code: Mapped[str | None] = mapped_column(String(128), nullable=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) @@ -139,10 +143,32 @@ class TenantEmailChallengeModel(GovernanceMixin, Base): expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + delivery_status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued", index=True) + delivery_attempted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + delivery_error_code: Mapped[str | None] = mapped_column(String(128), nullable=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 IdentityRateLimitEventModel(Base): + __tablename__ = "identity_rate_limit_events" + __table_args__ = ( + Index("ix_identity_rate_events_type_created", "event_type", "created_at"), + Index("ix_identity_rate_events_email_created", "email_hash", "created_at"), + Index("ix_identity_rate_events_ip_created", "ip_hash", "created_at"), + Index("ix_identity_rate_events_tenant_created", "tenant_hash", "created_at"), + ) + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + event_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + tenant_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) + email_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) + ip_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + expires_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 e8302fb3..e6badc94 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 @@ -28,6 +28,11 @@ CORE_TABLES = ( "tenants", "tenant_users", "account_settings", + "tenant_user_credentials", + "tenant_invitations", + "tenant_email_challenges", + "tenant_sso_configs", + "identity_rate_limit_events", "contracts", "contract_versions", "approval_instances", @@ -60,6 +65,10 @@ GOVERNANCE_COLUMNS_BY_TABLE = { "tenants": ("created_by", "updated_by", "deleted_at"), "tenant_users": ("created_by", "updated_by", "deleted_at"), "account_settings": ("created_by", "updated_by", "deleted_at"), + "tenant_user_credentials": ("created_by", "updated_by", "deleted_at"), + "tenant_invitations": ("created_by", "updated_by", "deleted_at"), + "tenant_email_challenges": ("created_by", "updated_by", "deleted_at"), + "tenant_sso_configs": ("created_by", "updated_by", "deleted_at"), "contracts": ("created_by", "updated_by", "deleted_at"), "contract_files": ("created_by", "updated_by", "deleted_at"), "review_tasks": ("tenant_id", "created_by", "updated_by", "deleted_at"), @@ -82,6 +91,28 @@ GOVERNANCE_COLUMNS_BY_TABLE = { ACTION_HINT_COLUMNS_BY_TABLE = { "contracts": ("action_hint",), } +IDENTITY_SECURITY_COLUMNS_BY_TABLE = { + "tenant_invitations": ( + "delivery_status", + "delivery_attempted_at", + "delivered_at", + "delivery_error_code", + ), + "tenant_email_challenges": ( + "delivery_status", + "delivery_attempted_at", + "delivered_at", + "delivery_error_code", + ), + "identity_rate_limit_events": ( + "event_type", + "tenant_hash", + "email_hash", + "ip_hash", + "created_at", + "expires_at", + ), +} class RuntimeSchemaCheck(BaseModel): @@ -133,6 +164,7 @@ def check_contract_schema( checks.append(_check_alembic_version(connection, table_names, strict=strict)) checks.append(_check_core_tables(table_names)) checks.append(_check_governance_columns(inspector, table_names, dialect=dialect)) + checks.append(_check_identity_security_columns(inspector, table_names, dialect=dialect)) action_hint_missing_columns = _contract_action_hint_missing_columns(inspector, table_names, dialect=dialect) checks.append(_check_action_hint_columns(inspector, table_names, dialect=dialect)) display_cache_missing_columns = _display_cache_missing_columns( @@ -339,6 +371,44 @@ def _check_action_hint_columns(inspector, table_names: set[str], *, dialect: str ) +def _check_identity_security_columns(inspector, table_names: set[str], *, dialect: str) -> RuntimeSchemaCheck: + schema = "public" if dialect == "postgresql" else None + missing: list[str] = [] + checked_columns = 0 + for table_name, expected_columns in IDENTITY_SECURITY_COLUMNS_BY_TABLE.items(): + if table_name not in table_names: + missing.extend(f"{table_name}.{column_name}" for column_name in expected_columns) + checked_columns += len(expected_columns) + continue + actual_columns = {column["name"] for column in inspector.get_columns(table_name, schema=schema)} + checked_columns += len(expected_columns) + missing.extend( + f"{table_name}.{column_name}" + for column_name in expected_columns + if column_name not in actual_columns + ) + if not missing: + return RuntimeSchemaCheck( + name="identity_security_columns", + status="ok", + message="Identity email delivery and rate-limit columns are present.", + details={ + "checked_tables": len(IDENTITY_SECURITY_COLUMNS_BY_TABLE), + "checked_columns": checked_columns, + }, + ) + return RuntimeSchemaCheck( + name="identity_security_columns", + status="error", + message="Identity email delivery or rate-limit columns are missing.", + details={ + "missing_columns": ",".join(sorted(missing)), + "checked_tables": len(IDENTITY_SECURITY_COLUMNS_BY_TABLE), + "checked_columns": checked_columns, + }, + ) + + def _display_cache_missing_columns(inspector, table_names: set[str], *, dialect: str) -> tuple[str, ...]: if "contracts" not in table_names: return () diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/sqlalchemy_contract_repository.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/sqlalchemy_contract_repository.py index ea645a7c..8fddd1cb 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/sqlalchemy_contract_repository.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/sqlalchemy_contract_repository.py @@ -247,6 +247,7 @@ class SqlAlchemyContractRepository(ContractRepository): ) if auto_create: Base.metadata.create_all(self.engine) + if auto_create or seed_demo_data: self.identity.seed_defaults() if seed_demo_data: self.seed_defaults() diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/sqlalchemy_query_orchestration.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/sqlalchemy_query_orchestration.py index e582b881..5992614b 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/sqlalchemy_query_orchestration.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/sqlalchemy_query_orchestration.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Callable +from concurrent.futures import Future import os from threading import Lock import time @@ -52,6 +53,7 @@ class SqlAlchemyQueryOrchestration: uvicorn_workers = _int_from_env("CONTRACT_API_UVICORN_WORKERS", 1) self._read_cache_ttl_seconds = configured_cache_ttl if uvicorn_workers == 1 else 0.0 self._read_cache: dict[tuple[object, ...], tuple[float, object]] = {} + self._read_cache_inflight: dict[tuple[object, ...], Future[object]] = {} self._read_cache_lock = Lock() def list_contracts( @@ -261,20 +263,44 @@ class SqlAlchemyQueryOrchestration: now = time.monotonic() if cached is not None and cached[0] > now: return cached[1] - value = loader() + flight = self._read_cache_inflight.get(key) + if flight is None: + flight = Future() + self._read_cache_inflight[key] = flight + is_loader = True + else: + is_loader = False + + if not is_loader: + return flight.result() + + try: + value = loader() + except BaseException as exc: + with self._read_cache_lock: + if self._read_cache_inflight.get(key) is flight: + del self._read_cache_inflight[key] + flight.set_exception(exc) + raise + now = time.monotonic() with self._read_cache_lock: cached = self._read_cache.get(key) if cached is not None and cached[0] > now: - return cached[1] - self._read_cache[key] = (now + self._read_cache_ttl_seconds, value) - if len(self._read_cache) > 256: - self._read_cache = { - cache_key: cache_value - for cache_key, cache_value in self._read_cache.items() - if cache_value[0] > now - } - return self._read_cache[key][1] + result = cached[1] + else: + self._read_cache[key] = (now + self._read_cache_ttl_seconds, value) + if len(self._read_cache) > 256: + self._read_cache = { + cache_key: cache_value + for cache_key, cache_value in self._read_cache.items() + if cache_value[0] > now + } + result = self._read_cache[key][1] + if self._read_cache_inflight.get(key) is flight: + del self._read_cache_inflight[key] + flight.set_result(result) + return result def clear_read_cache(self) -> None: with self._read_cache_lock: diff --git a/yuqei-contract-platform/services/contract-api/tests/test_contract_workflow.py b/yuqei-contract-platform/services/contract-api/tests/test_contract_workflow.py index 2a613ac5..72319c9a 100644 --- a/yuqei-contract-platform/services/contract-api/tests/test_contract_workflow.py +++ b/yuqei-contract-platform/services/contract-api/tests/test_contract_workflow.py @@ -2374,6 +2374,29 @@ def test_review_worker_calls_ai_platform_and_persists_result(tmp_path: Path, mon assert detail["audit_events"][0]["event_type"] == "review_completed" +def test_review_worker_route_forwards_ai_service_key(tmp_path: Path, monkeypatch) -> None: + captured: dict[str, object] = {} + + def fake_run_review_worker_once(**kwargs): + captured.update(kwargs) + return { + "processed": 0, + "succeeded": 0, + "failed": 0, + "claimed_task_ids": [], + } + + monkeypatch.setattr("yuqei_contract_api.api.review.run_review_worker_once", fake_run_review_worker_once) + settings = make_settings(tmp_path).model_copy(update={"ai_platform_service_key": "test-service-key"}) + app = create_app(settings) + client = TestClient(app) + + response = client.post("/api/v1/review-worker/run-once", json={"worker_name": "test-worker"}) + + assert response.status_code == 200 + assert captured["ai_platform_service_key"] == "test-service-key" + + def test_review_clause_repair_can_append_recommended_clause_to_contract_body(tmp_path: Path) -> None: app = create_app(make_settings(tmp_path)) client = TestClient(app) @@ -3737,7 +3760,7 @@ def test_sqlalchemy_repository_persists_contracts_and_approval_timeline(tmp_path ] -def test_cached_read_loader_runs_without_holding_the_lock() -> None: +def test_cached_read_coalesces_same_key_misses() -> None: import threading import time @@ -3750,6 +3773,55 @@ def test_cached_read_loader_runs_without_holding_the_lock() -> None: ) orchestration._read_cache_ttl_seconds = 10.0 + loader_calls = 0 + loader_calls_lock = threading.Lock() + loader_started = threading.Event() + loader_release = threading.Event() + + def loader() -> str: + nonlocal loader_calls + with loader_calls_lock: + loader_calls += 1 + loader_started.set() + assert loader_release.wait(timeout=2) + return "cached-value" + + results: list[str] = [] + errors: list[BaseException] = [] + + def worker() -> None: + try: + results.append(orchestration._cached_read(("contracts", "tenant-a"), loader)) + except BaseException as exc: # pragma: no cover - captured for assertion only. + errors.append(exc) + + first = threading.Thread(target=worker) + second = threading.Thread(target=worker) + first.start() + assert loader_started.wait(timeout=2) + second.start() + time.sleep(0.05) + loader_release.set() + first.join(timeout=3) + second.join(timeout=3) + + assert not errors + assert sorted(results) == ["cached-value", "cached-value"] + assert loader_calls == 1 + + +def test_cached_read_keeps_different_keys_parallel() -> None: + import threading + + from yuqei_contract_api.sqlalchemy_query_orchestration import SqlAlchemyQueryOrchestration + + orchestration = SqlAlchemyQueryOrchestration( + session_factory=lambda: object(), + tenant_id=lambda: "tenant-a", + search=object(), + ) + orchestration._read_cache_ttl_seconds = 10.0 + barrier = threading.Barrier(2) loader_calls = 0 loader_calls_lock = threading.Lock() @@ -3759,19 +3831,21 @@ def test_cached_read_loader_runs_without_holding_the_lock() -> None: with loader_calls_lock: loader_calls += 1 barrier.wait(timeout=2) - time.sleep(0.05) return "cached-value" results: list[str] = [] errors: list[BaseException] = [] - def worker() -> None: + def worker(key: tuple[str, str]) -> None: try: - results.append(orchestration._cached_read(("contracts", "tenant-a"), loader)) + results.append(orchestration._cached_read(key, loader)) except BaseException as exc: # pragma: no cover - captured for assertion only. errors.append(exc) - threads = [threading.Thread(target=worker) for _ in range(2)] + threads = [ + threading.Thread(target=worker, args=(("contracts", "tenant-a"),)), + threading.Thread(target=worker, args=(("search", "tenant-a"),)), + ] for thread in threads: thread.start() for thread in threads: @@ -3782,6 +3856,56 @@ def test_cached_read_loader_runs_without_holding_the_lock() -> None: assert loader_calls == 2 +def test_cached_read_exception_releases_single_flight() -> None: + import threading + import time + + from yuqei_contract_api.sqlalchemy_query_orchestration import SqlAlchemyQueryOrchestration + + orchestration = SqlAlchemyQueryOrchestration( + session_factory=lambda: object(), + tenant_id=lambda: "tenant-a", + search=object(), + ) + orchestration._read_cache_ttl_seconds = 10.0 + key = ("contracts", "tenant-a") + loader_calls = 0 + loader_started = threading.Event() + loader_release = threading.Event() + + def loader() -> str: + nonlocal loader_calls + loader_calls += 1 + if loader_calls == 1: + loader_started.set() + assert loader_release.wait(timeout=2) + raise RuntimeError("temporary query failure") + return "recovered-value" + + errors: list[BaseException] = [] + + def worker() -> None: + try: + orchestration._cached_read(key, loader) + except BaseException as exc: # pragma: no cover - captured for assertion only. + errors.append(exc) + + first = threading.Thread(target=worker) + second = threading.Thread(target=worker) + first.start() + assert loader_started.wait(timeout=2) + second.start() + time.sleep(0.05) + loader_release.set() + first.join(timeout=3) + second.join(timeout=3) + + assert len(errors) == 2 + assert all(isinstance(error, RuntimeError) for error in errors) + assert orchestration._cached_read(key, loader) == "recovered-value" + assert loader_calls == 2 + + def test_contract_query_read_cache_ttl_can_be_enabled_explicitly(monkeypatch) -> None: monkeypatch.setenv("CONTRACT_API_READ_CACHE_TTL_SECONDS", "10") monkeypatch.setenv("CONTRACT_API_UVICORN_WORKERS", "1") diff --git a/yuqei-contract-platform/services/contract-api/tests/test_health.py b/yuqei-contract-platform/services/contract-api/tests/test_health.py index 3286babf..1e3d3308 100644 --- a/yuqei-contract-platform/services/contract-api/tests/test_health.py +++ b/yuqei-contract-platform/services/contract-api/tests/test_health.py @@ -132,7 +132,9 @@ 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["governance_columns"]["status"] == "ok" - assert checks["governance_columns"]["details"]["checked_tables"] == 21 + assert checks["governance_columns"]["details"]["checked_tables"] == 25 + assert checks["identity_security_columns"]["status"] == "ok" + assert checks["identity_security_columns"]["details"]["checked_tables"] == 3 assert checks["action_hint_columns"]["status"] == "ok" assert checks["action_hint_columns"]["details"]["checked_tables"] == 1 assert checks["display_cache_consistency"]["status"] == "ok" 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 3e759699..f7ba09f4 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 @@ -138,7 +138,8 @@ def test_identity_register_company_login_and_invite_flow(tmp_path: Path) -> None "/api/v1/identity/login", json={"email": "founder.admin@yuqei.com", "password": "wrong-password"}, ) - assert bad_login_response.status_code == 404 + assert bad_login_response.status_code == 401 + assert bad_login_response.json() == {"detail": "Unable to sign in with the supplied credentials."} account_response = client.get( "/api/v1/identity/account", @@ -306,7 +307,7 @@ def test_identity_password_reset_flow(tmp_path: Path) -> None: "/api/v1/identity/login", json={"tenant_id": "tenant-reset", "email": "reset.owner@example.com", "password": "OldPassword123"}, ) - assert old_login_response.status_code == 404 + assert old_login_response.status_code == 401 new_login_response = client.post( "/api/v1/identity/login", 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 new file mode 100644 index 00000000..1db229ff --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/tests/test_identity_security.py @@ -0,0 +1,610 @@ +from __future__ import annotations + +import smtplib +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import delete, func, select + +from yuqei_contract_api.config import ContractApiSettings, check_runtime_config +from yuqei_contract_api.identity import EmailChallengeItem +from yuqei_contract_api.identity_email_delivery import ( + IdentityEmailDeliveryError, + build_identity_email_delivery, +) +from yuqei_contract_api.main import create_app +from yuqei_contract_api.persistence.models import ( + IdentityRateLimitEventModel, + TenantEmailChallengeModel, + TenantInvitationModel, + TenantUserCredentialModel, +) + + +def make_settings(tmp_path: Path, **overrides) -> ContractApiSettings: + values = { + "environment": "test", + "database_url": f"sqlite+pysqlite:///{(tmp_path / 'identity-security.sqlite3').as_posix()}", + "database_auto_create": True, + "seed_demo_data": False, + "identity_rate_limit_secret": "test-identity-rate-limit-secret-32-bytes", # pragma: allowlist secret + "identity_challenge_resend_cooldown_seconds": 0, + } + values.update(overrides) + return ContractApiSettings(**values) + + +def register_user(client: TestClient, *, tenant_id: str = "tenant-security", email: str = "owner@example.com") -> None: + response = client.post( + "/api/v1/identity/register-company", + json={ + "tenant_id": tenant_id, + "company_name": "Security Test Co", + "admin_email": email, + "admin_display_name": "Security Owner", + "admin_password": "SecurePassword123", # pragma: allowlist secret + }, + ) + assert response.status_code == 200 + + +def test_login_errors_do_not_enumerate_accounts(tmp_path: Path) -> None: + client = TestClient(create_app(make_settings(tmp_path))) + register_user(client) + + wrong_password = client.post( + "/api/v1/identity/login", + json={"tenant_id": "tenant-security", "email": "owner@example.com", "password": "wrong-password"}, + ) + unknown_account = client.post( + "/api/v1/identity/login", + json={"tenant_id": "tenant-security", "email": "missing@example.com", "password": "wrong-password"}, + ) + + assert wrong_password.status_code == unknown_account.status_code == 401 + assert wrong_password.json() == unknown_account.json() == { + "detail": "Unable to sign in with the supplied credentials." + } + + +def test_password_reset_unknown_email_uses_generic_success_response(tmp_path: Path, monkeypatch) -> None: + sent_messages = _install_fake_smtp(monkeypatch) + settings = make_settings( + tmp_path, + environment="production", + identity_email_delivery_mode="smtp", + identity_smtp_host="smtp.example.com", + identity_smtp_username="mailer@example.com", + identity_smtp_password="smtp-test-password", # pragma: allowlist secret + identity_smtp_from_email="mailer@example.com", + identity_public_web_base_url="https://contracts.example.com", + ) + app = create_app(settings) + client = TestClient(app) + register_user(client) + + known = client.post( + "/api/v1/identity/password-reset/request", + json={"tenant_id": "tenant-security", "email": "owner@example.com"}, + ) + unknown = client.post( + "/api/v1/identity/password-reset/request", + json={"tenant_id": "tenant-security", "email": "missing@example.com"}, + ) + + assert known.status_code == unknown.status_code == 200 + assert set(known.json()) == set(unknown.json()) + assert known.json()["dev_code"] is None + assert unknown.json()["dev_code"] is None + assert known.json()["user_id"] is None + assert unknown.json()["user_id"] is None + assert len(sent_messages) == 1 + assert sent_messages[0]["To"] == "owner@example.com" + with app.state.contract_repository._session_factory() as session: # noqa: SLF001 + assert session.scalar(select(func.count()).select_from(TenantEmailChallengeModel)) == 1 + challenge = session.scalar(select(TenantEmailChallengeModel)) + assert challenge is not None + assert challenge.delivery_status == "sent" + + +def test_production_invitation_is_emailed_and_token_is_redacted(tmp_path: Path, monkeypatch) -> None: + sent_messages = _install_fake_smtp(monkeypatch) + settings = make_settings( + tmp_path, + environment="production", + identity_email_delivery_mode="smtp", + identity_smtp_host="smtp.example.com", + identity_smtp_username="mailer@example.com", + identity_smtp_password="smtp-test-password", # pragma: allowlist secret + identity_smtp_from_email="mailer@example.com", + identity_public_web_base_url="https://contracts.example.com", + ) + app = create_app(settings) + client = TestClient(app) + register_user(client) + + response = client.post( + "/api/v1/identity/invitations", + json={ + "tenant_id": "tenant-security", + "email": "invitee@example.com", + "display_name": "Invitee", + "role": "business", + }, + ) + + assert response.status_code == 200 + assert response.json()["invite_token"] is None + assert len(sent_messages) == 1 + message = sent_messages[0] + assert message["To"] == "invitee@example.com" + assert "https://contracts.example.com/invite/accept?token=" in message.get_body(preferencelist=("plain",)).get_content() + with app.state.contract_repository._session_factory() as session: # noqa: SLF001 + invitation = session.scalar(select(TenantInvitationModel)) + assert invitation is not None + assert invitation.delivery_status == "sent" + + +def test_production_disabled_email_mode_supports_admin_managed_onboarding(tmp_path: Path) -> None: + proxy_secret = "production-web-proxy-secret-32-bytes" # pragma: allowlist secret + settings = make_settings( + tmp_path, + environment="production", + identity_email_delivery_mode="disabled", + identity_self_service_enabled=False, + identity_manual_invitation_enabled=True, + require_web_proxy_auth=True, + web_proxy_shared_secret=proxy_secret, + ) + app = create_app(settings) + client = TestClient(app, headers={"X-Contract-Proxy-Key": proxy_secret}) + + capabilities = client.get("/api/v1/identity/capabilities") + assert capabilities.status_code == 200 + assert capabilities.json() == { + "email_delivery_mode": "disabled", + "registration_enabled": False, + "password_reset_enabled": False, + "email_verification_enabled": False, + "invitation_enabled": True, + "manual_invitation_enabled": True, + } + + registration_payload = { + "tenant_id": "tenant-offline", + "company_name": "Offline Onboarding Co", + "admin_email": "owner@offline.example.com", + "admin_display_name": "Offline Owner", + "admin_password": "SecurePassword123", # pragma: allowlist secret + } + public_registration = client.post( + "/api/v1/identity/register-company", + json=registration_payload, + ) + assert public_registration.status_code == 403 + + platform_headers = { + "X-Tenant-Id": "default", + "X-User-Id": "user-current-admin", + } + admin_registration = client.post( + "/api/v1/identity/register-company", + headers=platform_headers, + json=registration_payload, + ) + assert admin_registration.status_code == 200 + tenant_admin = admin_registration.json()["user"] + + tenant_headers = { + "X-Tenant-Id": "tenant-offline", + "X-User-Id": tenant_admin["id"], + } + invitation = client.post( + "/api/v1/identity/invitations", + headers=tenant_headers, + json={ + "email": "employee@offline.example.com", + "display_name": "Offline Employee", + "role": "business", + }, + ) + assert invitation.status_code == 200 + invitation_token = invitation.json()["invite_token"] + assert invitation_token + + accepted = client.post( + "/api/v1/identity/invitations/accept", + json={ + "token": invitation_token, + "password": "EmployeePassword123", # pragma: allowlist secret + }, + ) + assert accepted.status_code == 200 + assert accepted.json()["user"]["status"] == "active" + + login = client.post( + "/api/v1/identity/login", + json={ + "tenant_id": "tenant-offline", + "email": "employee@offline.example.com", + "password": "EmployeePassword123", # pragma: allowlist secret + }, + ) + assert login.status_code == 200 + + password_reset = client.post( + "/api/v1/identity/password-reset/request", + json={"tenant_id": "tenant-offline", "email": "owner@offline.example.com"}, + ) + assert password_reset.status_code == 403 + email_verification = client.post( + "/api/v1/identity/email-verification/send", + headers=tenant_headers, + json={"tenant_id": "tenant-offline", "email": "owner@offline.example.com"}, + ) + assert email_verification.status_code == 403 + + with app.state.contract_repository._session_factory() as session: # noqa: SLF001 + stored = session.scalar(select(TenantInvitationModel)) + assert stored is not None + assert stored.delivery_status == "manual" + + +def test_identity_admin_bootstrap_is_idempotent_and_rotation_is_explicit(tmp_path: Path) -> None: + app = create_app(make_settings(tmp_path)) + identity_repository = app.state.contract_repository.identity + created = identity_repository.bootstrap_admin_credential( + tenant_id="tenant-bootstrap", + company_name="Bootstrap Co", + email="bootstrap-admin@example.com", + display_name="Bootstrap Admin", + password="BootstrapPassword123", # pragma: allowlist secret + ) + assert created.status == "created" + + unchanged = identity_repository.bootstrap_admin_credential( + tenant_id="tenant-bootstrap", + company_name="Bootstrap Co", + email="bootstrap-admin@example.com", + display_name="Bootstrap Admin", + password="IgnoredPassword123", # pragma: allowlist secret + ) + assert unchanged.status == "already_configured" + + client = TestClient(app) + original_login = client.post( + "/api/v1/identity/login", + json={ + "tenant_id": "tenant-bootstrap", + "email": "bootstrap-admin@example.com", + "password": "BootstrapPassword123", # pragma: allowlist secret + }, + ) + assert original_login.status_code == 200 + + rotated = identity_repository.bootstrap_admin_credential( + tenant_id="tenant-bootstrap", + company_name="Bootstrap Co", + email="bootstrap-admin@example.com", + display_name="Bootstrap Admin", + password="RotatedPassword123", # pragma: allowlist secret + force_rotate=True, + ) + assert rotated.status == "rotated" + old_login = client.post( + "/api/v1/identity/login", + json={ + "tenant_id": "tenant-bootstrap", + "email": "bootstrap-admin@example.com", + "password": "BootstrapPassword123", # pragma: allowlist secret + }, + ) + new_login = client.post( + "/api/v1/identity/login", + json={ + "tenant_id": "tenant-bootstrap", + "email": "bootstrap-admin@example.com", + "password": "RotatedPassword123", # pragma: allowlist secret + }, + ) + assert old_login.status_code == 401 + assert new_login.status_code == 200 + + +def test_demo_seed_backfills_default_admin_credential_after_migrations(tmp_path: Path) -> None: + database_url = f"sqlite+pysqlite:///{(tmp_path / 'seed-backfill.sqlite3').as_posix()}" + initial_app = create_app( + ContractApiSettings( + environment="test", + database_url=database_url, + database_auto_create=True, + seed_demo_data=False, + ) + ) + with initial_app.state.contract_repository._session_factory.begin() as session: # noqa: SLF001 + session.execute(delete(TenantUserCredentialModel)) + + backfilled_app = create_app( + ContractApiSettings( + environment="test", + database_url=database_url, + database_auto_create=False, + seed_demo_data=True, + ) + ) + with backfilled_app.state.contract_repository._session_factory() as session: # noqa: SLF001 + credentials = session.scalars(select(TenantUserCredentialModel)).all() + assert len(credentials) == 1 + assert credentials[0].user_id == "user-current-admin" + assert credentials[0].email == "admin@example.com" + + +def test_password_reset_rate_limits_email_and_ip(tmp_path: Path) -> None: + email_settings = make_settings( + tmp_path, + identity_challenge_email_limit=2, + identity_challenge_ip_limit=100, + identity_challenge_tenant_limit=100, + ) + email_app = create_app(email_settings) + email_client = TestClient(email_app) + + for _ in range(2): + response = email_client.post( + "/api/v1/identity/password-reset/request", + json={"tenant_id": "unknown-tenant", "email": "unknown@example.com"}, + ) + assert response.status_code == 200 + limited_email = email_client.post( + "/api/v1/identity/password-reset/request", + json={"tenant_id": "unknown-tenant", "email": "unknown@example.com"}, + ) + assert limited_email.status_code == 429 + assert int(limited_email.headers["Retry-After"]) > 0 + + ip_settings = make_settings( + tmp_path / "ip", + identity_challenge_email_limit=100, + identity_challenge_ip_limit=2, + identity_challenge_tenant_limit=100, + require_web_proxy_auth=True, + web_proxy_shared_secret="test-web-proxy-shared-secret-32-bytes", # pragma: allowlist secret + ) + ip_app = create_app(ip_settings) + ip_client = TestClient( + ip_app, + headers={ + "X-Contract-Proxy-Key": "test-web-proxy-shared-secret-32-bytes", # pragma: allowlist secret + "X-Identity-Client-IP": "203.0.113.10", + }, + ) + for index in range(2): + response = ip_client.post( + "/api/v1/identity/password-reset/request", + json={"tenant_id": "unknown-tenant", "email": f"unknown-{index}@example.com"}, + ) + assert response.status_code == 200 + limited_ip = ip_client.post( + "/api/v1/identity/password-reset/request", + json={"tenant_id": "unknown-tenant", "email": "unknown-3@example.com"}, + ) + assert limited_ip.status_code == 429 + with ip_app.state.contract_repository._session_factory() as session: # noqa: SLF001 + assert session.scalar(select(func.count()).select_from(IdentityRateLimitEventModel)) == 2 + + +def test_password_reset_enforces_resend_cooldown_and_tenant_limit(tmp_path: Path) -> None: + cooldown_app = create_app( + make_settings( + tmp_path / "cooldown", + identity_challenge_resend_cooldown_seconds=60, + identity_challenge_email_limit=100, + identity_challenge_ip_limit=100, + identity_challenge_tenant_limit=100, + ) + ) + cooldown_client = TestClient(cooldown_app) + first = cooldown_client.post( + "/api/v1/identity/password-reset/request", + json={"tenant_id": "tenant-cooldown", "email": "cooldown@example.com"}, + ) + second = cooldown_client.post( + "/api/v1/identity/password-reset/request", + json={"tenant_id": "tenant-cooldown", "email": "cooldown@example.com"}, + ) + assert first.status_code == 200 + assert second.status_code == 429 + assert 1 <= int(second.headers["Retry-After"]) <= 60 + + tenant_app = create_app( + make_settings( + tmp_path / "tenant", + identity_challenge_email_limit=100, + identity_challenge_ip_limit=100, + identity_challenge_tenant_limit=2, + ) + ) + tenant_client = TestClient(tenant_app) + for index in range(2): + response = tenant_client.post( + "/api/v1/identity/password-reset/request", + json={"tenant_id": "tenant-shared", "email": f"tenant-{index}@example.com"}, + ) + assert response.status_code == 200 + tenant_limited = tenant_client.post( + "/api/v1/identity/password-reset/request", + json={"tenant_id": "tenant-shared", "email": "tenant-3@example.com"}, + ) + assert tenant_limited.status_code == 429 + + +def test_email_code_fifth_failure_persists_lock(tmp_path: Path) -> None: + app = create_app(make_settings(tmp_path)) + client = TestClient(app) + register_user(client) + requested = client.post( + "/api/v1/identity/password-reset/request", + json={"tenant_id": "tenant-security", "email": "owner@example.com"}, + ) + challenge = requested.json() + wrong_code = "000000" if challenge["dev_code"] != "000000" else "999999" + + for _ in range(5): + response = client.post( + "/api/v1/identity/password-reset/confirm", + json={ + "challenge_id": challenge["id"], + "code": wrong_code, + "new_password": "NewSecurePassword123", # pragma: allowlist secret + }, + ) + assert response.status_code == 400 + + with app.state.contract_repository._session_factory() as session: # noqa: SLF001 + stored = session.get(TenantEmailChallengeModel, challenge["id"]) + assert stored is not None + assert stored.attempts == 5 + assert stored.status == "locked" + valid_after_lock = client.post( + "/api/v1/identity/password-reset/confirm", + json={ + "challenge_id": challenge["id"], + "code": challenge["dev_code"], + "new_password": "NewSecurePassword123", # pragma: allowlist secret + }, + ) + assert valid_after_lock.status_code == 400 + + +def test_email_verification_cannot_consume_password_reset_challenge(tmp_path: Path) -> None: + app = create_app(make_settings(tmp_path)) + client = TestClient(app) + register_user(client) + requested = client.post( + "/api/v1/identity/password-reset/request", + json={"tenant_id": "tenant-security", "email": "owner@example.com"}, + ) + challenge = requested.json() + + wrong_endpoint = client.post( + "/api/v1/identity/email-verification/confirm", + json={"challenge_id": challenge["id"], "code": challenge["dev_code"]}, + ) + assert wrong_endpoint.status_code == 400 + with app.state.contract_repository._session_factory() as session: # noqa: SLF001 + stored = session.get(TenantEmailChallengeModel, challenge["id"]) + assert stored is not None + assert stored.status == "pending" + assert stored.attempts == 0 + + +def test_smtp_delivery_wraps_transport_failure(monkeypatch) -> None: + class FailingSmtp(_FakeSmtp): + def send_message(self, message) -> None: + raise smtplib.SMTPException("transport unavailable") + + monkeypatch.setattr("yuqei_contract_api.identity_email_delivery.smtplib.SMTP", FailingSmtp) + settings = ContractApiSettings( + identity_email_delivery_mode="smtp", + identity_smtp_host="smtp.example.com", + identity_smtp_from_email="mailer@example.com", + ) + delivery = build_identity_email_delivery(settings) + + with pytest.raises(IdentityEmailDeliveryError, match="SMTP delivery failed"): + delivery.send_challenge(_challenge()) + + +def test_production_runtime_config_requires_smtp_and_rate_limit_secret() -> None: + unsafe = ContractApiSettings(environment="production") + unsafe_checks = {item.name: item for item in check_runtime_config(unsafe).checks} + assert unsafe_checks["identity_email_delivery"].status == "error" + assert unsafe_checks["identity_rate_limit"].status == "error" + + configured = ContractApiSettings( + environment="production", + identity_email_delivery_mode="smtp", + identity_smtp_host="smtp.example.com", + identity_smtp_from_email="mailer@example.com", + identity_public_web_base_url="https://contracts.example.com", + identity_rate_limit_secret="production-identity-rate-secret-32-bytes", # pragma: allowlist secret + ) + configured_checks = {item.name: item for item in check_runtime_config(configured).checks} + assert configured_checks["identity_email_delivery"].status == "ok" + assert configured_checks["identity_rate_limit"].status == "ok" + + offline = ContractApiSettings( + environment="production", + identity_email_delivery_mode="disabled", + identity_self_service_enabled=False, + identity_manual_invitation_enabled=True, + identity_rate_limit_secret="production-identity-rate-secret-32-bytes", # pragma: allowlist secret + ) + offline_checks = {item.name: item for item in check_runtime_config(offline).checks} + assert offline_checks["identity_email_delivery"].status == "ok" + + unsafe_offline = ContractApiSettings( + environment="production", + identity_email_delivery_mode="disabled", + identity_rate_limit_secret="production-identity-rate-secret-32-bytes", # pragma: allowlist secret + ) + unsafe_offline_checks = {item.name: item for item in check_runtime_config(unsafe_offline).checks} + assert unsafe_offline_checks["identity_email_delivery"].status == "error" + + +class _FakeSmtp: + messages: list = [] + + def __init__(self, host: str, port: int, *, timeout: float) -> None: + self.host = host + self.port = port + self.timeout = timeout + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback) -> None: + return None + + def ehlo(self) -> None: + return None + + def starttls(self, *, context) -> None: + return None + + def login(self, username: str, password: str) -> None: + return None + + def send_message(self, message) -> None: + self.messages.append(message) + + +def _install_fake_smtp(monkeypatch) -> list: + messages: list = [] + + class CapturingSmtp(_FakeSmtp): + def send_message(self, message) -> None: + messages.append(message) + + monkeypatch.setattr("yuqei_contract_api.identity_email_delivery.smtplib.SMTP", CapturingSmtp) + return messages + + +def _challenge() -> EmailChallengeItem: + now = datetime.now(UTC) + return EmailChallengeItem( + id="email-code-test", + tenant_id="tenant-security", + user_id="user-security", + email="owner@example.com", + purpose="password_reset", + status="pending", + expires_at=now + timedelta(minutes=10), + sent_at=now, + verified_at=None, + dev_code="123456", + created_at=now, + updated_at=now, + ) diff --git a/yuqei-infra/README.md b/yuqei-infra/README.md index d645bb69..aaf0f5fd 100644 --- a/yuqei-infra/README.md +++ b/yuqei-infra/README.md @@ -88,6 +88,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File yuqei-infra\scripts\depl The script: - Packages only V2 source and infrastructure directories. +- Excludes local `.env*` runtime files from the release archive. - Uploads the archive to `192.168.3.90`. - Starts compose with `yuqei_contract`, `yuqei_ai`, and `yuqei_ops`. - Runs health checks for Contract Web, Contract API, and AI Platform API. @@ -98,7 +99,7 @@ The script: Latest passing smoke artifact: -- `project-docs\qa-artifacts\v2-smoke-docker-deploy\2026-07-12T13-21-24` +- `project-docs\qa-artifacts\v2-smoke-docker-deploy\2026-07-16T11-12-47` - AI golden sample asset gate passed with `90` samples, `6/6` suites, and `16/16` checks. - Contract review worker smoke completed. - Remote database baseline passed for `yuqei_contract`, `yuqei_ai`, and `yuqei_ops`. @@ -106,6 +107,10 @@ Latest passing smoke artifact: After smoke passes, the old production deployment can be replaced deliberately: backend/AI/worker/database/MinIO on `192.168.3.90`, frontend on Aliyun, and `contact.yuqei.com` kept as the public entry. +Production is now a separate, guarded path. Use `compose/docker-compose.production.yml` together with the shared compose file, then follow `deploy/README.md`. The production path requires digest-pinned images, loopback-only internal ports, TLS edge configuration, an off-host backup mount, protected provider secret files, and a passing `deploy/preflight-production.py`. It intentionally has no automatic traffic-cutover command. + +Real-provider acceptance uses `scripts/run-ai-provider-acceptance.py`. It exercises 10 legal QA and 8 contract-review Phase13 samples and separates technical Provider success from named legal approval. The existing asset-count golden gate does not replace this runtime evaluation. + ## Release Quality Gate Use this after a smoke deploy and interface benchmark to turn the release checks into one pass/fail gate: diff --git a/yuqei-infra/backup/README.md b/yuqei-infra/backup/README.md new file mode 100644 index 00000000..25c35131 --- /dev/null +++ b/yuqei-infra/backup/README.md @@ -0,0 +1,66 @@ +# Production backup and restore verification + +Application backup APIs are not infrastructure backups. Production evidence requires PostgreSQL custom dumps, contract-file archives, MinIO object exports, checksums, and a disposable database restore rehearsal. + +## Backup + +Dry-run: + +```bash +python3 yuqei-infra/backup/run-production-backup.py \ + --env-file /etc/yuqei/production.env +``` + +Maintenance-window backup: + +```bash +python3 yuqei-infra/backup/run-production-backup.py \ + --env-file /etc/yuqei/production.env \ + --apply \ + --quiesce-writers \ + --prune-keep 14 +``` + +`BACKUP_HOST_DIR` must normally be a separate NAS/NFS/OSS-mounted filesystem. `--allow-local-target` exists only for rehearsals and cannot be used as production disaster-recovery evidence. + +Each completed directory contains: + +- three PostgreSQL custom-format dumps; +- `contract-files.tar.gz`; +- a MinIO bucket mirror; +- `manifest.json` with release ID, image digests, sizes, and SHA-256 values. + +Incomplete runs remain as `.backup-*.partial` with a `FAILED` marker and are never treated as completed backups. + +## Restore check + +Checksum and archive verification is read-only: + +```bash +python3 yuqei-infra/backup/verify-production-backup.py \ + --backup-dir /mnt/yuqei-v2-backups/backup-YYYYMMDDTHHMMSSZ +``` + +Disposable database restore rehearsal: + +```bash +python3 yuqei-infra/backup/verify-production-backup.py \ + --backup-dir /mnt/yuqei-v2-backups/backup-YYYYMMDDTHHMMSSZ \ + --env-file /etc/yuqei/production.env \ + --restore-databases \ + --apply +``` + +The script creates only generated `yuqei_restore_check_*` databases and drops them in `finally`. It never restores into the configured production database names. Contract-file and MinIO artifacts are integrity-checked but are not written into production volumes by this command. + +## Scheduling + +Install `yuqei-production-backup.service` and `.timer` under `/etc/systemd/system`, then use: + +```bash +systemctl daemon-reload +systemctl enable --now yuqei-production-backup.timer +systemctl list-timers yuqei-production-backup.timer +``` + +The default timer uses a short nightly maintenance window. Change that only after validating an online cross-store consistency strategy. diff --git a/yuqei-infra/backup/run-production-backup.py b/yuqei-infra/backup/run-production-backup.py new file mode 100644 index 00000000..52c58286 --- /dev/null +++ b/yuqei-infra/backup/run-production-backup.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +"""Create a checksummed PostgreSQL, contract-file, and MinIO production backup.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shutil +# Docker commands use fixed argv lists and never invoke a host shell. +import subprocess # nosec B404 +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import BinaryIO + + +REPO_ROOT = Path(__file__).resolve().parents[2] +BASE_COMPOSE = REPO_ROOT / "yuqei-infra" / "compose" / "docker-compose.yml" +PRODUCTION_COMPOSE = REPO_ROOT / "yuqei-infra" / "compose" / "docker-compose.production.yml" +IDENTIFIER_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") +BACKUP_RUN_PATTERN = re.compile(r"^backup-[0-9]{8}T[0-9]{6}Z$") +WRITER_SERVICES = ("contract-api", "contract-review-worker", "ai-platform-api", "worker") + + +def parse_env_file(path: Path) -> dict[str, str]: + values: dict[str, str] = {} + for raw_line in path.read_text(encoding="utf-8-sig").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line[7:].lstrip() + if "=" not in line: + continue + key, value = line.split("=", 1) + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + value = value[1:-1] + values[key.strip()] = value + return values + + +def compose_command(env_file: Path, project_name: str, *args: str, ops_profile: bool = False) -> list[str]: + command = [ + "docker", + "compose", + "--project-name", + project_name, + "--env-file", + str(env_file), + "-f", + str(BASE_COMPOSE), + "-f", + str(PRODUCTION_COMPOSE), + ] + if ops_profile: + command.extend(("--profile", "ops")) + command.extend(args) + return command + + +def create_backup( + *, + env_file: Path, + values: dict[str, str], + backup_root: Path, + quiesce_writers: bool, +) -> Path: + project_name = values["COMPOSE_PROJECT_NAME"] + run_id = datetime.now(timezone.utc).strftime("backup-%Y%m%dT%H%M%SZ") + partial_dir = backup_root / f".{run_id}.partial" + final_dir = backup_root / run_id + if partial_dir.exists() or final_dir.exists(): + raise RuntimeError("Backup run directory already exists.") + partial_dir.mkdir(parents=True, mode=0o700) + stopped_writers: tuple[str, ...] = () + try: + running = _run( + compose_command(env_file, project_name, "ps", "--status", "running", "--services"), + stdout=subprocess.PIPE, + ) + running_services = set(running.stdout.decode("utf-8", errors="replace").splitlines()) + if quiesce_writers: + stopped_writers = tuple(service for service in WRITER_SERVICES if service in running_services) + if stopped_writers: + _run(compose_command(env_file, project_name, "stop", "--timeout", "60", *stopped_writers)) + + databases = { + "contract": values["CONTRACT_POSTGRES_DB"], + "ai_platform": values["AI_PLATFORM_POSTGRES_DB"], + "operations": values["OPS_POSTGRES_DB"], + } + postgres_user = values["POSTGRES_USER"] + for label, database in databases.items(): + if not IDENTIFIER_PATTERN.fullmatch(database) or not IDENTIFIER_PATTERN.fullmatch(postgres_user): + raise RuntimeError("Database identifiers must use letters, numbers, and underscores only.") + target = partial_dir / f"postgres-{label}.dump" + command = compose_command( + env_file, + project_name, + "exec", + "-T", + "postgres", + "pg_dump", + "-U", + postgres_user, + "--format=custom", + "--compress=6", + "--no-owner", + "--no-acl", + database, + ) + _run_to_file(command, target) + + relative_partial = partial_dir.name + _run( + compose_command( + env_file, + project_name, + "run", + "--rm", + "--no-deps", + "contract-files-backup", + f"mkdir -p /backup/{relative_partial} && tar -C /source -czf /backup/{relative_partial}/contract-files.tar.gz .", + ops_profile=True, + ) + ) + _run( + compose_command( + env_file, + project_name, + "run", + "--rm", + "--no-deps", + "minio-backup", + ( + "mc alias set source http://minio:9000 \"$MINIO_ROOT_USER\" \"$MINIO_ROOT_PASSWORD\" >/dev/null " + f"&& mkdir -p /backup/{relative_partial}/minio " + f"&& mc mirror --overwrite source/\"$AI_PLATFORM_OBJECT_STORAGE_BUCKET\" /backup/{relative_partial}/minio" + ), + ops_profile=True, + ) + ) + + artifacts = _artifact_metadata(partial_dir) + manifest = { + "schema_version": "yuqei-production-backup.v1", + "backup_id": run_id, + "created_at": datetime.now(timezone.utc).isoformat(), + "release_id": values.get("RELEASE_ID"), + "compose_project_name": project_name, + "consistency_mode": "quiesced" if quiesce_writers else "online", + "database_names": databases, + "images": { + "contract_api": values.get("CONTRACT_API_IMAGE"), + "ai_platform_api": values.get("AI_PLATFORM_API_IMAGE"), + "ai_worker": values.get("AI_WORKER_IMAGE"), + }, + "artifacts": artifacts, + } + (partial_dir / "manifest.json").write_text( + json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + partial_dir.replace(final_dir) + return final_dir + except Exception: + (partial_dir / "FAILED").write_text(datetime.now(timezone.utc).isoformat() + "\n", encoding="utf-8") + raise + finally: + if stopped_writers: + _run( + compose_command(env_file, project_name, "up", "-d", "--no-build", *stopped_writers), + check=False, + ) + + +def verify_manifest(backup_dir: Path) -> dict[str, object]: + manifest_path = backup_dir / "manifest.json" + payload = json.loads(manifest_path.read_text(encoding="utf-8")) + artifacts = payload.get("artifacts") + if not isinstance(artifacts, list) or not artifacts: + raise RuntimeError("Backup manifest has no artifacts.") + for item in artifacts: + if not isinstance(item, dict): + raise RuntimeError("Backup manifest artifact is invalid.") + relative_path = str(item.get("path", "")) + target = (backup_dir / relative_path).resolve() + target.relative_to(backup_dir.resolve()) + if not target.is_file(): + raise RuntimeError(f"Backup artifact is missing: {relative_path}") + if target.stat().st_size != int(item.get("size_bytes", -1)): + raise RuntimeError(f"Backup artifact size mismatch: {relative_path}") + if _sha256(target) != item.get("sha256"): + raise RuntimeError(f"Backup artifact checksum mismatch: {relative_path}") + return payload + + +def prune_backups(backup_root: Path, *, keep: int) -> list[str]: + candidates = sorted( + [path for path in backup_root.iterdir() if path.is_dir() and BACKUP_RUN_PATTERN.fullmatch(path.name)], + key=lambda path: path.name, + reverse=True, + ) + removed: list[str] = [] + for path in candidates[max(keep, 1) :]: + path.resolve().relative_to(backup_root.resolve()) + shutil.rmtree(path) + removed.append(path.name) + return removed + + +def _artifact_metadata(root: Path) -> list[dict[str, object]]: + return [ + { + "path": path.relative_to(root).as_posix(), + "size_bytes": path.stat().st_size, + "sha256": _sha256(path), + } + for path in sorted(root.rglob("*")) + if path.is_file() and path.name != "FAILED" + ] + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _run_to_file(command: list[str], target: Path) -> None: + with target.open("wb") as stream: + _run(command, stdout=stream) + if target.stat().st_size == 0: + raise RuntimeError(f"Backup command produced an empty artifact: {target.name}") + + +def _run( + command: list[str], + *, + stdout: BinaryIO | int | None = subprocess.DEVNULL, + check: bool = True, +) -> subprocess.CompletedProcess[bytes]: + # Command tokens are fixed; database identifiers are validated before reaching this helper. + completed = subprocess.run( # nosec B603 + command, + stdin=subprocess.DEVNULL, + stdout=stdout, + stderr=subprocess.PIPE, + check=False, + ) + if check and completed.returncode != 0: + diagnostic = completed.stderr.decode("utf-8", errors="replace").strip() + raise RuntimeError(f"Backup command failed: {diagnostic[-1000:]}") + return completed + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--env-file", type=Path, required=True) + parser.add_argument("--apply", action="store_true", help="Execute the backup. Without this flag only a plan is printed.") + parser.add_argument("--quiesce-writers", action="store_true", help="Temporarily stop application writers for a cross-store maintenance-window backup.") + parser.add_argument("--allow-local-target", action="store_true", help="Allow a backup directory that is not a separate mount. Not suitable for production evidence.") + parser.add_argument("--prune-keep", type=int, default=0, help="After a successful backup, retain this many completed backup directories. Zero disables pruning.") + return parser.parse_args() + + +def main() -> int: + args = _parse_args() + env_file = args.env_file.resolve() + values = parse_env_file(env_file) + required = ("COMPOSE_PROJECT_NAME", "BACKUP_HOST_DIR", "POSTGRES_USER", "CONTRACT_POSTGRES_DB", "AI_PLATFORM_POSTGRES_DB", "OPS_POSTGRES_DB") + missing = [key for key in required if not values.get(key)] + if missing: + print(f"PRODUCTION_BACKUP_FAIL missing_keys={','.join(missing)}") + return 2 + backup_root = Path(values["BACKUP_HOST_DIR"]).resolve() + separate_mount_required = values.get("BACKUP_REQUIRE_SEPARATE_MOUNT", "true").lower() == "true" + plan = { + "status": "planned" if not args.apply else "running", + "env_file_name": env_file.name, + "compose_project_name": values["COMPOSE_PROJECT_NAME"], + "backup_root": str(backup_root), + "separate_mount_required": separate_mount_required, + "quiesce_writers": args.quiesce_writers, + "stores": ["contract_postgresql", "ai_platform_postgresql", "operations_postgresql", "contract_files", "minio"], + } + if not args.apply: + print(json.dumps(plan, ensure_ascii=False, indent=2)) + print("PRODUCTION_BACKUP_DRY_RUN use --apply to execute") + return 0 + if shutil.which("docker") is None: + print("PRODUCTION_BACKUP_FAIL docker_not_found") + return 2 + backup_root.mkdir(parents=True, exist_ok=True, mode=0o700) + if separate_mount_required and not args.allow_local_target and not os.path.ismount(backup_root): + print("PRODUCTION_BACKUP_FAIL backup_root_is_not_a_separate_mount") + return 2 + try: + backup_dir = create_backup( + env_file=env_file, + values=values, + backup_root=backup_root, + quiesce_writers=args.quiesce_writers, + ) + manifest = verify_manifest(backup_dir) + removed = prune_backups(backup_root, keep=args.prune_keep) if args.prune_keep > 0 else [] + except Exception as exc: + print(f"PRODUCTION_BACKUP_FAIL error={type(exc).__name__}: {exc}", file=sys.stderr) + return 1 + print( + "PRODUCTION_BACKUP_PASS " + f"backup_id={manifest['backup_id']} artifacts={len(manifest['artifacts'])} pruned={len(removed)}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/yuqei-infra/backup/verify-production-backup.py b/yuqei-infra/backup/verify-production-backup.py new file mode 100644 index 00000000..3e1af978 --- /dev/null +++ b/yuqei-infra/backup/verify-production-backup.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +"""Verify a production backup and optionally restore its databases into disposable check databases.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import shutil +# Restore checks use fixed Docker argv lists without a host shell. +import subprocess # nosec B404 +import sys +import tarfile +from datetime import datetime, timezone +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +BASE_COMPOSE = REPO_ROOT / "yuqei-infra" / "compose" / "docker-compose.yml" +PRODUCTION_COMPOSE = REPO_ROOT / "yuqei-infra" / "compose" / "docker-compose.production.yml" +DEFAULT_ARTIFACT_ROOT = REPO_ROOT / "project-docs" / "qa-artifacts" / "v2-production-restore-check" +SAFE_DATABASE_PATTERN = re.compile(r"^yuqei_restore_check_(contract|ai_platform|operations)_[0-9]{14}$") + + +def read_env(path: Path) -> dict[str, str]: + values: dict[str, str] = {} + for raw_line in path.read_text(encoding="utf-8-sig").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.removeprefix("export ").split("=", 1) + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + value = value[1:-1] + values[key.strip()] = value + return values + + +def validate_backup(backup_dir: Path) -> tuple[dict[str, object], list[dict[str, object]]]: + manifest = json.loads((backup_dir / "manifest.json").read_text(encoding="utf-8")) + if manifest.get("schema_version") != "yuqei-production-backup.v1": + raise RuntimeError("Unsupported backup manifest schema.") + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, list) or not artifacts: + raise RuntimeError("Backup manifest has no artifacts.") + verified: list[dict[str, object]] = [] + root = backup_dir.resolve() + for item in artifacts: + if not isinstance(item, dict): + raise RuntimeError("Invalid artifact entry.") + relative_path = str(item.get("path", "")) + path = (root / relative_path).resolve() + path.relative_to(root) + if not path.is_file(): + raise RuntimeError(f"Missing backup artifact: {relative_path}") + actual_size = path.stat().st_size + actual_sha256 = sha256(path) + if actual_size != int(item.get("size_bytes", -1)) or actual_sha256 != item.get("sha256"): + raise RuntimeError(f"Backup artifact integrity mismatch: {relative_path}") + verified.append({"path": relative_path, "size_bytes": actual_size, "sha256_match": True}) + + contract_archive = backup_dir / "contract-files.tar.gz" + if contract_archive.is_file(): + with tarfile.open(contract_archive, mode="r:gz") as archive: + for member in archive.getmembers(): + member_path = Path(member.name) + if member_path.is_absolute() or ".." in member_path.parts: + raise RuntimeError("Contract file archive contains an unsafe path.") + return manifest, verified + + +def restore_database_checks( + *, + backup_dir: Path, + env_file: Path, + values: dict[str, str], +) -> list[dict[str, str]]: + project_name = values["COMPOSE_PROJECT_NAME"] + postgres_user = values["POSTGRES_USER"] + suffix = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") + restored: list[dict[str, str]] = [] + targets: list[str] = [] + try: + for label in ("contract", "ai_platform", "operations"): + dump_path = backup_dir / f"postgres-{label}.dump" + target_database = f"yuqei_restore_check_{label}_{suffix}" + if not SAFE_DATABASE_PATTERN.fullmatch(target_database): + raise RuntimeError("Generated restore database name is unsafe.") + _run_psql( + env_file, + project_name, + postgres_user, + "postgres", + f'CREATE DATABASE "{target_database}"', + ) + targets.append(target_database) + restore_command = _compose( + env_file, + project_name, + "exec", + "-T", + "postgres", + "pg_restore", + "-U", + postgres_user, + "--exit-on-error", + "--no-owner", + "--no-acl", + "--dbname", + target_database, + ) + with dump_path.open("rb") as stream: + # The target database name is generated and allowlisted above. + completed = subprocess.run( # nosec B603 + restore_command, + stdin=stream, + capture_output=True, + check=False, + ) + if completed.returncode != 0: + raise RuntimeError(f"pg_restore failed for {label}: {_diagnostic(completed)}") + validation_sql = ( + "SELECT version_num FROM alembic_version LIMIT 1" + if label != "operations" + else "SELECT current_database()" + ) + version = _run_psql( + env_file, + project_name, + postgres_user, + target_database, + validation_sql, + capture=True, + ) + restored.append( + { + "label": label, + "database": target_database, + "validation": "alembic_version" if label != "operations" else "current_database", + "validation_value": version.strip(), + } + ) + return restored + finally: + for database in reversed(targets): + if SAFE_DATABASE_PATTERN.fullmatch(database): + _run_psql( + env_file, + project_name, + postgres_user, + "postgres", + f'DROP DATABASE IF EXISTS "{database}" WITH (FORCE)', + check=False, + ) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _compose(env_file: Path, project_name: str, *args: str) -> list[str]: + return [ + "docker", + "compose", + "--project-name", + project_name, + "--env-file", + str(env_file), + "-f", + str(BASE_COMPOSE), + "-f", + str(PRODUCTION_COMPOSE), + *args, + ] + + +def _run_psql( + env_file: Path, + project_name: str, + user: str, + database: str, + sql: str, + *, + capture: bool = False, + check: bool = True, +) -> str: + command = _compose( + env_file, + project_name, + "exec", + "-T", + "postgres", + "psql", + "-U", + user, + "-d", + database, + "-v", + "ON_ERROR_STOP=1", + "-tAc", + sql, + ) + # Command tokens are fixed and SQL identifiers are generated or validated. + completed = subprocess.run( # nosec B603 + command, + capture_output=True, + check=False, + ) + if check and completed.returncode != 0: + raise RuntimeError(f"psql failed: {_diagnostic(completed)}") + return completed.stdout.decode("utf-8", errors="replace") if capture else "" + + +def _diagnostic(completed: subprocess.CompletedProcess[bytes]) -> str: + return completed.stderr.decode("utf-8", errors="replace").strip()[-1000:] + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--backup-dir", type=Path, required=True) + parser.add_argument("--env-file", type=Path) + parser.add_argument("--restore-databases", action="store_true", help="Restore dumps into disposable check databases. Requires --apply.") + parser.add_argument("--apply", action="store_true", help="Allow creation and removal of disposable restore-check databases.") + parser.add_argument("--artifact-dir", type=Path) + return parser.parse_args() + + +def main() -> int: + args = _parse_args() + backup_dir = args.backup_dir.resolve() + try: + manifest, verified = validate_backup(backup_dir) + restored: list[dict[str, str]] = [] + if args.restore_databases: + if not args.apply or args.env_file is None: + raise RuntimeError("--restore-databases requires --apply and --env-file.") + if shutil.which("docker") is None: + raise RuntimeError("docker is not available.") + env_file = args.env_file.resolve() + restored = restore_database_checks( + backup_dir=backup_dir, + env_file=env_file, + values=read_env(env_file), + ) + except Exception as exc: + print(f"PRODUCTION_RESTORE_CHECK_FAIL error={type(exc).__name__}: {exc}", file=sys.stderr) + return 1 + + 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) + summary = { + "status": "pass", + "checked_at": checked_at.isoformat(), + "backup_id": manifest.get("backup_id"), + "integrity_artifacts_verified": len(verified), + "database_restore_checks": restored, + "database_restore_check_executed": bool(restored), + } + summary_path = artifact_dir / "production-restore-check-summary.json" + summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(f"PRODUCTION_RESTORE_CHECK_PASS artifacts={len(verified)} restored_databases={len(restored)} artifact={summary_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/yuqei-infra/backup/yuqei-production-backup.service b/yuqei-infra/backup/yuqei-production-backup.service new file mode 100644 index 00000000..d4a20ef0 --- /dev/null +++ b/yuqei-infra/backup/yuqei-production-backup.service @@ -0,0 +1,20 @@ +[Unit] +Description=Yuqei V2 production backup +After=docker.service network-online.target +Requires=docker.service + +[Service] +Type=oneshot +User=yuqei-ops +Group=yuqei-ops +WorkingDirectory=/opt/apps/ai-contract-platform-v2-production +ExecStart=/usr/bin/python3 yuqei-infra/backup/run-production-backup.py --env-file /etc/yuqei/production.env --apply --quiesce-writers --prune-keep 14 +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +Nice=10 +IOSchedulingClass=best-effort +IOSchedulingPriority=7 + +[Install] +WantedBy=multi-user.target diff --git a/yuqei-infra/backup/yuqei-production-backup.timer b/yuqei-infra/backup/yuqei-production-backup.timer new file mode 100644 index 00000000..f2c6cd0c --- /dev/null +++ b/yuqei-infra/backup/yuqei-production-backup.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Run Yuqei V2 production backup every night + +[Timer] +OnCalendar=*-*-* 02:30:00 Asia/Shanghai +RandomizedDelaySec=10min +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/yuqei-infra/compose/.env.example b/yuqei-infra/compose/.env.example index 08b985ad..44bf3244 100644 --- a/yuqei-infra/compose/.env.example +++ b/yuqei-infra/compose/.env.example @@ -34,6 +34,11 @@ 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_IDENTITY_EMAIL_DELIVERY_MODE=development +CONTRACT_API_IDENTITY_SELF_SERVICE_ENABLED=true +CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED=false +CONTRACT_API_IDENTITY_PUBLIC_WEB_BASE_URL=http://localhost:3000 +CONTRACT_API_IDENTITY_RATE_LIMIT_SECRET=local-only-identity-rate-secret-change-me CONTRACT_REVIEW_WORKER_SEED_DEMO_DATA=false CONTRACT_WEB_SESSION_SECRET=local-only-session-secret-change-me-32 CONTRACT_WEB_SESSION_SECURE=false diff --git a/yuqei-infra/compose/.env.production.example b/yuqei-infra/compose/.env.production.example index 71972199..20f947e5 100644 --- a/yuqei-infra/compose/.env.production.example +++ b/yuqei-infra/compose/.env.production.example @@ -1,9 +1,48 @@ # Copy to .env.production and replace every CHANGE_ME value before deployment. +COMPOSE_PROJECT_NAME=yuqei-v2-production +RELEASE_ID=CHANGE_ME_IMMUTABLE_RC_TAG +CONTRACT_API_IMAGE=registry.example.com/yuqei/contract-api@sha256:CHANGE_ME_64_HEX_DIGEST +AI_PLATFORM_API_IMAGE=registry.example.com/yuqei/ai-platform-api@sha256:CHANGE_ME_64_HEX_DIGEST +AI_WORKER_IMAGE=registry.example.com/yuqei/ai-worker@sha256:CHANGE_ME_64_HEX_DIGEST +POSTGRES_IMAGE=pgvector/pgvector@sha256:CHANGE_ME_64_HEX_DIGEST +POSTGRES_CLIENT_IMAGE=postgres@sha256:CHANGE_ME_64_HEX_DIGEST +REDIS_IMAGE=redis@sha256:CHANGE_ME_64_HEX_DIGEST +MINIO_IMAGE=minio/minio@sha256:CHANGE_ME_64_HEX_DIGEST +MINIO_MC_IMAGE=minio/mc@sha256:CHANGE_ME_64_HEX_DIGEST +NGINX_IMAGE=nginx@sha256:CHANGE_ME_64_HEX_DIGEST +BACKUP_UTILITY_IMAGE=alpine@sha256:CHANGE_ME_64_HEX_DIGEST + +BACKEND_SERVER_NAME=api.contracts.example.com +BACKEND_PUBLIC_BASE_URL=https://api.contracts.example.com +BACKEND_TLS_CERT_DIR=/etc/yuqei/tls +BACKEND_HTTP_BIND_ADDRESS=0.0.0.0 +BACKEND_HTTP_PORT=80 +BACKEND_HTTPS_BIND_ADDRESS=0.0.0.0 +BACKEND_HTTPS_PORT=443 + +POSTGRES_BIND_ADDRESS=127.0.0.1 +REDIS_BIND_ADDRESS=127.0.0.1 +MINIO_API_BIND_ADDRESS=127.0.0.1 +MINIO_CONSOLE_BIND_ADDRESS=127.0.0.1 +CONTRACT_API_BIND_ADDRESS=127.0.0.1 +AI_PLATFORM_API_BIND_ADDRESS=127.0.0.1 +CONTRACT_WEB_BIND_ADDRESS=127.0.0.1 + +POSTGRES_VOLUME_NAME=yuqei-v2-production-postgres +MINIO_VOLUME_NAME=yuqei-v2-production-minio +CONTRACT_FILES_VOLUME_NAME=yuqei-v2-production-contract-files +BACKUP_HOST_DIR=/mnt/yuqei-v2-backups +BACKUP_REQUIRE_SEPARATE_MOUNT=true +BACKUP_MAX_AGE_HOURS=30 + +# The file is mounted as /run/secrets/ai_provider_api_key. Configure the default +# database provider with api_key_secret_ref=file:///run/secrets/ai_provider_api_key. +AI_PROVIDER_API_KEY_FILE=/etc/yuqei/secrets/ai-provider-api-key + POSTGRES_USER=CHANGE_ME POSTGRES_PASSWORD=CHANGE_ME POSTGRES_DB=postgres -POSTGRES_IMAGE=pgvector/pgvector:pg16 PYPI_INDEX_URL=https://pypi.org/simple NPM_REGISTRY=https://registry.npmjs.org CONTRACT_POSTGRES_DB=yuqei_contract @@ -16,8 +55,6 @@ MINIO_CONSOLE_HOST_PORT=9001 CONTRACT_API_HOST_PORT=8100 AI_PLATFORM_API_HOST_PORT=8101 CONTRACT_WEB_HOST_PORT=3000 -MINIO_IMAGE=minio/minio:RELEASE.2023-06-23T20-26-00Z -MINIO_MC_IMAGE=minio/mc:latest MINIO_ROOT_USER=CHANGE_ME MINIO_ROOT_PASSWORD=CHANGE_ME AI_PLATFORM_OBJECT_STORAGE_BUCKET=ai-platform-uploads @@ -36,6 +73,31 @@ 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_IDENTITY_EMAIL_DELIVERY_MODE=disabled +CONTRACT_API_IDENTITY_SELF_SERVICE_ENABLED=false +CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED=true +# SMTP settings are retained for a future switch to CONTRACT_API_IDENTITY_EMAIL_DELIVERY_MODE=smtp. +CONTRACT_API_IDENTITY_SMTP_HOST=smtp.example.com +CONTRACT_API_IDENTITY_SMTP_PORT=587 +CONTRACT_API_IDENTITY_SMTP_USERNAME=mailer@example.com +CONTRACT_API_IDENTITY_SMTP_PASSWORD=CHANGE_ME_LOAD_FROM_PROTECTED_ENV +CONTRACT_API_IDENTITY_SMTP_FROM_EMAIL=mailer@example.com +CONTRACT_API_IDENTITY_SMTP_FROM_NAME=AI 合同平台 +CONTRACT_API_IDENTITY_SMTP_USE_STARTTLS=true +CONTRACT_API_IDENTITY_SMTP_USE_SSL=false +CONTRACT_API_IDENTITY_SMTP_TIMEOUT_SECONDS=10 +CONTRACT_API_IDENTITY_PUBLIC_WEB_BASE_URL=https://contracts.example.com +CONTRACT_API_IDENTITY_RATE_LIMIT_SECRET=CHANGE_ME_USE_A_RANDOM_32_BYTE_SECRET +CONTRACT_API_IDENTITY_CHALLENGE_RESEND_COOLDOWN_SECONDS=60 +CONTRACT_API_IDENTITY_CHALLENGE_RATE_WINDOW_SECONDS=3600 +CONTRACT_API_IDENTITY_CHALLENGE_EMAIL_LIMIT=5 +CONTRACT_API_IDENTITY_CHALLENGE_IP_LIMIT=20 +CONTRACT_API_IDENTITY_CHALLENGE_TENANT_LIMIT=100 +CONTRACT_API_IDENTITY_LOGIN_RATE_WINDOW_SECONDS=900 +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_REVIEW_WORKER_SEED_DEMO_DATA=false CONTRACT_WEB_SESSION_SECRET=CHANGE_ME_USE_A_DIFFERENT_RANDOM_32_BYTE_SECRET CONTRACT_WEB_SESSION_SECURE=true diff --git a/yuqei-infra/compose/docker-compose.production.yml b/yuqei-infra/compose/docker-compose.production.yml new file mode 100644 index 00000000..2f23ace6 --- /dev/null +++ b/yuqei-infra/compose/docker-compose.production.yml @@ -0,0 +1,133 @@ +services: + postgres: + image: ${POSTGRES_IMAGE:?POSTGRES_IMAGE must reference an immutable production image} + volumes: + - postgres-data:/var/lib/postgresql/data + + postgres-init-databases: + image: ${POSTGRES_CLIENT_IMAGE:?POSTGRES_CLIENT_IMAGE must reference an immutable production image} + + redis: + image: ${REDIS_IMAGE:?REDIS_IMAGE must reference an immutable production image} + command: ["redis-server", "--appendonly", "yes", "--maxmemory-policy", "noeviction"] + + minio: + image: ${MINIO_IMAGE:?MINIO_IMAGE must reference an immutable production image} + volumes: + - minio-data:/data + + contract-api-migrate: + image: ${CONTRACT_API_IMAGE:?CONTRACT_API_IMAGE must reference an immutable production image} + pull_policy: always + + contract-api: + image: ${CONTRACT_API_IMAGE:?CONTRACT_API_IMAGE must reference an immutable production image} + pull_policy: always + volumes: + - contract-files:/data/contract-files + + contract-review-worker: + image: ${CONTRACT_API_IMAGE:?CONTRACT_API_IMAGE must reference an immutable production image} + pull_policy: always + volumes: + - contract-files:/data/contract-files + + ai-platform-api-migrate: + image: ${AI_PLATFORM_API_IMAGE:?AI_PLATFORM_API_IMAGE must reference an immutable production image} + pull_policy: always + + ai-platform-prompt-assets-import: + image: ${AI_PLATFORM_API_IMAGE:?AI_PLATFORM_API_IMAGE must reference an immutable production image} + pull_policy: always + + ai-platform-api: + image: ${AI_PLATFORM_API_IMAGE:?AI_PLATFORM_API_IMAGE must reference an immutable production image} + pull_policy: always + environment: + AI_PLATFORM_SECRET_FILE_ROOT: /run/secrets + secrets: + - source: ai_provider_api_key + target: ai_provider_api_key + + worker: + image: ${AI_WORKER_IMAGE:?AI_WORKER_IMAGE must reference an immutable production image} + pull_policy: always + + contract-web: + profiles: ["backend-hosted-web"] + + edge: + image: ${NGINX_IMAGE:?NGINX_IMAGE must reference an immutable production image} + restart: unless-stopped + environment: + BACKEND_SERVER_NAME: ${BACKEND_SERVER_NAME:?BACKEND_SERVER_NAME is required} + NGINX_ENVSUBST_OUTPUT_DIR: /etc/nginx/conf.d + ports: + - "${BACKEND_HTTP_BIND_ADDRESS:-0.0.0.0}:${BACKEND_HTTP_PORT:-80}:80" + - "${BACKEND_HTTPS_BIND_ADDRESS:-0.0.0.0}:${BACKEND_HTTPS_PORT:-443}:443" + volumes: + - ../nginx/templates:/etc/nginx/templates:ro + - ${BACKEND_TLS_CERT_DIR:?BACKEND_TLS_CERT_DIR is required}:/etc/nginx/tls:ro + read_only: true + tmpfs: + - /var/cache/nginx + - /var/run + - /etc/nginx/conf.d + - /tmp + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD", "wget", "--no-check-certificate", "-qO-", "https://127.0.0.1/healthz"] + interval: 15s + timeout: 5s + retries: 6 + depends_on: + contract-api: + condition: service_healthy + ai-platform-api: + condition: service_healthy + + contract-files-backup: + image: ${BACKUP_UTILITY_IMAGE:?BACKUP_UTILITY_IMAGE must reference an immutable production image} + profiles: ["ops"] + entrypoint: ["/bin/sh", "-c"] + command: ["exit 0"] + volumes: + - contract-files:/source:ro + - ${BACKUP_HOST_DIR:?BACKUP_HOST_DIR is required}:/backup + read_only: true + security_opt: + - no-new-privileges:true + + minio-backup: + image: ${MINIO_MC_IMAGE:?MINIO_MC_IMAGE must reference an immutable production image} + profiles: ["ops"] + entrypoint: ["/bin/sh", "-c"] + command: ["exit 0"] + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER is required} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD is required} + AI_PLATFORM_OBJECT_STORAGE_BUCKET: ${AI_PLATFORM_OBJECT_STORAGE_BUCKET:-ai-platform-uploads} + MC_CONFIG_DIR: /tmp/.mc + volumes: + - ${BACKUP_HOST_DIR:?BACKUP_HOST_DIR is required}:/backup + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + depends_on: + minio: + condition: service_healthy + +volumes: + postgres-data: + name: ${POSTGRES_VOLUME_NAME:-yuqei-v2-production-postgres} + minio-data: + name: ${MINIO_VOLUME_NAME:-yuqei-v2-production-minio} + contract-files: + name: ${CONTRACT_FILES_VOLUME_NAME:-yuqei-v2-production-contract-files} + +secrets: + ai_provider_api_key: + file: ${AI_PROVIDER_API_KEY_FILE:?AI_PROVIDER_API_KEY_FILE must point to a protected provider key file} diff --git a/yuqei-infra/compose/docker-compose.yml b/yuqei-infra/compose/docker-compose.yml index 89fd7382..6c4d48b9 100644 --- a/yuqei-infra/compose/docker-compose.yml +++ b/yuqei-infra/compose/docker-compose.yml @@ -9,7 +9,7 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-yuqei-local} POSTGRES_DB: ${POSTGRES_DB:-postgres} ports: - - "${POSTGRES_HOST_PORT:-5432}:5432" + - "${POSTGRES_BIND_ADDRESS:-0.0.0.0}:${POSTGRES_HOST_PORT:-5432}:5432" volumes: - postgres-data:/var/lib/postgresql/data healthcheck: @@ -19,7 +19,7 @@ services: retries: 5 postgres-init-databases: - image: postgres:16-alpine + image: ${POSTGRES_CLIENT_IMAGE:-postgres:16-alpine} depends_on: postgres: condition: service_healthy @@ -41,10 +41,10 @@ services: restart: "no" redis: - image: redis:7-alpine + image: ${REDIS_IMAGE:-redis:7-alpine} restart: unless-stopped ports: - - "${REDIS_HOST_PORT:-6379}:6379" + - "${REDIS_BIND_ADDRESS:-0.0.0.0}:${REDIS_HOST_PORT:-6379}:6379" healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s @@ -59,8 +59,8 @@ services: MINIO_ROOT_USER: ${MINIO_ROOT_USER:-yuqei-minio} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-yuqei-minio-local} ports: - - "${MINIO_API_HOST_PORT:-9000}:9000" - - "${MINIO_CONSOLE_HOST_PORT:-9001}:9001" + - "${MINIO_API_BIND_ADDRESS:-0.0.0.0}:${MINIO_API_HOST_PORT:-9000}:9000" + - "${MINIO_CONSOLE_BIND_ADDRESS:-0.0.0.0}:${MINIO_CONSOLE_HOST_PORT:-9001}:9001" volumes: - minio-data:/data healthcheck: @@ -121,8 +121,33 @@ services: CONTRACT_API_DB_POOL_TIMEOUT_SECONDS: ${CONTRACT_API_DB_POOL_TIMEOUT_SECONDS:-30} CONTRACT_API_DB_POOL_RECYCLE_SECONDS: ${CONTRACT_API_DB_POOL_RECYCLE_SECONDS:-1800} CONTRACT_API_READ_CACHE_TTL_SECONDS: ${CONTRACT_API_READ_CACHE_TTL_SECONDS:-2} + CONTRACT_API_IDENTITY_EMAIL_DELIVERY_MODE: ${CONTRACT_API_IDENTITY_EMAIL_DELIVERY_MODE:-development} + CONTRACT_API_IDENTITY_SELF_SERVICE_ENABLED: ${CONTRACT_API_IDENTITY_SELF_SERVICE_ENABLED:-true} + CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED: ${CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED:-false} + CONTRACT_API_IDENTITY_SMTP_HOST: ${CONTRACT_API_IDENTITY_SMTP_HOST:-} + CONTRACT_API_IDENTITY_SMTP_PORT: ${CONTRACT_API_IDENTITY_SMTP_PORT:-587} + CONTRACT_API_IDENTITY_SMTP_USERNAME: ${CONTRACT_API_IDENTITY_SMTP_USERNAME:-} + CONTRACT_API_IDENTITY_SMTP_PASSWORD: ${CONTRACT_API_IDENTITY_SMTP_PASSWORD:-} + CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE: ${CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE:-} + CONTRACT_API_IDENTITY_SMTP_FROM_EMAIL: ${CONTRACT_API_IDENTITY_SMTP_FROM_EMAIL:-} + CONTRACT_API_IDENTITY_SMTP_FROM_NAME: ${CONTRACT_API_IDENTITY_SMTP_FROM_NAME:-AI 合同平台} + CONTRACT_API_IDENTITY_SMTP_USE_STARTTLS: ${CONTRACT_API_IDENTITY_SMTP_USE_STARTTLS:-true} + CONTRACT_API_IDENTITY_SMTP_USE_SSL: ${CONTRACT_API_IDENTITY_SMTP_USE_SSL:-false} + CONTRACT_API_IDENTITY_SMTP_TIMEOUT_SECONDS: ${CONTRACT_API_IDENTITY_SMTP_TIMEOUT_SECONDS:-10} + CONTRACT_API_IDENTITY_PUBLIC_WEB_BASE_URL: ${CONTRACT_API_IDENTITY_PUBLIC_WEB_BASE_URL:-http://localhost:3000} + CONTRACT_API_IDENTITY_RATE_LIMIT_SECRET: ${CONTRACT_API_IDENTITY_RATE_LIMIT_SECRET:-local-only-identity-rate-secret-change-me} + CONTRACT_API_IDENTITY_CHALLENGE_RESEND_COOLDOWN_SECONDS: ${CONTRACT_API_IDENTITY_CHALLENGE_RESEND_COOLDOWN_SECONDS:-60} + CONTRACT_API_IDENTITY_CHALLENGE_RATE_WINDOW_SECONDS: ${CONTRACT_API_IDENTITY_CHALLENGE_RATE_WINDOW_SECONDS:-3600} + CONTRACT_API_IDENTITY_CHALLENGE_EMAIL_LIMIT: ${CONTRACT_API_IDENTITY_CHALLENGE_EMAIL_LIMIT:-5} + CONTRACT_API_IDENTITY_CHALLENGE_IP_LIMIT: ${CONTRACT_API_IDENTITY_CHALLENGE_IP_LIMIT:-20} + CONTRACT_API_IDENTITY_CHALLENGE_TENANT_LIMIT: ${CONTRACT_API_IDENTITY_CHALLENGE_TENANT_LIMIT:-100} + CONTRACT_API_IDENTITY_LOGIN_RATE_WINDOW_SECONDS: ${CONTRACT_API_IDENTITY_LOGIN_RATE_WINDOW_SECONDS:-900} + CONTRACT_API_IDENTITY_LOGIN_EMAIL_LIMIT: ${CONTRACT_API_IDENTITY_LOGIN_EMAIL_LIMIT:-10} + CONTRACT_API_IDENTITY_LOGIN_IP_LIMIT: ${CONTRACT_API_IDENTITY_LOGIN_IP_LIMIT:-50} + CONTRACT_API_IDENTITY_ANTI_ENUMERATION_FLOOR_MS: ${CONTRACT_API_IDENTITY_ANTI_ENUMERATION_FLOOR_MS:-120} + CONTRACT_API_IDENTITY_ANTI_ENUMERATION_JITTER_MS: ${CONTRACT_API_IDENTITY_ANTI_ENUMERATION_JITTER_MS:-30} ports: - - "${CONTRACT_API_HOST_PORT:-8100}:8100" + - "${CONTRACT_API_BIND_ADDRESS:-0.0.0.0}:${CONTRACT_API_HOST_PORT:-8100}:8100" volumes: - contract-files:/data/contract-files healthcheck: @@ -250,7 +275,7 @@ services: AI_PLATFORM_OBJECT_STORAGE_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD:-yuqei-minio-local} AI_PLATFORM_OBJECT_STORAGE_REGION_NAME: us-east-1 ports: - - "${AI_PLATFORM_API_HOST_PORT:-8101}:8101" + - "${AI_PLATFORM_API_BIND_ADDRESS:-0.0.0.0}:${AI_PLATFORM_API_HOST_PORT:-8101}:8101" healthcheck: test: ["CMD", "python", "-c", "import json, urllib.request; data=json.load(urllib.request.urlopen('http://127.0.0.1:8101/health', timeout=5)); assert data['status'] == 'ok'"] interval: 10s @@ -331,7 +356,7 @@ services: AI_PLATFORM_PROXY_SECRET: ${AI_PLATFORM_SERVICE_SHARED_SECRET:-local-only-ai-service-secret-change-me-32} NEXT_PUBLIC_AI_PLATFORM_API_BASE_URL: http://localhost:8101 ports: - - "${CONTRACT_WEB_HOST_PORT:-3000}:3000" + - "${CONTRACT_WEB_BIND_ADDRESS:-0.0.0.0}:${CONTRACT_WEB_HOST_PORT:-3000}:3000" healthcheck: test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then((response) => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1));"] interval: 10s diff --git a/yuqei-infra/deploy/README.md b/yuqei-infra/deploy/README.md new file mode 100644 index 00000000..3cb5c2db --- /dev/null +++ b/yuqei-infra/deploy/README.md @@ -0,0 +1,74 @@ +# V2 production backend deployment + +The production backend runs on `192.168.3.90`; Contract Web is deployed separately to Alibaba Cloud. The production compose override therefore disables Contract Web by default and exposes only the HTTPS edge service publicly. + +## Files + +- `../compose/docker-compose.yml`: shared service definitions used by local and smoke environments. +- `../compose/docker-compose.production.yml`: backend-only production image, volume, TLS edge, and backup-helper overrides. +- `../compose/.env.production.example`: reviewed variable inventory. The real file belongs at `/etc/yuqei/production.env`, mode `0600`, outside the repository. +- `preflight-production.py`: read-only production configuration gate. + +## Prepare without changing traffic + +1. Create an immutable RC commit and publish the three application images by digest. Pin PostgreSQL/pgvector, Redis, MinIO, mc, Nginx, and the backup helper by digest as well. +2. Create a new production PostgreSQL/volume set. Do not reuse or rename the smoke database or volumes. +3. Mount an off-host backup filesystem at `BACKUP_HOST_DIR`. +4. Place `tls.crt`, `tls.key`, and the AI provider key in protected host directories. +5. Copy `.env.production.example` to `/etc/yuqei/production.env`, replace every placeholder, and keep mode `0600`. +6. Run the read-only gate: + +```bash +python3 yuqei-infra/deploy/preflight-production.py \ + --env-file /etc/yuqei/production.env \ + --artifact-dir /var/lib/yuqei/preflight +``` + +7. Validate and pull images without starting services: + +```bash +docker compose \ + --project-name yuqei-v2-production \ + --env-file /etc/yuqei/production.env \ + -f yuqei-infra/compose/docker-compose.yml \ + -f yuqei-infra/compose/docker-compose.production.yml \ + config --quiet + +docker compose \ + --project-name yuqei-v2-production \ + --env-file /etc/yuqei/production.env \ + -f yuqei-infra/compose/docker-compose.yml \ + -f yuqei-infra/compose/docker-compose.production.yml \ + pull +``` + +These commands do not start containers and do not switch traffic. + +## Provider secret and model config + +The provider key file is mounted at `/run/secrets/ai_provider_api_key`. The enabled default model config must use: + +```text +api_key_secret_ref=file:///run/secrets/ai_provider_api_key +``` + +Do not send the key in the model-config API payload. Run the Provider acceptance gate before any production start approval. + +## Alibaba Cloud frontend variables + +The frontend deployment must use server-only values: + +```text +CONTRACT_API_BASE_URL=https:///contract +AI_PLATFORM_API_BASE_URL=https:///ai +CONTRACT_API_PROXY_SECRET= +AI_PLATFORM_PROXY_SECRET= +CONTRACT_WEB_SESSION_SECRET= +CONTRACT_WEB_SESSION_SECURE=true +``` + +Do not expose any of these secrets through `NEXT_PUBLIC_*` variables. The browser should call only same-origin Contract Web API routes. + +## Traffic boundary + +Starting `postgres`, `redis`, `minio`, migrations, APIs, and workers is a backend deployment. Starting `edge` or changing Alibaba Cloud frontend variables/DNS is a separate traffic-change approval. This repository intentionally does not provide an automatic production cutover command. diff --git a/yuqei-infra/deploy/preflight-production.py b/yuqei-infra/deploy/preflight-production.py new file mode 100644 index 00000000..161cd08d --- /dev/null +++ b/yuqei-infra/deploy/preflight-production.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +"""Validate V2 production configuration without starting or changing services.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import stat +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import urlparse + + +REPO_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_ARTIFACT_ROOT = REPO_ROOT / "project-docs" / "qa-artifacts" / "v2-production-preflight" +IMMUTABLE_IMAGE_PATTERN = re.compile(r"^[^\s]+@sha256:[0-9a-fA-F]{64}$") +IDENTIFIER_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") +PLACEHOLDER_MARKERS = ("change_me", "local-only", "example.com", "your-api-key", "replace_with") +LOOPBACK_VALUES = {"127.0.0.1", "::1", "localhost"} + + +@dataclass(frozen=True) +class Check: + key: str + status: str + message: str + + +def parse_env_file(path: Path) -> dict[str, str]: + values: dict[str, str] = {} + for line_number, raw_line in enumerate(path.read_text(encoding="utf-8-sig").splitlines(), start=1): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line[7:].lstrip() + if "=" not in line: + raise ValueError(f"Invalid env syntax at line {line_number}.") + key, value = line.split("=", 1) + key = key.strip() + if not IDENTIFIER_PATTERN.fullmatch(key): + raise ValueError(f"Invalid env key at line {line_number}.") + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + value = value[1:-1] + values[key] = value + return values + + +def validate_environment(values: dict[str, str], *, template_mode: bool = False) -> list[Check]: + checks: list[Check] = [] + + required = ( + "COMPOSE_PROJECT_NAME", + "RELEASE_ID", + "CONTRACT_API_IMAGE", + "AI_PLATFORM_API_IMAGE", + "AI_WORKER_IMAGE", + "BACKEND_SERVER_NAME", + "BACKEND_PUBLIC_BASE_URL", + "BACKEND_TLS_CERT_DIR", + "BACKUP_HOST_DIR", + "AI_PROVIDER_API_KEY_FILE", + "POSTGRES_USER", + "POSTGRES_PASSWORD", + "CONTRACT_POSTGRES_DB", + "AI_PLATFORM_POSTGRES_DB", + "CONTRACT_API_DATABASE_URL", + "AI_PLATFORM_DATABASE_URL", + "YUQEI_WORKER_DATABASE_URL", + "MINIO_ROOT_USER", + "MINIO_ROOT_PASSWORD", + "CONTRACT_API_WEB_PROXY_SHARED_SECRET", + "CONTRACT_API_IDENTITY_RATE_LIMIT_SECRET", + "CONTRACT_WEB_SESSION_SECRET", + "AI_PLATFORM_SERVICE_SHARED_SECRET", + ) + missing = [key for key in required if not values.get(key, "").strip()] + checks.append(_result("required_values", not missing, "All required values are present." if not missing else f"Missing required keys: {', '.join(missing)}.")) + + exact_values = { + "CONTRACT_API_ENVIRONMENT": "production", + "CONTRACT_API_DATABASE_AUTO_CREATE": "false", + "CONTRACT_API_SEED_DEMO_DATA": "false", + "CONTRACT_REVIEW_WORKER_SEED_DEMO_DATA": "false", + "CONTRACT_API_REQUIRE_WEB_PROXY_AUTH": "true", + "CONTRACT_API_IDENTITY_EMAIL_DELIVERY_MODE": "disabled", + "CONTRACT_API_IDENTITY_SELF_SERVICE_ENABLED": "false", + "CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED": "true", + "CONTRACT_WEB_SESSION_SECURE": "true", + "AI_PLATFORM_ENVIRONMENT": "production", + "AI_PLATFORM_DATABASE_AUTO_CREATE": "false", + "AI_PLATFORM_REPOSITORY_BACKEND": "sqlalchemy", + "AI_PLATFORM_PROVIDER_CALL_MODE": "enabled", + "AI_PLATFORM_PROVIDER_FALLBACK_ENABLED": "false", + "AI_PLATFORM_REQUIRE_SERVICE_AUTH": "true", + "YUQEI_WORKER_ENVIRONMENT": "production", + "YUQEI_WORKER_DATABASE_AUTO_CREATE": "false", + } + invalid_exact = [ + f"{key}={values.get(key, '')}" + for key, expected in exact_values.items() + if values.get(key, "").strip().lower() != expected + ] + checks.append(_result("production_modes", not invalid_exact, "Production modes are strict." if not invalid_exact else f"Invalid production values: {', '.join(invalid_exact)}.")) + + placeholders = [ + key + for key, value in values.items() + if value and any(marker in value.lower() for marker in PLACEHOLDER_MARKERS) + and not _allowed_example_value(key, values) + ] + checks.append(_result("placeholders", template_mode or not placeholders, "No unresolved placeholders remain." if not placeholders else f"Unresolved placeholder keys: {', '.join(sorted(placeholders))}.", warning=template_mode and bool(placeholders))) + + image_keys = ( + "CONTRACT_API_IMAGE", + "AI_PLATFORM_API_IMAGE", + "AI_WORKER_IMAGE", + "POSTGRES_IMAGE", + "POSTGRES_CLIENT_IMAGE", + "REDIS_IMAGE", + "MINIO_IMAGE", + "MINIO_MC_IMAGE", + "NGINX_IMAGE", + "BACKUP_UTILITY_IMAGE", + ) + mutable_images = [key for key in image_keys if not IMMUTABLE_IMAGE_PATTERN.fullmatch(values.get(key, ""))] + checks.append(_result("immutable_images", template_mode or not mutable_images, "Application images are pinned by digest." if not mutable_images else f"Images must use @sha256 digests: {', '.join(mutable_images)}.", warning=template_mode and bool(mutable_images))) + + secret_keys = ( + "POSTGRES_PASSWORD", + "MINIO_ROOT_PASSWORD", + "CONTRACT_API_WEB_PROXY_SHARED_SECRET", + "CONTRACT_API_IDENTITY_RATE_LIMIT_SECRET", + "CONTRACT_WEB_SESSION_SECRET", + "AI_PLATFORM_SERVICE_SHARED_SECRET", + ) + weak_secrets = [key for key in secret_keys if len(values.get(key, "")) < 32] + secret_values = [values[key] for key in secret_keys if values.get(key)] + duplicate_secret_count = len(secret_values) - len(set(secret_values)) + secret_ok = not weak_secrets and duplicate_secret_count == 0 + runtime_message = "Protected runtime values are long and independent." + if not secret_ok: + runtime_message = f"Weak protected keys: {', '.join(weak_secrets) or '-'}; duplicate protected values: {duplicate_secret_count}." + checks.append(_result("runtime_secrets", template_mode or secret_ok, runtime_message, warning=template_mode and not secret_ok)) + + bind_keys = ( + "POSTGRES_BIND_ADDRESS", + "REDIS_BIND_ADDRESS", + "MINIO_API_BIND_ADDRESS", + "MINIO_CONSOLE_BIND_ADDRESS", + "CONTRACT_API_BIND_ADDRESS", + "AI_PLATFORM_API_BIND_ADDRESS", + ) + public_internal_binds = [key for key in bind_keys if values.get(key, "") not in LOOPBACK_VALUES] + checks.append(_result("internal_port_binding", not public_internal_binds, "Database, cache, storage, and application ports bind only to loopback." if not public_internal_binds else f"Internal ports are not loopback-bound: {', '.join(public_internal_binds)}.")) + + database_checks = _validate_database_urls(values) + checks.extend(database_checks) + + public_url = values.get("BACKEND_PUBLIC_BASE_URL", "") + parsed_public_url = urlparse(public_url) + endpoint_ok = parsed_public_url.scheme == "https" and parsed_public_url.hostname == values.get("BACKEND_SERVER_NAME") and not parsed_public_url.path.rstrip("/") + checks.append(_result("https_endpoint", endpoint_ok, "Backend public URL is an origin-only HTTPS endpoint matching BACKEND_SERVER_NAME." if endpoint_ok else "BACKEND_PUBLIC_BASE_URL must be an HTTPS origin matching BACKEND_SERVER_NAME.")) + + release_id = values.get("RELEASE_ID", "") + release_ok = bool(release_id) and not any(char.isspace() for char in release_id) and not _contains_placeholder(release_id) + checks.append(_result("release_id", template_mode or release_ok, "Release ID is immutable and explicit." if release_ok else "RELEASE_ID must be a non-placeholder tag or commit identifier.", warning=template_mode and not release_ok)) + + volume_keys = ("POSTGRES_VOLUME_NAME", "MINIO_VOLUME_NAME", "CONTRACT_FILES_VOLUME_NAME") + invalid_volumes = [key for key in volume_keys if "smoke" in values.get(key, "").lower() or not values.get(key)] + checks.append(_result("production_volumes", not invalid_volumes, "Stable production volume names are configured." if not invalid_volumes else f"Invalid production volume names: {', '.join(invalid_volumes)}.")) + + backup_value = values.get("BACKUP_HOST_DIR", "") + backup_root = Path(backup_value or ".") + is_absolute_host_path = backup_root.is_absolute() or backup_value.startswith("/") + under_application_root = backup_value == "/opt/apps" or backup_value.startswith("/opt/apps/") + backup_location_ok = is_absolute_host_path and "smoke" not in backup_value.lower() and not under_application_root + checks.append(_result("backup_location", backup_location_ok, "Backup destination is absolute and outside the application directory." if backup_location_ok else "BACKUP_HOST_DIR must be absolute, non-smoke, and outside /opt/apps.")) + + return checks + + +def validate_files(values: dict[str, str], *, template_mode: bool = False) -> list[Check]: + if template_mode: + return [Check("protected_files", "warning", "Template mode skipped TLS, provider key, and backup mount checks.")] + checks: list[Check] = [] + tls_dir = Path(values.get("BACKEND_TLS_CERT_DIR", "")) + tls_files = (tls_dir / "tls.crt", tls_dir / "tls.key") + checks.append(_result("tls_files", all(path.is_file() and path.stat().st_size > 0 for path in tls_files), "TLS certificate and private key are present." if all(path.is_file() and path.stat().st_size > 0 for path in tls_files) else "TLS files tls.crt and tls.key are required.")) + + provider_key = Path(values.get("AI_PROVIDER_API_KEY_FILE", "")) + provider_key_ok = provider_key.is_file() and 0 < provider_key.stat().st_size <= 64 * 1024 + checks.append(_result("provider_key_file", provider_key_ok, "Provider API key file is present." if provider_key_ok else "AI provider key file is missing, empty, or unexpectedly large.")) + if provider_key_ok and os.name != "nt": + mode = stat.S_IMODE(provider_key.stat().st_mode) + secure_mode = mode & 0o077 == 0 + checks.append(_result("provider_key_permissions", secure_mode, "Provider key file is owner-only." if secure_mode else f"Provider key file mode {oct(mode)} exposes group or other permissions.")) + + backup_root = Path(values.get("BACKUP_HOST_DIR", "")) + require_mount = values.get("BACKUP_REQUIRE_SEPARATE_MOUNT", "true").lower() == "true" + backup_mount_ok = backup_root.is_dir() and (not require_mount or os.path.ismount(backup_root)) + checks.append(_result("backup_mount", backup_mount_ok, "Backup destination is an available separate mount." if backup_mount_ok else "Backup destination is missing or is not a separate mount.")) + return checks + + +def _validate_database_urls(values: dict[str, str]) -> list[Check]: + pairs = ( + ("CONTRACT_API_DATABASE_URL", "CONTRACT_POSTGRES_DB"), + ("AI_PLATFORM_DATABASE_URL", "AI_PLATFORM_POSTGRES_DB"), + ("YUQEI_WORKER_DATABASE_URL", "AI_PLATFORM_POSTGRES_DB"), + ) + problems: list[str] = [] + for url_key, database_key in pairs: + raw_url = values.get(url_key, "") + parseable_url = raw_url.replace("postgresql+psycopg://", "postgresql://", 1) + parsed = urlparse(parseable_url) + database_name = parsed.path.lstrip("/") + expected_database = values.get(database_key, "") + if parsed.scheme != "postgresql" or parsed.hostname != "postgres" or database_name != expected_database: + problems.append(url_key) + if any(marker in database_name.lower() for marker in ("smoke", "test", "dev")): + problems.append(url_key) + database_names = [values.get(key, "") for key in ("CONTRACT_POSTGRES_DB", "AI_PLATFORM_POSTGRES_DB", "OPS_POSTGRES_DB")] + if any(not IDENTIFIER_PATTERN.fullmatch(name) for name in database_names if name): + problems.append("database_names") + if len([name for name in database_names if name]) != len(set(name for name in database_names if name)): + problems.append("database_names_not_unique") + return [_result("database_boundaries", not problems, "Database URLs target distinct production PostgreSQL databases on the internal service host." if not problems else f"Invalid database configuration keys: {', '.join(sorted(set(problems)))}.")] + + +def _allowed_example_value(key: str, values: dict[str, str]) -> bool: + email_disabled = values.get("CONTRACT_API_IDENTITY_EMAIL_DELIVERY_MODE") == "disabled" + if email_disabled and ( + key.startswith("CONTRACT_API_IDENTITY_SMTP_") + or key == "CONTRACT_API_IDENTITY_PUBLIC_WEB_BASE_URL" + ): + return True + return False + + +def _contains_placeholder(value: str) -> bool: + return any(marker in value.lower() for marker in PLACEHOLDER_MARKERS) + + +def _result(key: str, passed: bool, message: str, *, warning: bool = False) -> Check: + status = "warning" if warning else ("pass" if passed else "fail") + return Check(key=key, status=status, message=message) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--env-file", type=Path, default=REPO_ROOT / "yuqei-infra" / "compose" / ".env.production") + parser.add_argument("--artifact-dir", type=Path) + parser.add_argument("--template", action="store_true", help="Validate the committed example without requiring real secrets or files.") + return parser.parse_args() + + +def main() -> int: + args = _parse_args() + env_file = args.env_file.resolve() + if not env_file.is_file(): + print(f"PRODUCTION_PREFLIGHT_FAIL env_file_missing={env_file}") + return 2 + try: + values = parse_env_file(env_file) + except (OSError, UnicodeError, ValueError) as exc: + print(f"PRODUCTION_PREFLIGHT_FAIL env_file_invalid={type(exc).__name__}") + return 2 + + checks = validate_environment(values, template_mode=args.template) + checks.extend(validate_files(values, template_mode=args.template)) + required_failures = [check for check in checks if check.status == "fail"] + status_value = "pass" if not required_failures else "fail" + 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) + summary = { + "status": status_value, + "checked_at": checked_at.isoformat(), + "template_mode": args.template, + "env_file_name": env_file.name, + "checks": [asdict(check) for check in checks], + "failed_checks": [check.key for check in required_failures], + } + summary_path = artifact_dir / "production-preflight-summary.json" + summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(f"PRODUCTION_PREFLIGHT_{status_value.upper()} failed={len(required_failures)} artifact={summary_path}") + return 0 if not required_failures else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/yuqei-infra/nginx/templates/default.conf.template b/yuqei-infra/nginx/templates/default.conf.template new file mode 100644 index 00000000..e18bd67f --- /dev/null +++ b/yuqei-infra/nginx/templates/default.conf.template @@ -0,0 +1,78 @@ +limit_req_zone $binary_remote_addr zone=yuqei_api_per_ip:10m rate=20r/s; +limit_conn_zone $binary_remote_addr zone=yuqei_connections_per_ip:10m; + +upstream contract_api_upstream { + server contract-api:8100; + keepalive 32; +} + +upstream ai_platform_upstream { + server ai-platform-api:8101; + keepalive 16; +} + +server { + listen 80; + server_name ${BACKEND_SERVER_NAME}; + return 308 https://$host$request_uri; +} + +server { + listen 443 ssl; + http2 on; + server_name ${BACKEND_SERVER_NAME}; + + ssl_certificate /etc/nginx/tls/tls.crt; + ssl_certificate_key /etc/nginx/tls/tls.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + + server_tokens off; + client_max_body_size 100m; + limit_conn yuqei_connections_per_ip 30; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + location = /healthz { + access_log off; + default_type application/json; + return 200 '{"status":"ok","service":"yuqei-v2-edge"}'; + } + + location /contract/ { + limit_req zone=yuqei_api_per_ip burst=40 nodelay; + proxy_pass http://contract_api_upstream/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Connection ""; + proxy_connect_timeout 5s; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + } + + location /ai/ { + limit_req zone=yuqei_api_per_ip burst=20 nodelay; + proxy_pass http://ai_platform_upstream/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Connection ""; + proxy_connect_timeout 5s; + proxy_read_timeout 180s; + proxy_send_timeout 180s; + } + + location / { + return 404; + } +} diff --git a/yuqei-infra/observability/README.md b/yuqei-infra/observability/README.md new file mode 100644 index 00000000..f766c6fc --- /dev/null +++ b/yuqei-infra/observability/README.md @@ -0,0 +1,27 @@ +# Production minimum monitoring + +`check-production-health.py` is the first-release operational guard. It checks: + +- public HTTPS edge health; +- Contract API health and schema-check; +- AI Platform health, schema-check, and `provider_runtime`; +- worker runtime health; +- latest production backup age; +- root disk warning and critical thresholds. + +Example: + +```bash +python3 yuqei-infra/observability/check-production-health.py \ + --edge-base-url https://api.contracts.example.com \ + --contract-api-base http://127.0.0.1:8100 \ + --ai-api-base http://127.0.0.1:8101 \ + --ai-service-key-file /etc/yuqei/secrets/ai-platform-service-key \ + --backup-root /mnt/yuqei-v2-backups \ + --backup-max-age-hours 30 \ + --webhook-url-file /etc/yuqei/secrets/alert-webhook-url +``` + +Install the included systemd service and timer for one-minute checks. The webhook file is optional but, when configured, must contain an HTTPS URL and mode `0600`. + +This probe is a minimum launch guard, not a replacement for centralized logs, long-term metrics, PostgreSQL slow-query analysis, and infrastructure dashboards. diff --git a/yuqei-infra/observability/check-production-health.py b/yuqei-infra/observability/check-production-health.py new file mode 100644 index 00000000..09cc8382 --- /dev/null +++ b/yuqei-infra/observability/check-production-health.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""Run production health, schema, worker, backup freshness, and disk checks.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import sys +import time +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import urlparse +from urllib.request import Request, urlopen + + +REPO_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_ARTIFACT_ROOT = REPO_ROOT / "project-docs" / "qa-artifacts" / "v2-production-health" + + +@dataclass(frozen=True) +class HealthCheck: + key: str + status: str + message: str + elapsed_ms: int = 0 + + +def fetch_json(url: str, *, headers: dict[str, str] | None = None, timeout: float = 10.0) -> tuple[Any, int]: + _require_http_url(url, allow_http=True) + started = time.perf_counter() + request = Request(url, method="GET", headers={"Accept": "application/json", **(headers or {})}) + try: + with urlopen(request, timeout=timeout) as response: # nosec B310 + payload = json.loads(response.read().decode("utf-8")) + if response.status >= 400: + raise RuntimeError(f"HTTP {response.status}") + except (HTTPError, URLError, TimeoutError, ValueError, RuntimeError) as exc: + raise RuntimeError(f"{type(exc).__name__}: {exc}") from exc + return payload, int((time.perf_counter() - started) * 1000) + + +def check_json_endpoint( + key: str, + url: str, + *, + headers: dict[str, str] | None = None, + expected_status: str = "ok", + timeout: float = 10.0, +) -> tuple[HealthCheck, Any | None]: + try: + payload, elapsed_ms = fetch_json(url, headers=headers, timeout=timeout) + except RuntimeError as exc: + return HealthCheck(key, "fail", str(exc)), None + actual_status = payload.get("status") if isinstance(payload, dict) else None + if actual_status != expected_status: + return HealthCheck(key, "fail", f"Expected status={expected_status}, received status={actual_status}.", elapsed_ms), payload + return HealthCheck(key, "pass", f"Endpoint returned status={actual_status}.", elapsed_ms), payload + + +def check_schema(key: str, base_url: str, *, headers: dict[str, str] | None, timeout: float) -> tuple[HealthCheck, Any | None]: + return check_json_endpoint( + key, + f"{base_url.rstrip('/')}/api/v1/runtime/schema-check", + headers=headers, + expected_status="ok", + timeout=timeout, + ) + + +def check_provider_runtime(schema_payload: Any) -> HealthCheck: + if not isinstance(schema_payload, dict): + return HealthCheck("provider_runtime", "fail", "AI schema payload is unavailable.") + checks = schema_payload.get("checks", []) + provider = next((item for item in checks if isinstance(item, dict) and item.get("name") == "provider_runtime"), None) + if not isinstance(provider, dict): + return HealthCheck("provider_runtime", "fail", "provider_runtime schema check is missing.") + status = provider.get("status") + return HealthCheck( + "provider_runtime", + "pass" if status == "ok" else "fail", + "Provider runtime is ready." if status == "ok" else "Provider runtime is not production-ready.", + ) + + +def check_backup_freshness(backup_root: Path, *, max_age_hours: float) -> HealthCheck: + manifests = [path for path in backup_root.glob("backup-*/manifest.json") if path.is_file()] if backup_root.is_dir() else [] + if not manifests: + return HealthCheck("backup_freshness", "fail", "No completed production backup manifest was found.") + latest = max(manifests, key=lambda path: path.stat().st_mtime) + try: + payload = json.loads(latest.read_text(encoding="utf-8")) + created_at = datetime.fromisoformat(str(payload["created_at"]).replace("Z", "+00:00")) + if created_at.tzinfo is None: + created_at = created_at.replace(tzinfo=timezone.utc) + except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError): + return HealthCheck("backup_freshness", "fail", "Latest backup manifest is unreadable.") + age_hours = (datetime.now(timezone.utc) - created_at.astimezone(timezone.utc)).total_seconds() / 3600 + if age_hours > max_age_hours: + return HealthCheck("backup_freshness", "fail", f"Latest backup is stale: age_hours={age_hours:.2f}.") + return HealthCheck("backup_freshness", "pass", f"Latest backup age_hours={age_hours:.2f}.") + + +def check_disk(path: Path, *, warning_percent: float, critical_percent: float) -> HealthCheck: + try: + usage = shutil.disk_usage(path) + except OSError as exc: + return HealthCheck("disk_usage", "fail", f"Disk usage is unavailable: {type(exc).__name__}.") + used_percent = (usage.used / usage.total * 100) if usage.total else 100.0 + if used_percent >= critical_percent: + status = "fail" + elif used_percent >= warning_percent: + status = "warning" + else: + status = "pass" + return HealthCheck( + "disk_usage", + status, + f"used_percent={used_percent:.2f}, free_gib={usage.free / 1024 ** 3:.2f}.", + ) + + +def send_webhook(webhook_file: Path, summary: dict[str, Any], *, timeout: float) -> None: + url = webhook_file.read_text(encoding="utf-8").strip() + _require_http_url(url, allow_http=False) + body = json.dumps(summary, ensure_ascii=False).encode("utf-8") + request = Request(url, data=body, method="POST", headers={"Content-Type": "application/json"}) + with urlopen(request, timeout=timeout) as response: # nosec B310 + if response.status >= 400: + raise RuntimeError(f"Alert webhook returned HTTP {response.status}.") + + +def read_secret_file(path: Path | None) -> str | None: + if path is None: + return None + value = path.read_text(encoding="utf-8").strip() + return value or None + + +def _require_http_url(url: str, *, allow_http: bool) -> None: + parsed = urlparse(url) + allowed_schemes = {"https", "http"} if allow_http else {"https"} + if parsed.scheme not in allowed_schemes or not parsed.hostname or parsed.username or parsed.password: + raise RuntimeError("URL must use an allowed HTTP scheme and must not contain credentials.") + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--edge-base-url", required=True) + parser.add_argument("--contract-api-base", default="http://127.0.0.1:8100") + parser.add_argument("--ai-api-base", default="http://127.0.0.1:8101") + parser.add_argument("--ai-service-key-file", type=Path) + parser.add_argument("--tenant-id", default="default") + parser.add_argument("--backup-root", type=Path, required=True) + parser.add_argument("--backup-max-age-hours", type=float, default=30.0) + parser.add_argument("--disk-path", type=Path, default=Path("/")) + parser.add_argument("--disk-warning-percent", type=float, default=80.0) + parser.add_argument("--disk-critical-percent", type=float, default=90.0) + parser.add_argument("--timeout-seconds", type=float, default=10.0) + parser.add_argument("--webhook-url-file", type=Path) + parser.add_argument("--artifact-dir", type=Path) + return parser.parse_args() + + +def main() -> int: + args = _parse_args() + try: + service_key = read_secret_file(args.ai_service_key_file) + except OSError: + print("PRODUCTION_HEALTH_FAIL ai_service_key_file_unreadable") + return 2 + ai_headers = {"X-Tenant-Id": args.tenant_id} + if service_key: + ai_headers["X-AI-Platform-Service-Key"] = service_key + + checks: list[HealthCheck] = [] + edge_check, _ = check_json_endpoint( + "edge_health", + f"{args.edge_base_url.rstrip('/')}/healthz", + timeout=args.timeout_seconds, + ) + checks.append(edge_check) + contract_health, _ = check_json_endpoint( + "contract_api_health", + f"{args.contract_api_base.rstrip('/')}/api/v1/health", + timeout=args.timeout_seconds, + ) + checks.append(contract_health) + contract_schema, _ = check_schema("contract_schema", args.contract_api_base, headers=None, timeout=args.timeout_seconds) + checks.append(contract_schema) + ai_health, _ = check_json_endpoint( + "ai_api_health", + f"{args.ai_api_base.rstrip('/')}/health", + timeout=args.timeout_seconds, + ) + checks.append(ai_health) + ai_schema, ai_schema_payload = check_schema("ai_schema", args.ai_api_base, headers=None, timeout=args.timeout_seconds) + checks.extend((ai_schema, check_provider_runtime(ai_schema_payload))) + worker_health, _ = check_json_endpoint( + "worker_health", + f"{args.ai_api_base.rstrip('/')}/api/v1/runtime/worker-health", + headers=ai_headers, + timeout=args.timeout_seconds, + ) + checks.append(worker_health) + checks.append(check_backup_freshness(args.backup_root.resolve(), max_age_hours=args.backup_max_age_hours)) + checks.append( + check_disk( + args.disk_path.resolve(), + warning_percent=args.disk_warning_percent, + critical_percent=args.disk_critical_percent, + ) + ) + + failed = [check for check in checks if check.status == "fail"] + warnings = [check for check in checks if check.status == "warning"] + checked_at = datetime.now(timezone.utc).astimezone() + summary = { + "status": "fail" if failed else ("warning" if warnings else "pass"), + "checked_at": checked_at.isoformat(), + "failed_checks": [check.key for check in failed], + "warning_checks": [check.key for check in warnings], + "checks": [asdict(check) for check in checks], + } + 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) + summary_path = artifact_dir / "production-health-summary.json" + summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + if failed and args.webhook_url_file: + try: + send_webhook(args.webhook_url_file, summary, timeout=args.timeout_seconds) + except Exception as exc: + print(f"PRODUCTION_HEALTH_WEBHOOK_FAIL error={type(exc).__name__}", file=sys.stderr) + print( + f"PRODUCTION_HEALTH_{summary['status'].upper()} " + f"failed={len(failed)} warnings={len(warnings)} artifact={summary_path}" + ) + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/yuqei-infra/observability/yuqei-production-health.service b/yuqei-infra/observability/yuqei-production-health.service new file mode 100644 index 00000000..8f226b6c --- /dev/null +++ b/yuqei-infra/observability/yuqei-production-health.service @@ -0,0 +1,20 @@ +[Unit] +Description=Yuqei V2 production health gate +After=docker.service network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +User=yuqei-ops +Group=yuqei-ops +WorkingDirectory=/opt/apps/ai-contract-platform-v2-production +ExecStart=/usr/bin/python3 yuqei-infra/observability/check-production-health.py --edge-base-url ${BACKEND_PUBLIC_BASE_URL} --contract-api-base http://127.0.0.1:${CONTRACT_API_HOST_PORT} --ai-api-base http://127.0.0.1:${AI_PLATFORM_API_HOST_PORT} --ai-service-key-file /etc/yuqei/secrets/ai-platform-service-key --backup-root ${BACKUP_HOST_DIR} --backup-max-age-hours ${BACKUP_MAX_AGE_HOURS} --webhook-url-file /etc/yuqei/secrets/alert-webhook-url +EnvironmentFile=/etc/yuqei/production.env +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/opt/apps/ai-contract-platform-v2-production/project-docs/qa-artifacts + +[Install] +WantedBy=multi-user.target diff --git a/yuqei-infra/observability/yuqei-production-health.timer b/yuqei-infra/observability/yuqei-production-health.timer new file mode 100644 index 00000000..dd2d4464 --- /dev/null +++ b/yuqei-infra/observability/yuqei-production-health.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Run Yuqei V2 production health gate every minute + +[Timer] +OnBootSec=2min +OnUnitActiveSec=1min +AccuracySec=10s +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/yuqei-infra/scripts/deploy-v2-smoke-docker.ps1 b/yuqei-infra/scripts/deploy-v2-smoke-docker.ps1 index 77a02177..b3a0e4a2 100644 --- a/yuqei-infra/scripts/deploy-v2-smoke-docker.ps1 +++ b/yuqei-infra/scripts/deploy-v2-smoke-docker.ps1 @@ -47,6 +47,7 @@ $composeFile = "$RemoteProjectPath/yuqei-infra/compose/docker-compose.yml" $webProxySharedSecret = "$([guid]::NewGuid().ToString('N'))$([guid]::NewGuid().ToString('N'))" $webSessionSecret = "$([guid]::NewGuid().ToString('N'))$([guid]::NewGuid().ToString('N'))" $aiPlatformServiceSecret = "$([guid]::NewGuid().ToString('N'))$([guid]::NewGuid().ToString('N'))" +$identityRateLimitSecret = "$([guid]::NewGuid().ToString('N'))$([guid]::NewGuid().ToString('N'))" New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null @@ -270,7 +271,15 @@ mkdir -p $(ConvertTo-ShellSingleQuote $RemoteBackupRoot) ".venv", "qa-artifacts" ) - $excludedFiles = @("*.tgz", "*.zip", "*.log") + $excludedFiles = @( + "*.tgz", + "*.zip", + "*.log", + ".env", + ".env.local", + ".env.production", + ".env.smoke" + ) foreach ($dir in @("yuqei-ai-platform", "yuqei-ai-sdk-python", "yuqei-contract-platform", "yuqei-infra")) { $sourceDir = Join-Path $repoRoot $dir $targetDir = Join-Path $stagingRoot $dir @@ -357,6 +366,10 @@ CONTRACT_API_READ_CACHE_TTL_SECONDS=2 CONTRACT_API_REVIEW_WORKER_NAME=contract-review-worker-smoke-1 CONTRACT_API_REVIEW_WORKER_POLL_INTERVAL_SECONDS=5 CONTRACT_API_REVIEW_WORKER_BATCH_LIMIT=5 +CONTRACT_API_IDENTITY_EMAIL_DELIVERY_MODE=disabled +CONTRACT_API_IDENTITY_SELF_SERVICE_ENABLED=false +CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED=true +CONTRACT_API_IDENTITY_RATE_LIMIT_SECRET=$identityRateLimitSecret CONTRACT_REVIEW_WORKER_SEED_DEMO_DATA=false CONTRACT_WEB_SESSION_SECRET=$webSessionSecret CONTRACT_WEB_SESSION_SECURE=false diff --git a/yuqei-infra/scripts/run-ai-provider-acceptance.py b/yuqei-infra/scripts/run-ai-provider-acceptance.py new file mode 100644 index 00000000..3dd6de37 --- /dev/null +++ b/yuqei-infra/scripts/run-ai-provider-acceptance.py @@ -0,0 +1,447 @@ +#!/usr/bin/env python3 +"""Run real-provider legal QA and contract-review acceptance against V1 golden samples.""" + +from __future__ import annotations + +import argparse +import csv +import json +import time +from dataclasses import asdict, 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.request import Request, urlopen + + +REPO_ROOT = Path(__file__).resolve().parents[2] +PHASE13_ROOT = ( + REPO_ROOT + / "yuqei-ai-platform" + / "services" + / "ai-platform-api" + / "src" + / "yuqei_ai_platform_api" + / "golden_assets" + / "v1_phase13_legal_knowledge_template_library" +) +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" + + +@dataclass(frozen=True) +class CaseResult: + sample_id: str + suite: str + technical_status: str + legal_status: str + latency_ms: int + provider: str | None + model: str | None + audit_run_id: str | None + answer_non_empty: bool + citation_count: int + expected_source_ids: list[str] + matched_source_ids: list[str] + expected_boundary: str + expected_severity: str | None + observed_severity: str | None + production_ready: bool + asset_status: str + legal_reviewer: str + problems: list[str] + + +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.") + self.base_url = base_url.rstrip("/") + self.timeout_seconds = timeout_seconds + self.headers = { + "Accept": "application/json", + "Content-Type": "application/json", + "X-AI-Platform-Service-Key": service_key, + "X-Tenant-Id": tenant_id, + } + + def get(self, path: str) -> tuple[Any, int]: + return self._request("GET", path, None) + + def post(self, path: str, payload: dict[str, Any]) -> tuple[Any, int]: + return self._request("POST", path, payload) + + def _request(self, method: str, path: str, payload: dict[str, Any] | None) -> tuple[Any, int]: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None + request = Request(f"{self.base_url}{path}", method=method, headers=self.headers, data=body) + started = time.perf_counter() + try: + with urlopen(request, timeout=self.timeout_seconds) as response: # nosec B310 + raw = response.read().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 + return result, int((time.perf_counter() - started) * 1000) + + +def load_rows(path: Path) -> list[dict[str, str]]: + with path.open("r", encoding="utf-8-sig", newline="") as stream: + return [dict(row) for row in csv.DictReader(stream)] + + +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": + raise RuntimeError("AI Platform schema-check is not ok.") + provider_check = next( + ( + item + for item in schema.get("checks", []) + if isinstance(item, dict) and item.get("name") == "provider_runtime" + ), + None, + ) + if 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): + raise RuntimeError("Provider config list is invalid.") + enabled = [item for item in configs if isinstance(item, dict) and item.get("enabled")] + default = next((item for item in enabled if item.get("is_default")), enabled[0] if enabled else None) + if not isinstance(default, dict): + raise RuntimeError("No enabled provider config is available.") + if str(default.get("provider", "")).lower() in {"", "mock", "disabled"}: + raise RuntimeError("Default provider is still mock or disabled.") + if not default.get("base_url") or not default.get("api_key_set"): + raise RuntimeError("Default provider base URL or secret ref is missing.") + return schema, default + + +def run_qa_case(client: ApiClient, row: dict[str, str], seen_run_ids: set[str]) -> tuple[CaseResult, dict[str, Any]]: + started_at = datetime.now(timezone.utc) + response, latency_ms = client.post( + "/api/v1/legal/qa", + { + "question": row["question"], + "retrieval_strategy": "hybrid", + "knowledge_base_ids": [], + }, + ) + audit = find_latest_audit(client, operation="legal_qa", started_at=started_at, seen_run_ids=seen_run_ids) + return evaluate_case( + row=row, + suite="legal_qa", + response=response, + audit=audit, + latency_ms=latency_ms, + expected_boundary=row.get("expected_answer_boundary", ""), + expected_severity=None, + ), response + + +def run_review_case(client: ApiClient, row: dict[str, str], seen_run_ids: set[str]) -> tuple[CaseResult, dict[str, Any]]: + input_path = PHASE13_ROOT / row["input_sample_path"] + response, latency_ms = client.post( + "/api/v1/legal/contract-review", + { + "contract_title": row["sample_id"], + "contract_text": input_path.read_text(encoding="utf-8"), + "contract_type": row.get("contract_type") or None, + "review_focus": row.get("risk_categories") or None, + "retrieval_strategy": "hybrid", + "knowledge_base_ids": [], + }, + ) + run_id = response.get("audit_run_id") if isinstance(response, dict) else None + if not isinstance(run_id, str) or not run_id: + audit = find_latest_audit( + client, + operation="contract_review", + started_at=datetime.now(timezone.utc) - timedelta(seconds=max(5, latency_ms / 1000 + 2)), + seen_run_ids=seen_run_ids, + ) + else: + audit, _ = client.get(f"/api/v1/audit/ai-runs/{run_id}") + seen_run_ids.add(run_id) + expected_path = PHASE13_ROOT / row["expected_findings_path"] + return evaluate_case( + row=row, + suite="contract_review", + response=response, + audit=audit, + latency_ms=latency_ms, + expected_boundary=expected_path.read_text(encoding="utf-8")[:4000], + expected_severity=row.get("expected_severity") or None, + ), response + + +def find_latest_audit( + client: ApiClient, + *, + operation: str, + started_at: datetime, + seen_run_ids: set[str], +) -> dict[str, Any]: + threshold = started_at - timedelta(seconds=2) + for _ in range(10): + rows, _ = client.get("/api/v1/audit/ai-runs?limit=100&archive_status=active") + if isinstance(rows, list): + for item in rows: + if not isinstance(item, dict) or item.get("operation") != operation: + continue + run_id = item.get("id") + if not isinstance(run_id, str) or run_id in seen_run_ids: + continue + created_at = parse_datetime(item.get("created_at")) + if created_at is not None and created_at >= threshold: + seen_run_ids.add(run_id) + detail, _ = client.get(f"/api/v1/audit/ai-runs/{run_id}") + return detail + time.sleep(0.5) + raise RuntimeError(f"No new {operation} audit run was found.") + + +def evaluate_case( + *, + row: dict[str, str], + suite: str, + response: Any, + audit: Any, + latency_ms: int, + expected_boundary: str, + expected_severity: str | None, +) -> CaseResult: + problems: list[str] = [] + response_dict = response if isinstance(response, dict) else {} + audit_dict = audit if isinstance(audit, dict) else {} + answer = response_dict.get("answer") if suite == "legal_qa" else response_dict.get("summary") + answer_non_empty = isinstance(answer, str) and bool(answer.strip()) + if not answer_non_empty: + problems.append("empty_answer") + if audit_dict.get("status") != "succeeded" or audit_dict.get("provider_status") != "succeeded": + problems.append("provider_not_succeeded") + if audit_dict.get("provider_error"): + problems.append("provider_error") + quality_codes = { + item.get("code") + for item in audit_dict.get("quality_labels", []) + if isinstance(item, dict) + } + if {"fallback_used", "provider_failed"} & quality_codes: + problems.append("fallback_or_provider_failure_label") + + citations = response_dict.get("citations", []) + citation_rows = citations if isinstance(citations, list) else [] + citation_required = str(row.get("expected_citation_required", "true")).lower() == "true" or suite == "contract_review" + 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") + } + 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") + + observed_severity = None + if suite == "contract_review": + risk_items = response_dict.get("risk_items", []) + if not isinstance(risk_items, list) or not risk_items: + problems.append("missing_risk_items") + observed_severity = highest_severity( + [str(item.get("severity", "")) for item in risk_items if isinstance(item, dict)] + ) + if expected_severity and severity_rank(observed_severity) < severity_rank(expected_severity): + problems.append("severity_below_expected") + + production_ready = str(row.get("production_ready", "false")).lower() == "true" + asset_status = row.get("status", "").strip() + legal_reviewer = row.get("legal_reviewer", "").strip() + reviewer_is_named = bool(legal_reviewer) and legal_reviewer not in {"待法务复核", "pending", "-"} + legal_status = "approved" if production_ready and reviewer_is_named else "needs_legal_review" + return CaseResult( + sample_id=row.get("sample_id", "unknown"), + suite=suite, + technical_status="failed" if problems else "passed", + legal_status=legal_status, + latency_ms=latency_ms, + provider=audit_dict.get("provider"), + model=audit_dict.get("model"), + audit_run_id=audit_dict.get("id"), + answer_non_empty=answer_non_empty, + citation_count=len(citation_rows), + expected_source_ids=expected_source_ids, + matched_source_ids=matched_source_ids, + expected_boundary=expected_boundary, + expected_severity=expected_severity, + observed_severity=observed_severity, + production_ready=production_ready, + asset_status=asset_status, + legal_reviewer=legal_reviewer, + problems=problems, + ) + + +def split_ids(value: str) -> list[str]: + return [item.strip() for item in value.split(";") if item.strip()] + + +def severity_rank(value: str | None) -> int: + return {"low": 1, "medium": 2, "high": 3, "critical": 4}.get((value or "").lower(), 0) + + +def highest_severity(values: list[str]) -> str | None: + return max(values, key=severity_rank) if values else None + + +def parse_datetime(value: Any) -> datetime | None: + if not isinstance(value, str): + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return 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: + 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']}`", + "", + "| Sample | Suite | Technical | Legal | Latency ms | Citations | Problems |", + "| --- | --- | --- | --- | ---: | ---: | --- |", + ] + 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 '-'} |" + ) + lines.extend( + ( + "", + "Automated technical success does not approve legal correctness. Each expected boundary and output must be reviewed by a named legal reviewer before production_ready can become true.", + ) + ) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + 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("--artifact-dir", type=Path) + return parser.parse_args() + + +def main() -> int: + 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") + 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() + try: + schema, provider_config = validate_runtime(client) + qa_rows = load_rows(QA_INVENTORY) + review_rows = load_rows(REVIEW_INVENTORY) + if args.max_qa_cases > 0: + qa_rows = qa_rows[: args.max_qa_cases] + 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) + cases.append(result) + responses[result.sample_id] = response + for row in review_rows: + result, response = 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", + ) + print(f"AI_PROVIDER_ACCEPTANCE_FAIL error={type(exc).__name__} artifact={artifact_dir}") + return 1 + + technical_passed = sum(item.technical_status == "passed" for item in cases) + legal_approved = sum(item.legal_status == "approved" for item in cases) + if technical_passed != len(cases): + status = "failed" + elif legal_approved != len(cases): + 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}" + ) + return 1 if status == "failed" else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/yuqei-infra/scripts/verify-core-user-flows.cjs b/yuqei-infra/scripts/verify-core-user-flows.cjs index da36462b..07b03f68 100644 --- a/yuqei-infra/scripts/verify-core-user-flows.cjs +++ b/yuqei-infra/scripts/verify-core-user-flows.cjs @@ -5,11 +5,15 @@ const fs = require("fs"); const path = require("path"); const REPO_ROOT = path.resolve(__dirname, "..", ".."); +let apiRequestHeaders = {}; function parseArgs(argv) { const args = { baseUrl: "http://localhost:3000", contractApiBaseUrl: "http://127.0.0.1:8100", + contractProxyKeyEnv: "CONTRACT_API_WEB_PROXY_SHARED_SECRET", + tenantId: process.env.YUQEI_TEST_TENANT_ID || "default", + userId: process.env.YUQEI_TEST_USER_ID || "user-current-admin", email: process.env.YUQEI_TEST_EMAIL || "admin@example.com", password: process.env.YUQEI_TEST_PASSWORD || "", contractId: "", @@ -27,6 +31,15 @@ function parseArgs(argv) { } else if (arg === "--contract-api-base-url") { args.contractApiBaseUrl = requireValue(arg, next); index += 1; + } else if (arg === "--contract-proxy-key-env") { + args.contractProxyKeyEnv = requireValue(arg, next); + index += 1; + } else if (arg === "--tenant-id") { + args.tenantId = requireValue(arg, next); + index += 1; + } else if (arg === "--user-id") { + args.userId = requireValue(arg, next); + index += 1; } else if (arg === "--email") { args.email = requireValue(arg, next); index += 1; @@ -48,6 +61,9 @@ function parseArgs(argv) { console.log("Usage: node yuqei-infra/scripts/verify-core-user-flows.cjs [options]"); console.log(" --email Login email (or YUQEI_TEST_EMAIL)"); console.log(" --password Login password (or YUQEI_TEST_PASSWORD)"); + console.log(" --contract-proxy-key-env Env var containing the Contract API proxy key"); + console.log(" --tenant-id Tenant context for direct API checks"); + console.log(" --user-id User context for direct API checks"); console.log(" --contract-id Reuse an existing contract instead of creating one"); console.log(" --headed Show the browser window"); process.exit(0); @@ -76,8 +92,18 @@ function normalizeUrl(value) { return value.replace(/\/+$/, ""); } +function fetchApi(url, init = {}) { + return fetch(url, { + ...init, + headers: { + ...apiRequestHeaders, + ...(init.headers || {}), + }, + }); +} + async function fetchJson(url, init = {}) { - const response = await fetch(url, init); + const response = await fetchApi(url, init); const payload = await response.json().catch(() => null); if (!response.ok) { throw new Error(`${init.method || "GET"} ${url} failed: HTTP ${response.status} ${JSON.stringify(payload)}`); @@ -169,10 +195,23 @@ async function saveRecordDownload(page, args, contractId) { await page.waitForLoadState("networkidle", { timeout: args.timeoutMs }); const downloadButton = page.getByRole("button", { name: "\u4e0b\u8f7d" }).first(); await downloadButton.waitFor({ state: "visible", timeout: args.timeoutMs }); - const [download] = await Promise.all([ - page.waitForEvent("download", { timeout: args.timeoutMs }), - downloadButton.click(), - ]); + const responsePromise = page.waitForResponse( + (response) => response.url().includes(`/contracts/${contractId}/download`) && response.request().method() === "GET", + { timeout: args.timeoutMs }, + ); + const downloadPromise = page + .waitForEvent("download", { timeout: args.timeoutMs }) + .catch(() => null); + await downloadButton.click(); + const response = await responsePromise; + if (!response.ok()) { + const diagnostic = await response.text().catch(() => ""); + throw new Error(`Word download request failed: HTTP ${response.status()} ${diagnostic.slice(0, 1000)}`); + } + const download = await downloadPromise; + if (!download) { + throw new Error("Word download response succeeded but no browser download event was emitted."); + } const target = path.join(args.outputDir, `${safeFilename(contractId)}-${download.suggestedFilename()}`); await download.saveAs(target); const stat = fs.statSync(target); @@ -210,18 +249,21 @@ async function startAndRunReview(page, args, contractId) { } let workerRun = null; let completedTask = null; - for (let attempt = 0; attempt < 15; attempt += 1) { + const reviewDeadline = Date.now() + args.timeoutMs; + while (Date.now() < reviewDeadline) { const currentTasks = await fetchJson(`${args.contractApiBaseUrl}/api/v1/contracts/${encodeURIComponent(contractId)}/review-tasks`); completedTask = currentTasks.find((item) => item.id === task.id) || null; if (completedTask?.status === "succeeded" || completedTask?.status === "failed") { break; } - workerRun = await fetchJson(`${args.contractApiBaseUrl}/api/v1/review-worker/run-once`, jsonRequest({ - limit: 20, - worker_name: "rc-core-regression-worker", - review_focus: "Check parties, payment, breach, and dispute resolution.", - knowledge_base_ids: ["laws-cn"], - })); + if (completedTask?.status === "queued") { + workerRun = await fetchJson(`${args.contractApiBaseUrl}/api/v1/review-worker/run-once`, jsonRequest({ + limit: 20, + worker_name: "rc-core-regression-worker", + review_focus: "Check parties, payment, breach, and dispute resolution.", + knowledge_base_ids: ["laws-cn"], + })); + } await new Promise((resolve) => setTimeout(resolve, 250)); } if (completedTask?.status !== "succeeded") { @@ -237,11 +279,11 @@ async function startAndRunReview(page, args, contractId) { } const reviewQuery = new URLSearchParams({ review_task_id: task.id }).toString(); - const risksResponse = await fetch( + const risksResponse = await fetchApi( `${args.contractApiBaseUrl}/api/v1/contracts/${encodeURIComponent(contractId)}/review-risks?${reviewQuery}`, ); const risks = await risksResponse.json(); - const citationsResponse = await fetch( + const citationsResponse = await fetchApi( `${args.contractApiBaseUrl}/api/v1/contracts/${encodeURIComponent(contractId)}/review-citations?${reviewQuery}`, ); const citations = await citationsResponse.json(); @@ -524,6 +566,15 @@ async function main() { const args = parseArgs(process.argv.slice(2)); args.baseUrl = normalizeUrl(args.baseUrl); args.contractApiBaseUrl = normalizeUrl(args.contractApiBaseUrl); + const contractProxyKey = process.env[args.contractProxyKeyEnv]?.trim(); + if (!contractProxyKey) { + throw new Error(`Missing Contract API proxy key in ${args.contractProxyKeyEnv}.`); + } + apiRequestHeaders = { + "X-Contract-Proxy-Key": contractProxyKey, + "X-Tenant-Id": args.tenantId, + "X-User-Id": args.userId, + }; fs.mkdirSync(args.outputDir, { recursive: true }); const browser = await chromium.launch({ headless: !args.headed, channel: "chrome" }); diff --git a/yuqei-infra/tests/test_production_release_tools.py b/yuqei-infra/tests/test_production_release_tools.py new file mode 100644 index 00000000..c5bdce52 --- /dev/null +++ b/yuqei-infra/tests/test_production_release_tools.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import io +import json +import sys +import tarfile +from pathlib import Path +from types import ModuleType + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def load_script(name: str, relative_path: str) -> ModuleType: + path = REPO_ROOT / relative_path + spec = importlib.util.spec_from_file_location(name, path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +preflight = load_script("yuqei_production_preflight", "yuqei-infra/deploy/preflight-production.py") +backup_verify = load_script("yuqei_backup_verify", "yuqei-infra/backup/verify-production-backup.py") +provider_acceptance = load_script("yuqei_provider_acceptance", "yuqei-infra/scripts/run-ai-provider-acceptance.py") + + +def production_values() -> dict[str, str]: + values = preflight.parse_env_file(REPO_ROOT / "yuqei-infra/compose/.env.production.example") + replacements = { + "RELEASE_ID": "v2.0.0-rc.1", + "CONTRACT_API_IMAGE": f"registry.internal/contract-api@sha256:{'1' * 64}", + "AI_PLATFORM_API_IMAGE": f"registry.internal/ai-platform-api@sha256:{'2' * 64}", + "AI_WORKER_IMAGE": f"registry.internal/ai-worker@sha256:{'3' * 64}", + "POSTGRES_IMAGE": f"registry.internal/pgvector@sha256:{'4' * 64}", + "POSTGRES_CLIENT_IMAGE": f"registry.internal/postgres@sha256:{'5' * 64}", + "REDIS_IMAGE": f"registry.internal/redis@sha256:{'6' * 64}", + "MINIO_IMAGE": f"registry.internal/minio@sha256:{'7' * 64}", + "MINIO_MC_IMAGE": f"registry.internal/minio-mc@sha256:{'8' * 64}", + "NGINX_IMAGE": f"registry.internal/nginx@sha256:{'9' * 64}", + "BACKUP_UTILITY_IMAGE": f"registry.internal/alpine@sha256:{'a' * 64}", + "BACKEND_SERVER_NAME": "api.contracts.acme.cn", + "BACKEND_PUBLIC_BASE_URL": "https://api.contracts.acme.cn", + "POSTGRES_USER": "yuqei_prod", + "POSTGRES_PASSWORD": "postgres-secret-that-is-long-and-unique-001", + "MINIO_ROOT_USER": "yuqei_prod_minio", + "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_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", + "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", + } + values.update(replacements) + return values + + +def test_production_preflight_accepts_strict_configuration() -> None: + checks = preflight.validate_environment(production_values()) + + assert [check.key for check in checks if check.status == "fail"] == [] + + +def test_production_preflight_rejects_smoke_database_public_bind_and_fallback() -> None: + values = production_values() + values["CONTRACT_POSTGRES_DB"] = "yuqei_contract_smoke" + values["CONTRACT_API_DATABASE_URL"] = ( + "postgresql+psycopg://yuqei_prod:encoded-password@postgres:5432/yuqei_contract_smoke" + ) + values["POSTGRES_BIND_ADDRESS"] = "0.0.0.0" + values["AI_PLATFORM_PROVIDER_FALLBACK_ENABLED"] = "true" + + failed = {check.key for check in preflight.validate_environment(values) if check.status == "fail"} + + assert {"production_modes", "internal_port_binding", "database_boundaries"} <= failed + + +def test_backup_manifest_and_contract_archive_are_verified(tmp_path: Path) -> None: + backup_dir = tmp_path / "backup-20260716T120000Z" + backup_dir.mkdir() + archive_path = backup_dir / "contract-files.tar.gz" + with tarfile.open(archive_path, mode="w:gz") as archive: + content = b"contract" + info = tarfile.TarInfo("contract-1/document.txt") + info.size = len(content) + archive.addfile(info, io.BytesIO(content)) + dump_path = backup_dir / "postgres-contract.dump" + dump_path.write_bytes(b"postgres-dump") + artifacts = [] + for path in (archive_path, dump_path): + artifacts.append( + { + "path": path.name, + "size_bytes": path.stat().st_size, + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + } + ) + (backup_dir / "manifest.json").write_text( + json.dumps( + { + "schema_version": "yuqei-production-backup.v1", + "backup_id": backup_dir.name, + "created_at": "2026-07-16T12:00:00+00:00", + "artifacts": artifacts, + } + ), + encoding="utf-8", + ) + + manifest, verified = backup_verify.validate_backup(backup_dir) + + assert manifest["backup_id"] == backup_dir.name + assert len(verified) == 2 + + +def test_provider_acceptance_separates_technical_success_from_legal_approval() -> None: + result = provider_acceptance.evaluate_case( + row={ + "sample_id": "KBQA-1", + "expected_citation_required": "true", + "expected_source_ids": "LAW-CIVIL-001", + "production_ready": "false", + "status": "LEGAL_REVIEW", + "legal_reviewer": "待法务复核", + }, + suite="legal_qa", + response={ + "answer": "应结合合同和证据进一步判断。", + "citations": [{"document_id": "LAW-CIVIL-001"}], + }, + audit={ + "id": "run-1", + "status": "succeeded", + "provider_status": "succeeded", + "provider": "deepseek", + "model": "deepseek-chat", + "quality_labels": [], + }, + latency_ms=100, + expected_boundary="不得直接判断个案效力。", + expected_severity=None, + ) + + assert result.technical_status == "passed" + assert result.legal_status == "needs_legal_review" + assert result.matched_source_ids == ["LAW-CIVIL-001"] + + +def test_provider_acceptance_rejects_fallback_audit() -> None: + result = provider_acceptance.evaluate_case( + row={"sample_id": "KBQA-2", "expected_citation_required": "false"}, + suite="legal_qa", + response={"answer": "fallback answer", "citations": []}, + audit={ + "id": "run-2", + "status": "fallback", + "provider_status": "failed", + "provider_error": "provider unavailable", + "quality_labels": [{"code": "fallback_used"}], + }, + latency_ms=50, + expected_boundary="", + expected_severity=None, + ) + + assert result.technical_status == "failed" + assert "provider_not_succeeded" in result.problems + assert "fallback_or_provider_failure_label" in result.problems + + +def test_production_tool_bandit_suppressions_are_exactly_allowlisted() -> None: + expected = { + "yuqei-infra/backup/run-production-backup.py": ["B404", "B603"], + "yuqei-infra/backup/verify-production-backup.py": ["B404", "B603", "B603"], + "yuqei-infra/observability/check-production-health.py": ["B310", "B310"], + "yuqei-infra/scripts/run-ai-provider-acceptance.py": ["B310"], + } + observed: dict[str, list[str]] = {} + for relative_path in expected: + lines = (REPO_ROOT / relative_path).read_text(encoding="utf-8").splitlines() + observed[relative_path] = [ + line.split("# nosec", 1)[1].strip() + for line in lines + if "# nosec" in line + ] + + assert observed == expected