fix: restore authenticated server-rendered data
Security Quality Gate / npm audit, Bandit, pip-audit, detect-secrets (pull_request) Successful in 10m53s

This commit is contained in:
2026-07-13 13:47:49 +08:00
parent 5f89c11226
commit 6abca4eb75
7 changed files with 151 additions and 28 deletions
@@ -4,8 +4,8 @@
## 结论 ## 结论
- **内部 UAT / 受控单租户试点:有条件可进入。** 当前版本需先部署到 `192.168.3.90` smoke,并通过本文列出的安全、身份和真实 AI 抽样门槛。 - **内部 UAT / 受控单租户试点:可以进入。** 当前候选版本已部署到 `192.168.3.90` smoke,并通过远端身份边界、核心页面、资产计数、schema-check 和 Docker smoke 验证。
- **公开多企业 SaaS 生产:当前 No-Go。** 阻断项不是模板或 Prompt 数量,而是真实邮件交付、真实 AI Provider 评估、内容法务签批和本轮身份安全改动尚未完成远端验证。 - **公开多企业 SaaS 生产:当前 No-Go。** 阻断项不是模板或 Prompt 数量,而是真实邮件交付、真实 AI Provider 评估、内容法务签批和生产运行保障。
- 不建议继续横向增加模板数量。下一阶段应优先完成内容治理、真实模型质量评估和生产运行保障。 - 不建议继续横向增加模板数量。下一阶段应优先完成内容治理、真实模型质量评估和生产运行保障。
## 当前能力 ## 当前能力
@@ -22,7 +22,7 @@
资产精确核对证据: 资产精确核对证据:
- `project-docs/qa-artifacts/v2-asset-count/v2-asset-count-2026-07-13T02-53-18.128434+00-00.json` - `project-docs/qa-artifacts/v2-asset-count/v2-asset-count-2026-07-13T05-33-30.778054+00-00.json`
- 模板:`30 / 30 / 30` - 模板:`30 / 30 / 30`
- 标准条款:`8 / 8 / 8` - 标准条款:`8 / 8 / 8`
- 审查规则:`8 / 8 / 8` - 审查规则:`8 / 8 / 8`
@@ -30,7 +30,7 @@
## 本轮安全硬化 ## 本轮安全硬化
本轮本地代码已完成: 本轮代码和远端 smoke 已完成:
1. Web 会话从可篡改的 Base64 JSON 改为带有效期的 HMAC 签名 cookie,并启用 `HttpOnly`、`SameSite=Lax` 和生产 `Secure`。 1. Web 会话从可篡改的 Base64 JSON 改为带有效期的 HMAC 签名 cookie,并启用 `HttpOnly`、`SameSite=Lax` 和生产 `Secure`。
2. Contract API 不再在生产环境无条件信任 `X-Tenant-Id` / `X-User-Id`,由受控 Web 代理密钥建立信任边界。 2. Contract API 不再在生产环境无条件信任 `X-Tenant-Id` / `X-User-Id`,由受控 Web 代理密钥建立信任边界。
@@ -39,16 +39,29 @@
5. Contract API、审查 worker 和 Python SDK 支持向 AI Platform 传递服务密钥。 5. Contract API、审查 worker 和 Python SDK 支持向 AI Platform 传递服务密钥。
6. 生产响应不再返回邀请原始 token 和邮箱开发验证码。 6. 生产响应不再返回邀请原始 token 和邮箱开发验证码。
这些变更在本地测试和生产构建通过,但在部署到 smoke 前仍属于“本地已修、远端待验”。 这些变更已通过本地测试、生产构建和远端 smoke 验证。
## 远端验证证据
- 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。
- Web 登录:`admin@example.com` 可进入默认租户;合同台账显示 `12,129` 条,模板库和模板起草入口均显示 30 个模板,文件仓库、AI Trace、用户租户页均正常加载。
- SSR 认证回归:修复了认证硬化后 Server Component 裸请求 Contract API 导致合同和模板页面错误显示为 0 的问题;服务端缓存键已纳入租户、用户和角色,浏览器端继续只访问同源代理。
- Word 交付:抽样合同下载返回 `HT-20260713-70C052.docx`,MIME 为 Office Open XML,解码后 37,502 字节且 DOCX 文件头有效。
- 资产精确核对:模板 `30 / 30 / 30`、条款 `8 / 8 / 8`、规则 `8 / 8 / 8`、Prompt `46 / 46 / 46`,法律条文运行时 `789,867` 条。
- 法律问答工程链路可用,但 smoke fallback 抽样的引用相关性不足,不能作为生产 AI 质量证据;必须在真实 Provider、fallback 关闭后重新验收。
- PR 旧提交 `5f89c112` 的 CI 失败来自 Linux detect-secrets 对 SDK 测试固定假值的单个平台差异误报;已使用标准 inline allowlist 标记,安全规则本身未放宽,待当前提交触发 CI 重跑。
## 生产阻断项 ## 生产阻断项
### P0-1 远端发布候选验证 ### P0-1 远端发布候选验证(已完成)
- 部署当前代码到 `192.168.3.90` smoke。 - [x] 部署当前代码到 `192.168.3.90` smoke。
- 验证无代理密钥直连 Contract API / AI Platform 数据接口返回 `401`。 - [x] 验证无代理密钥直连 Contract API / AI Platform 数据接口返回 `401`。
- 验证 Web 登录后合同、模板、文件、AI Trace、知识库均正常。 - [x] 验证 Web 登录后合同、模板、文件、AI Trace、知识库均正常。
- 复跑两套 schema-check、资产精确核对、Docker smoke、核心接口压测和安全扫描。 - [x] 复跑两套 schema-check、资产精确核对和 Docker smoke。
- [ ] 当前提交 CI 全绿后,复跑带最新候选代码的接口压测与安全质量门,作为生产审批证据。
### P0-2 真实 AI Provider 与质量验收 ### P0-2 真实 AI Provider 与质量验收
@@ -77,7 +77,7 @@ def test_ai_platform_client_posts_legal_qa_with_context_headers() -> None:
client = AiPlatformClient( client = AiPlatformClient(
"http://ai-platform.test", "http://ai-platform.test",
transport=httpx.MockTransport(handler), transport=httpx.MockTransport(handler),
service_key="ai-platform-service-key", service_key="ai-platform-service-key", # pragma: allowlist secret
) )
response = client.run_legal_qa( response = client.run_legal_qa(
@@ -3,15 +3,17 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Building2, MailCheck, Plus, Save, Settings, ShieldCheck, UsersRound } from "lucide-react"; import { Building2, MailCheck, Plus, Save, Settings, ShieldCheck, UsersRound } from "lucide-react";
import { import {
getAccountSettings,
getIdentityRoles,
getIdentityTenants,
getIdentityUsers,
type AccountSettings, type AccountSettings,
type RolePermissionsPage, type RolePermissionsPage,
type TenantInfo, type TenantInfo,
type TenantUserItem, type TenantUserItem,
} from "@/services/contracts"; } from "@/services/contracts";
import {
getAccountSettings,
getIdentityRoles,
getIdentityTenants,
getIdentityUsers,
} from "@/services/contracts-client";
import { getRoleLabel, type UserRole } from "@/lib/access-control"; import { getRoleLabel, type UserRole } from "@/lib/access-control";
type TenantStatus = TenantInfo["status"]; type TenantStatus = TenantInfo["status"];
@@ -3,7 +3,8 @@
import Link from "next/link"; import Link from "next/link";
import { BookOpenCheck, ClipboardList, Loader2, MessageSquareText, RotateCcw, Send, ShieldCheck } from "lucide-react"; import { BookOpenCheck, ClipboardList, Loader2, MessageSquareText, RotateCcw, Send, ShieldCheck } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { runLegalQa, type LegalQaCitation, type LegalQaResponse } from "@/services/contracts"; import type { LegalQaCitation, LegalQaResponse } from "@/services/contracts";
import { runLegalQa } from "@/services/contracts-client";
type LegalQaClientProps = { type LegalQaClientProps = {
initialQuestion: string; initialQuestion: string;
@@ -0,0 +1,58 @@
import type {
AccountSettings,
LegalQaRequest,
LegalQaResponse,
RolePermissionsPage,
TenantPage,
TenantUserPage,
} from "@/services/contracts";
export async function runLegalQa(request: LegalQaRequest): Promise<LegalQaResponse> {
const response = await fetch("/api/contract-platform/legal/qa", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
question: request.question,
matter_context: request.matter_context || undefined,
knowledge_base_ids: request.knowledge_base_ids ?? [],
retrieval_strategy: request.retrieval_strategy ?? "hybrid",
}),
});
if (!response.ok) {
const payload = await response.json().catch(() => null) as { detail?: string; message?: string } | null;
throw new Error(payload?.detail || payload?.message || "Legal QA request failed.");
}
return await response.json() as LegalQaResponse;
}
export async function getIdentityTenants(): Promise<TenantPage> {
const payload = await fetchClientJson<TenantPage>("/api/contract-platform/identity/tenants");
return payload ?? { items: [], total: 0 };
}
export async function getIdentityUsers(tenantId?: string): Promise<TenantUserPage> {
const suffix = tenantId?.trim() ? `?tenant_id=${encodeURIComponent(tenantId.trim())}` : "";
const payload = await fetchClientJson<TenantUserPage>(`/api/contract-platform/identity/users${suffix}`);
return payload ?? { items: [], total: 0 };
}
export async function getIdentityRoles(): Promise<RolePermissionsPage> {
const payload = await fetchClientJson<RolePermissionsPage>("/api/contract-platform/identity/roles");
return payload ?? { roles: [] };
}
export async function getAccountSettings(): Promise<AccountSettings | null> {
return fetchClientJson<AccountSettings>("/api/contract-platform/identity/account");
}
async function fetchClientJson<T>(pathname: string): Promise<T | null> {
try {
const response = await fetch(pathname, { cache: "no-store" });
if (!response.ok) {
return null;
}
return await response.json() as T;
} catch {
return null;
}
}
@@ -1,5 +1,7 @@
import { contracts as fallbackContracts, tasks as fallbackTasks } from "@/lib/mock-data"; import { contracts as fallbackContracts, tasks as fallbackTasks } from "@/lib/mock-data";
import { resolveCurrentRole, type UserRole } from "@/lib/access-control"; import { resolveCurrentRole, type UserRole } from "@/lib/access-control";
import { fetchContractApi } from "@/lib/contract-api";
import { getContractWebSession } from "@/lib/contract-session";
export type ContractListItem = { export type ContractListItem = {
id: string; id: string;
@@ -1276,8 +1278,15 @@ async function fetchJson<T>(
const timeout = controller const timeout = controller
? setTimeout(() => controller.abort(), options.timeoutMs) ? setTimeout(() => controller.abort(), options.timeoutMs)
: null; : null;
const isServerRequest = typeof window === "undefined";
const session = isServerRequest ? await getContractWebSession() : null;
if (isServerRequest && !session) {
return null;
}
const url = resolveUrl(apiPathname, proxyPathname); const url = resolveUrl(apiPathname, proxyPathname);
const cacheKey = typeof window === "undefined" && options.cacheTtlMs ? url : null; const cacheKey = isServerRequest && options.cacheTtlMs
? `${session?.tenant_id}:${session?.user_id}:${session?.role}:${url}`
: null;
if (cacheKey) { if (cacheKey) {
const cached = serverFetchCache.get(cacheKey); const cached = serverFetchCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) { if (cached && cached.expiresAt > Date.now()) {
@@ -1289,7 +1298,9 @@ async function fetchJson<T>(
cache: "no-store", cache: "no-store",
signal: controller?.signal, signal: controller?.signal,
}; };
const response = await fetch(url, fetchInit); const response = isServerRequest
? await fetchContractApi(new URL(url), fetchInit)
: await fetch(url, fetchInit);
if (!response.ok) { if (!response.ok) {
return null; return null;
} }
+48 -10
View File
@@ -4,11 +4,12 @@ from __future__ import annotations
import argparse import argparse
import json import json
import os
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from urllib.error import URLError from urllib.error import URLError
from urllib.request import urlopen from urllib.request import Request, urlopen
REPO_ROOT = Path(__file__).resolve().parents[2] REPO_ROOT = Path(__file__).resolve().parents[2]
@@ -22,6 +23,10 @@ def main() -> int:
parser.add_argument("--v1-root", type=Path, default=DEFAULT_V1_ROOT) parser.add_argument("--v1-root", type=Path, default=DEFAULT_V1_ROOT)
parser.add_argument("--contract-api-base", default=DEFAULT_CONTRACT_API_BASE) parser.add_argument("--contract-api-base", default=DEFAULT_CONTRACT_API_BASE)
parser.add_argument("--ai-platform-api-base", default=DEFAULT_AI_PLATFORM_API_BASE) parser.add_argument("--ai-platform-api-base", default=DEFAULT_AI_PLATFORM_API_BASE)
parser.add_argument("--tenant-id", default="default")
parser.add_argument("--user-id", default="user-current-admin")
parser.add_argument("--contract-proxy-key-env", default="CONTRACT_API_WEB_PROXY_SHARED_SECRET")
parser.add_argument("--ai-service-key-env", default="AI_PLATFORM_SERVICE_SHARED_SECRET")
parser.add_argument( parser.add_argument(
"--artifact-dir", "--artifact-dir",
type=Path, type=Path,
@@ -35,14 +40,34 @@ def main() -> int:
stamp = checked_at.replace(":", "-").replace("+00:00", "Z") stamp = checked_at.replace(":", "-").replace("+00:00", "Z")
contract_api_base = args.contract_api_base.rstrip("/") contract_api_base = args.contract_api_base.rstrip("/")
ai_platform_api_base = args.ai_platform_api_base.rstrip("/") ai_platform_api_base = args.ai_platform_api_base.rstrip("/")
contract_headers = identity_headers(args.tenant_id, args.user_id)
ai_platform_headers = identity_headers(args.tenant_id, args.user_id)
contract_proxy_key = os.getenv(args.contract_proxy_key_env, "").strip()
ai_service_key = os.getenv(args.ai_service_key_env, "").strip()
if contract_proxy_key:
contract_headers["X-Contract-Proxy-Key"] = contract_proxy_key
if ai_service_key:
ai_platform_headers["X-AI-Platform-Service-Key"] = ai_service_key
counts, parity = build_asset_report(args.v1_root, contract_api_base, ai_platform_api_base) counts, parity = build_asset_report(
args.v1_root,
contract_api_base,
ai_platform_api_base,
contract_headers=contract_headers,
ai_platform_headers=ai_platform_headers,
)
result = { result = {
"status": derive_status(counts, parity), "status": derive_status(counts, parity),
"checked_at": checked_at, "checked_at": checked_at,
"v1_root": str(args.v1_root), "v1_root": str(args.v1_root),
"contract_api_base": contract_api_base, "contract_api_base": contract_api_base,
"ai_platform_api_base": ai_platform_api_base, "ai_platform_api_base": ai_platform_api_base,
"request_context": {
"tenant_id": args.tenant_id,
"user_id": args.user_id,
"contract_proxy_auth_configured": bool(contract_proxy_key),
"ai_service_auth_configured": bool(ai_service_key),
},
"counts": counts, "counts": counts,
"parity": parity, "parity": parity,
"notes": [ "notes": [
@@ -69,6 +94,9 @@ def build_asset_report(
v1_root: Path, v1_root: Path,
contract_api_base: str, contract_api_base: str,
ai_platform_api_base: str, ai_platform_api_base: str,
*,
contract_headers: dict[str, str] | None = None,
ai_platform_headers: dict[str, str] | None = None,
) -> tuple[dict[str, Any], dict[str, Any]]: ) -> tuple[dict[str, Any], dict[str, Any]]:
v1_phase13 = v1_root / "project-docs" / "phase13-legal-knowledge-template-library" v1_phase13 = v1_root / "project-docs" / "phase13-legal-knowledge-template-library"
v1_templates = v1_phase13 / "contract-templates" v1_templates = v1_phase13 / "contract-templates"
@@ -105,12 +133,12 @@ def build_asset_report(
v2_prompt_keys = prompt_keys(read_json_file(v2_prompt_assets_file)) v2_prompt_keys = prompt_keys(read_json_file(v2_prompt_assets_file))
runtime_payloads = { runtime_payloads = {
"templates": api_json(contract_api_base, "/api/v1/templates?page_size=100"), "templates": api_json(contract_api_base, "/api/v1/templates?page_size=100", contract_headers),
"clauses": api_json(contract_api_base, "/api/v1/standard-clauses?page_size=100"), "clauses": api_json(contract_api_base, "/api/v1/standard-clauses?page_size=100", contract_headers),
"rules": api_json(contract_api_base, "/api/v1/review-rules?page_size=100"), "rules": api_json(contract_api_base, "/api/v1/review-rules?page_size=100", contract_headers),
"legal_articles": api_json(contract_api_base, "/api/v1/legal-articles?page_size=1"), "legal_articles": api_json(contract_api_base, "/api/v1/legal-articles?page_size=1", contract_headers),
"contracts": api_json(contract_api_base, "/api/v1/contracts?page_size=1"), "contracts": api_json(contract_api_base, "/api/v1/contracts?page_size=1", contract_headers),
"prompts": api_json(ai_platform_api_base, "/api/v1/prompt-templates"), "prompts": api_json(ai_platform_api_base, "/api/v1/prompt-templates", ai_platform_headers),
} }
runtime_template_ids = item_ids(runtime_payloads["templates"]) runtime_template_ids = item_ids(runtime_payloads["templates"])
runtime_clause_ids = item_ids(runtime_payloads["clauses"]) runtime_clause_ids = item_ids(runtime_payloads["clauses"])
@@ -212,9 +240,19 @@ def read_json_file(path: Path) -> Any:
return {"__error__": f"Unable to read {path}: {exc}"} return {"__error__": f"Unable to read {path}: {exc}"}
def api_json(base_url: str, path: str) -> Any: def identity_headers(tenant_id: str, user_id: str) -> dict[str, str]:
headers: dict[str, str] = {}
if tenant_id.strip():
headers["X-Tenant-Id"] = tenant_id.strip()
if user_id.strip():
headers["X-User-Id"] = user_id.strip()
return headers
def api_json(base_url: str, path: str, headers: dict[str, str] | None = None) -> Any:
try: try:
with urlopen(f"{base_url}{path}", timeout=20) as response: request = Request(f"{base_url}{path}", headers=headers or {})
with urlopen(request, timeout=20) as response:
return json.loads(response.read().decode("utf-8")) return json.loads(response.read().decode("utf-8"))
except (OSError, URLError, TimeoutError, json.JSONDecodeError) as exc: except (OSError, URLError, TimeoutError, json.JSONDecodeError) as exc:
return {"__error__": f"{base_url}{path}: {exc}"} return {"__error__": f"{base_url}{path}: {exc}"}