fix: restore authenticated server-rendered data
Security Quality Gate / npm audit, Bandit, pip-audit, detect-secrets (pull_request) Successful in 10m53s
Security Quality Gate / npm audit, Bandit, pip-audit, detect-secrets (pull_request) Successful in 10m53s
This commit is contained in:
@@ -4,8 +4,8 @@
|
||||
|
||||
## 结论
|
||||
|
||||
- **内部 UAT / 受控单租户试点:有条件可进入。** 当前版本需先部署到 `192.168.3.90` smoke,并通过本文列出的安全、身份和真实 AI 抽样门槛。
|
||||
- **公开多企业 SaaS 生产:当前 No-Go。** 阻断项不是模板或 Prompt 数量,而是真实邮件交付、真实 AI Provider 评估、内容法务签批和本轮身份安全改动尚未完成远端验证。
|
||||
- **内部 UAT / 受控单租户试点:可以进入。** 当前候选版本已部署到 `192.168.3.90` smoke,并通过远端身份边界、核心页面、资产计数、schema-check 和 Docker smoke 验证。
|
||||
- **公开多企业 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`
|
||||
- 标准条款:`8 / 8 / 8`
|
||||
- 审查规则:`8 / 8 / 8`
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
## 本轮安全硬化
|
||||
|
||||
本轮本地代码已完成:
|
||||
本轮代码和远端 smoke 已完成:
|
||||
|
||||
1. Web 会话从可篡改的 Base64 JSON 改为带有效期的 HMAC 签名 cookie,并启用 `HttpOnly`、`SameSite=Lax` 和生产 `Secure`。
|
||||
2. Contract API 不再在生产环境无条件信任 `X-Tenant-Id` / `X-User-Id`,由受控 Web 代理密钥建立信任边界。
|
||||
@@ -39,16 +39,29 @@
|
||||
5. Contract API、审查 worker 和 Python SDK 支持向 AI Platform 传递服务密钥。
|
||||
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。
|
||||
- 验证无代理密钥直连 Contract API / AI Platform 数据接口返回 `401`。
|
||||
- 验证 Web 登录后合同、模板、文件、AI Trace、知识库均正常。
|
||||
- 复跑两套 schema-check、资产精确核对、Docker smoke、核心接口压测和安全扫描。
|
||||
- [x] 部署当前代码到 `192.168.3.90` smoke。
|
||||
- [x] 验证无代理密钥直连 Contract API / AI Platform 数据接口返回 `401`。
|
||||
- [x] 验证 Web 登录后合同、模板、文件、AI Trace、知识库均正常。
|
||||
- [x] 复跑两套 schema-check、资产精确核对和 Docker smoke。
|
||||
- [ ] 当前提交 CI 全绿后,复跑带最新候选代码的接口压测与安全质量门,作为生产审批证据。
|
||||
|
||||
### P0-2 真实 AI Provider 与质量验收
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ def test_ai_platform_client_posts_legal_qa_with_context_headers() -> None:
|
||||
client = AiPlatformClient(
|
||||
"http://ai-platform.test",
|
||||
transport=httpx.MockTransport(handler),
|
||||
service_key="ai-platform-service-key",
|
||||
service_key="ai-platform-service-key", # pragma: allowlist secret
|
||||
)
|
||||
|
||||
response = client.run_legal_qa(
|
||||
|
||||
@@ -3,15 +3,17 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Building2, MailCheck, Plus, Save, Settings, ShieldCheck, UsersRound } from "lucide-react";
|
||||
import {
|
||||
getAccountSettings,
|
||||
getIdentityRoles,
|
||||
getIdentityTenants,
|
||||
getIdentityUsers,
|
||||
type AccountSettings,
|
||||
type RolePermissionsPage,
|
||||
type TenantInfo,
|
||||
type TenantUserItem,
|
||||
} from "@/services/contracts";
|
||||
import {
|
||||
getAccountSettings,
|
||||
getIdentityRoles,
|
||||
getIdentityTenants,
|
||||
getIdentityUsers,
|
||||
} from "@/services/contracts-client";
|
||||
import { getRoleLabel, type UserRole } from "@/lib/access-control";
|
||||
|
||||
type TenantStatus = TenantInfo["status"];
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import Link from "next/link";
|
||||
import { BookOpenCheck, ClipboardList, Loader2, MessageSquareText, RotateCcw, Send, ShieldCheck } from "lucide-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 = {
|
||||
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 { resolveCurrentRole, type UserRole } from "@/lib/access-control";
|
||||
import { fetchContractApi } from "@/lib/contract-api";
|
||||
import { getContractWebSession } from "@/lib/contract-session";
|
||||
|
||||
export type ContractListItem = {
|
||||
id: string;
|
||||
@@ -1276,8 +1278,15 @@ async function fetchJson<T>(
|
||||
const timeout = controller
|
||||
? setTimeout(() => controller.abort(), options.timeoutMs)
|
||||
: null;
|
||||
const isServerRequest = typeof window === "undefined";
|
||||
const session = isServerRequest ? await getContractWebSession() : null;
|
||||
if (isServerRequest && !session) {
|
||||
return null;
|
||||
}
|
||||
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) {
|
||||
const cached = serverFetchCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
@@ -1289,7 +1298,9 @@ async function fetchJson<T>(
|
||||
cache: "no-store",
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.error import URLError
|
||||
from urllib.request import urlopen
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
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("--contract-api-base", default=DEFAULT_CONTRACT_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(
|
||||
"--artifact-dir",
|
||||
type=Path,
|
||||
@@ -35,14 +40,34 @@ def main() -> int:
|
||||
stamp = checked_at.replace(":", "-").replace("+00:00", "Z")
|
||||
contract_api_base = args.contract_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 = {
|
||||
"status": derive_status(counts, parity),
|
||||
"checked_at": checked_at,
|
||||
"v1_root": str(args.v1_root),
|
||||
"contract_api_base": contract_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,
|
||||
"parity": parity,
|
||||
"notes": [
|
||||
@@ -69,6 +94,9 @@ def build_asset_report(
|
||||
v1_root: Path,
|
||||
contract_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]]:
|
||||
v1_phase13 = v1_root / "project-docs" / "phase13-legal-knowledge-template-library"
|
||||
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))
|
||||
|
||||
runtime_payloads = {
|
||||
"templates": api_json(contract_api_base, "/api/v1/templates?page_size=100"),
|
||||
"clauses": api_json(contract_api_base, "/api/v1/standard-clauses?page_size=100"),
|
||||
"rules": api_json(contract_api_base, "/api/v1/review-rules?page_size=100"),
|
||||
"legal_articles": api_json(contract_api_base, "/api/v1/legal-articles?page_size=1"),
|
||||
"contracts": api_json(contract_api_base, "/api/v1/contracts?page_size=1"),
|
||||
"prompts": api_json(ai_platform_api_base, "/api/v1/prompt-templates"),
|
||||
"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", contract_headers),
|
||||
"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", contract_headers),
|
||||
"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", ai_platform_headers),
|
||||
}
|
||||
runtime_template_ids = item_ids(runtime_payloads["templates"])
|
||||
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}"}
|
||||
|
||||
|
||||
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:
|
||||
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"))
|
||||
except (OSError, URLError, TimeoutError, json.JSONDecodeError) as exc:
|
||||
return {"__error__": f"{base_url}{path}: {exc}"}
|
||||
|
||||
Reference in New Issue
Block a user