diff --git a/.github/workflows/build-test-quality-gate.yml b/.github/workflows/build-test-quality-gate.yml index c75bc31a..251d275c 100644 --- a/.github/workflows/build-test-quality-gate.yml +++ b/.github/workflows/build-test-quality-gate.yml @@ -103,4 +103,4 @@ jobs: python -m alembic upgrade head python -m alembic downgrade 20260710_0030 python -m alembic upgrade head - python -m alembic current | grep -F "20260809_0045 (head)" + python -m alembic current | grep -F "20260812_0046 (head)" diff --git a/yuqei-contract-platform/apps/contract-web/package.json b/yuqei-contract-platform/apps/contract-web/package.json index d8f9f6f8..7880665b 100644 --- a/yuqei-contract-platform/apps/contract-web/package.json +++ b/yuqei-contract-platform/apps/contract-web/package.json @@ -8,6 +8,9 @@ "start": "next start -p 3000", "test:auth-entry-routing": "node --no-warnings --experimental-strip-types --test src/lib/auth-entry-routing.test.mjs", "test:identity-session": "node --no-warnings --experimental-strip-types --test src/lib/identity-session-mapping.test.mjs", + "test:agreement-consent-routing": "node --no-warnings --experimental-strip-types --test src/lib/agreement-consent-routing.test.mjs", + "test:required-agreements": "node --no-warnings --experimental-strip-types --test src/lib/required-agreements.test.mjs", + "test:public-policies": "node --no-warnings --experimental-strip-types --test src/lib/public-policies.test.mjs", "test:workspace-access": "node --no-warnings --experimental-strip-types --test src/lib/workspace-access-state.test.mjs", "test:api-base-url": "node --no-warnings --experimental-strip-types --test src/lib/api-base-url.test.mjs", "typecheck": "tsc --noEmit" diff --git a/yuqei-contract-platform/apps/contract-web/src/app/agreements/review/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/agreements/review/page.tsx new file mode 100644 index 00000000..5117a4a9 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/app/agreements/review/page.tsx @@ -0,0 +1,186 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { FileCheck2, LogOut } from "lucide-react"; +import type { RequiredAgreement } from "@/lib/contract-session"; +import { + buildAgreementConsentSubmission, + parseRequiredAgreementStatus, + type RequiredAgreementStatus, +} from "@/lib/required-agreements"; + +export default function AgreementReviewPage() { + const router = useRouter(); + const [status, setStatus] = useState(); + const [accepted, setAccepted] = useState>(() => new Set()); + const [message, setMessage] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + void loadRequiredAgreements(); + }, []); + + async function loadRequiredAgreements() { + setStatus(undefined); + setMessage(null); + try { + const response = await fetch("/api/auth/agreements/required", { cache: "no-store" }); + const payload: unknown = await response.json().catch(() => null); + const nextStatus = parseRequiredAgreementStatus(payload); + if (!response.ok || !nextStatus) { + throw new Error(readError(payload, "协议确认状态加载失败。")); + } + if (!nextStatus.required) { + router.replace("/workspace"); + router.refresh(); + return; + } + setAccepted(new Set()); + setStatus(nextStatus); + } catch (error) { + setStatus(null); + setMessage(error instanceof Error ? error.message : "协议确认状态加载失败。"); + } + } + + function toggleAgreement(agreement: RequiredAgreement, checked: boolean) { + setAccepted((current) => { + const next = new Set(current); + const key = agreementKey(agreement); + if (checked) { + next.add(key); + } else { + next.delete(key); + } + return next; + }); + setMessage(null); + } + + const allAccepted = useMemo( + () => Boolean(status?.items.length) && status?.items.every((item) => accepted.has(agreementKey(item))), + [accepted, status], + ); + + async function logout() { + await fetch("/api/auth/logout", { method: "POST" }).catch(() => null); + router.replace("/login"); + router.refresh(); + } + + async function submit(event: React.FormEvent) { + event.preventDefault(); + if (!status || !allAccepted) { + setMessage("请逐项阅读并同意全部必需协议。"); + return; + } + setSaving(true); + setMessage(null); + try { + const response = await fetch("/api/auth/agreements/consents", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(buildAgreementConsentSubmission(status.items)), + }); + const payload: unknown = await response.json().catch(() => null); + const nextStatus = parseRequiredAgreementStatus(payload); + if (!response.ok || !nextStatus || nextStatus.required) { + throw new Error(readError(payload, "协议确认未完成,请重试。")); + } + router.replace("/workspace"); + router.refresh(); + } catch (error) { + setMessage(error instanceof Error ? error.message : "协议确认未完成,请重试。"); + } finally { + setSaving(false); + } + } + + if (status === undefined) { + return
正在核对必需协议...
; + } + + return ( +
+
+
+

Agreement update

+

确认更新后的服务协议

+

继续使用工作空间前,请逐份阅读并确认当前有效版本。

+
+ +
+ + {message &&
{message}
} + + {status === null ? ( +
+ +
+ ) : ( +
+
+
+

Required agreements

+

待确认协议({status.total})

+
+ +
+
+ {status.items.map((agreement) => ( + + ))} + +
+
+ )} +
+ ); +} + +function agreementKey(agreement: RequiredAgreement) { + return `${agreement.agreement_type}\u0000${agreement.version}`; +} + +function formatEffectiveAt(value: string) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" }); +} + +function readError(value: unknown, fallback: string) { + if (!value || typeof value !== "object") { + return fallback; + } + const payload = value as { detail?: unknown; message?: unknown }; + if (typeof payload.detail === "string") { + return payload.detail; + } + return typeof payload.message === "string" ? payload.message : fallback; +} diff --git a/yuqei-contract-platform/apps/contract-web/src/app/ai-disclaimer/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/ai-disclaimer/page.tsx index fcd355b6..c96556b7 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/ai-disclaimer/page.tsx +++ b/yuqei-contract-platform/apps/contract-web/src/app/ai-disclaimer/page.tsx @@ -1,9 +1,15 @@ import { LegalPolicyPage, PolicyParagraph } from "@/components/legal-policy-page"; +import { getCurrentPublicPolicy } from "@/lib/public-policies"; -export default function AiDisclaimerPage() { +export default async function AiDisclaimerPage() { + const lookup = await getCurrentPublicPolicy("ai_use_notice"); + const policy = lookup.status === "effective" ? lookup.document : null; return ( null); + const status = parseRequiredAgreementStatus(payload); + if (response.ok && status && !status.required) { + await setContractWebSession({ + ...session, + agreement_consent_required: false, + required_agreements: [], + }); + } + 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/agreements/required/route.ts b/yuqei-contract-platform/apps/contract-web/src/app/api/auth/agreements/required/route.ts new file mode 100644 index 00000000..d0ed4500 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/app/api/auth/agreements/required/route.ts @@ -0,0 +1,30 @@ +import { buildContractApiUrl, fetchContractApi } from "@/lib/contract-api"; +import { getContractWebSession, setContractWebSession } from "@/lib/contract-session"; +import { parseRequiredAgreementStatus } from "@/lib/required-agreements"; + +export async function GET() { + const session = await getContractWebSession(); + if (!session) { + return Response.json({ message: "请先登录。" }, { status: 401 }); + } + try { + const response = await fetchContractApi(buildContractApiUrl("/api/v1/identity/agreements/required"), { + cache: "no-store", + }); + const payload: unknown = await response.json().catch(() => null); + const status = parseRequiredAgreementStatus(payload); + if (response.ok && status) { + await setContractWebSession({ + ...session, + agreement_consent_required: status.required, + required_agreements: status.items, + }); + } + 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/globals.css b/yuqei-contract-platform/apps/contract-web/src/app/globals.css index 8c00ed8d..0d0920e2 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/globals.css +++ b/yuqei-contract-platform/apps/contract-web/src/app/globals.css @@ -697,6 +697,36 @@ textarea { line-height: 1.6; } +.policy-document-content { + overflow-wrap: anywhere; + color: var(--ink-soft); + line-height: 1.8; + white-space: pre-wrap; +} + +.agreement-review-page { + width: min(760px, 100%); + margin: 0 auto; +} + +.agreement-review-panel, +.agreement-review-form { + display: grid; + gap: 16px; +} + +.agreement-review-item { + align-items: flex-start; + padding: 16px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-elevated); +} + +.agreement-review-item input { + margin-top: 3px; +} + .button { display: inline-flex; align-items: center; diff --git a/yuqei-contract-platform/apps/contract-web/src/app/privacy/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/privacy/page.tsx index b483a932..710ee32b 100644 --- a/yuqei-contract-platform/apps/contract-web/src/app/privacy/page.tsx +++ b/yuqei-contract-platform/apps/contract-web/src/app/privacy/page.tsx @@ -1,9 +1,15 @@ import { LegalPolicyPage, PolicyParagraph } from "@/components/legal-policy-page"; +import { getCurrentPublicPolicy } from "@/lib/public-policies"; -export default function PrivacyPage() { +export default async function PrivacyPage() { + const lookup = await getCurrentPublicPolicy("privacy_policy"); + const policy = lookup.status === "effective" ? lookup.document : null; return (
-

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

+

+ 版本 {policy?.version ?? "2026-08-01"} / {policy ? `生效于 ${formatEffectiveAt(policy.effective_at)}` : "产品草案"} +

{title}

{summary}

@@ -27,19 +40,37 @@ export function LegalPolicyPage({
- 本页面用于产品开发和内部验收,正式发布前必须完成具名法务审核、版本确认和生效审批。 + {unavailable + ? "当前无法核验正式政策版本。为避免展示未经确认的内容,本页已暂停显示政策正文。" + : policy + ? `正式版本 ${policy.version}。文档指纹 SHA-256:${policy.sha256}` + : "本页面当前展示产品开发与内部验收草案,正式发布前必须完成具名法务审核、版本确认和生效审批。"}
-
- {sections.map((section) => ( -
-
-

{section.title}

-
-
{section.content}
-
- ))} -
+ {unavailable ? ( +
+ 正式政策服务暂时不可用,请稍后重新核验。 + 重新加载 +
+ ) : policy ? ( +
+
+

正式政策文本

+
+
{policy.content}
+
+ ) : ( +
+ {sections.map((section) => ( +
+
+

{section.title}

+
+
{section.content}
+
+ ))} +
+ )} ); } @@ -47,3 +78,8 @@ export function LegalPolicyPage({ export function PolicyParagraph({ children }: { children: ReactNode }) { return

{children}

; } + +function formatEffectiveAt(value: string) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" }); +} diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/agreement-consent-routing.test.mjs b/yuqei-contract-platform/apps/contract-web/src/lib/agreement-consent-routing.test.mjs new file mode 100644 index 00000000..258e0694 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/lib/agreement-consent-routing.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { agreementConsentRedirect, isAgreementConsentAllowedPath } from "./agreement-consent-routing.ts"; + +test("required consent redirects business and authentication entry pages", () => { + assert.equal(agreementConsentRedirect("/workspace", true), "/agreements/review"); + assert.equal(agreementConsentRedirect("/contracts/contract-1", true), "/agreements/review"); + assert.equal(agreementConsentRedirect("/login", true), "/agreements/review"); +}); + +test("review, public policies and logout stay available during consent", () => { + for (const pathname of [ + "/agreements/review", + "/terms", + "/privacy", + "/service-agreement", + "/ai-disclaimer", + "/api/auth/logout", + ]) { + assert.equal(isAgreementConsentAllowedPath(pathname), true, pathname); + assert.equal(agreementConsentRedirect(pathname, true), null, pathname); + } +}); + +test("sessions without a consent requirement are not redirected", () => { + assert.equal(agreementConsentRedirect("/workspace", false), null); +}); diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/agreement-consent-routing.ts b/yuqei-contract-platform/apps/contract-web/src/lib/agreement-consent-routing.ts new file mode 100644 index 00000000..825a3fe3 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/lib/agreement-consent-routing.ts @@ -0,0 +1,18 @@ +const POLICY_PATHS = ["/terms", "/privacy", "/service-agreement", "/ai-disclaimer"]; + +export function agreementConsentRedirect( + pathname: string, + agreementConsentRequired: boolean, +): string | null { + if (!agreementConsentRequired || isAgreementConsentAllowedPath(pathname)) { + return null; + } + return "/agreements/review"; +} + +export function isAgreementConsentAllowedPath(pathname: string) { + return pathname === "/agreements/review" + || pathname.startsWith("/agreements/review/") + || pathname === "/api/auth/logout" + || POLICY_PATHS.some((path) => pathname === path || pathname.startsWith(`${path}/`)); +} diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/contract-session.ts b/yuqei-contract-platform/apps/contract-web/src/lib/contract-session.ts index 71bf0850..f1a1654e 100644 --- a/yuqei-contract-platform/apps/contract-web/src/lib/contract-session.ts +++ b/yuqei-contract-platform/apps/contract-web/src/lib/contract-session.ts @@ -4,6 +4,15 @@ import { createHmac, timingSafeEqual } from "node:crypto"; export const CONTRACT_SESSION_COOKIE = "yuqei_contract_session"; const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; +export type RequiredAgreement = { + agreement_type: string; + version: string; + title: string; + document_path: string; + sha256?: string; + effective_at?: string | null; +}; + export type ContractWebSession = { tenant_id: string; tenant_name: string; @@ -14,6 +23,8 @@ export type ContractWebSession = { role: string; platform_permissions?: string[]; email_verified_at?: string | null; + agreement_consent_required?: boolean; + required_agreements?: RequiredAgreement[]; }; export async function getContractWebSession(): Promise { @@ -55,12 +66,27 @@ export function readContractWebSession(rawValue: string | null | undefined): Con ? parsed.platform_permissions.filter((item): item is string => typeof item === "string") : undefined, email_verified_at: typeof parsed.email_verified_at === "string" ? parsed.email_verified_at : null, + agreement_consent_required: parsed.agreement_consent_required === true, + required_agreements: Array.isArray(parsed.required_agreements) + ? parsed.required_agreements.filter(isRequiredAgreement) + : [], }; } catch { return null; } } +function isRequiredAgreement(value: unknown): value is RequiredAgreement { + if (!value || typeof value !== "object") { + return false; + } + const agreement = value as Partial; + return typeof agreement.agreement_type === "string" + && typeof agreement.version === "string" + && typeof agreement.title === "string" + && typeof agreement.document_path === "string"; +} + export async function setContractWebSession(session: ContractWebSession) { const cookieStore = await cookies(); cookieStore.set(CONTRACT_SESSION_COOKIE, encodeSession(session), { diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/identity-session-mapping.test.mjs b/yuqei-contract-platform/apps/contract-web/src/lib/identity-session-mapping.test.mjs index 69adec66..bc584b03 100644 --- a/yuqei-contract-platform/apps/contract-web/src/lib/identity-session-mapping.test.mjs +++ b/yuqei-contract-platform/apps/contract-web/src/lib/identity-session-mapping.test.mjs @@ -30,15 +30,42 @@ test("session mapping carries the global email verification timestamp", () => { role: "business", platform_permissions: ["platform_admin"], email_verified_at: "2026-08-02T03:00:00Z", + agreement_consent_required: false, + required_agreements: [], }); }); test("legacy identity sessions remain valid and explicitly unverified", () => { assert.equal(isIdentitySessionPayload(basePayload), true); assert.equal(toContractWebSession(basePayload).email_verified_at, null); + assert.equal(toContractWebSession(basePayload).agreement_consent_required, false); + assert.deepEqual(toContractWebSession(basePayload).required_agreements, []); assert.equal(isIdentitySessionPayload({ tenant: {}, user: {} }), false); }); +test("session mapping carries valid required agreements and drops malformed entries", () => { + const payload = { + ...basePayload, + agreement_consent_required: true, + required_agreements: [ + { + agreement_type: "user_terms", + version: "2026-08-12", + title: "用户协议", + document_path: "/terms", + sha256: "a".repeat(64), + effective_at: "2026-08-13T00:00:00+08:00", + }, + { agreement_type: "privacy_policy" }, + ], + }; + + const session = toContractWebSession(payload); + assert.equal(session.agreement_consent_required, true); + assert.equal(session.required_agreements?.length, 1); + assert.equal(session.required_agreements?.[0]?.agreement_type, "user_terms"); +}); + test("legacy identity sessions keep platform permissions absent", () => { const legacyPayload = { ...basePayload, diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/identity-session-mapping.ts b/yuqei-contract-platform/apps/contract-web/src/lib/identity-session-mapping.ts index f3e1310f..0caea876 100644 --- a/yuqei-contract-platform/apps/contract-web/src/lib/identity-session-mapping.ts +++ b/yuqei-contract-platform/apps/contract-web/src/lib/identity-session-mapping.ts @@ -1,4 +1,4 @@ -import type { ContractWebSession } from "@/lib/contract-session"; +import type { ContractWebSession, RequiredAgreement } from "@/lib/contract-session"; export type IdentitySessionPayload = { tenant: { @@ -16,6 +16,8 @@ export type IdentitySessionPayload = { global_account?: { email_verified_at?: string | null; } | null; + agreement_consent_required?: boolean; + required_agreements?: RequiredAgreement[]; }; export function isIdentitySessionPayload(value: unknown): value is IdentitySessionPayload { @@ -49,5 +51,20 @@ export function toContractWebSession(payload: IdentitySessionPayload): ContractW ? payload.user.platform_permissions.filter((item): item is string => typeof item === "string") : undefined, email_verified_at: typeof emailVerifiedAt === "string" ? emailVerifiedAt : null, + agreement_consent_required: payload.agreement_consent_required === true, + required_agreements: Array.isArray(payload.required_agreements) + ? payload.required_agreements.filter(isRequiredAgreement) + : [], }; } + +function isRequiredAgreement(value: unknown): value is RequiredAgreement { + if (!value || typeof value !== "object") { + return false; + } + const agreement = value as Partial; + return typeof agreement.agreement_type === "string" + && typeof agreement.version === "string" + && typeof agreement.title === "string" + && typeof agreement.document_path === "string"; +} diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/public-policies.test.mjs b/yuqei-contract-platform/apps/contract-web/src/lib/public-policies.test.mjs new file mode 100644 index 00000000..5cea6b98 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/lib/public-policies.test.mjs @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseCurrentPublicPolicy } from "./public-policy-document.ts"; + +const policy = { + policy_id: "user_terms", + title: "用户协议", + public_path: "/terms", + version: "2026-08-12", + effective_at: "2026-08-13T00:00:00+08:00", + sha256: "c".repeat(64), + size_bytes: 4096, + content: "# 用户协议\n", +}; + +test("current public policy parser preserves the exact document", () => { + assert.deepEqual(parseCurrentPublicPolicy(policy), policy); +}); + +test("current public policy parser rejects draft and incomplete payloads", () => { + assert.equal(parseCurrentPublicPolicy({ ...policy, policy_id: "candidate_terms" }), null); + assert.equal(parseCurrentPublicPolicy({ ...policy, content: undefined }), null); +}); diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/public-policies.ts b/yuqei-contract-platform/apps/contract-web/src/lib/public-policies.ts new file mode 100644 index 00000000..11f41cde --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/lib/public-policies.ts @@ -0,0 +1,34 @@ +import { buildContractApiUrl, fetchContractApi } from "@/lib/contract-api"; +import { + parseCurrentPublicPolicy, + type CurrentPublicPolicy, + type PublicPolicyId, +} from "@/lib/public-policy-document"; + +export type CurrentPublicPolicyLookup = + | { status: "effective"; document: CurrentPublicPolicy } + | { status: "not_published" } + | { status: "unavailable" }; + +export async function getCurrentPublicPolicy(policyId: PublicPolicyId): Promise { + try { + const response = await fetchContractApi( + buildContractApiUrl(`/api/v1/public-policies/current/${policyId}`), + { cache: "no-store" }, + ); + if (response.status === 404) { + return { status: "not_published" }; + } + if (!response.ok) { + return { status: "unavailable" }; + } + const payload: unknown = await response.json().catch(() => null); + const policy = parseCurrentPublicPolicy(payload); + if (!policy || policy.policy_id !== policyId) { + return { status: "unavailable" }; + } + return { status: "effective", document: policy }; + } catch { + return { status: "unavailable" }; + } +} diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/public-policy-document.ts b/yuqei-contract-platform/apps/contract-web/src/lib/public-policy-document.ts new file mode 100644 index 00000000..f313cae3 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/lib/public-policy-document.ts @@ -0,0 +1,31 @@ +export type PublicPolicyId = "user_terms" | "privacy_policy" | "service_agreement" | "ai_use_notice"; + +export type CurrentPublicPolicy = { + policy_id: PublicPolicyId; + title: string; + public_path: string; + version: string; + effective_at: string; + sha256: string; + size_bytes: number; + content: string; +}; + +export function parseCurrentPublicPolicy(value: unknown): CurrentPublicPolicy | null { + if (!value || typeof value !== "object") { + return null; + } + const policy = value as Partial; + const valid = (policy.policy_id === "user_terms" + || policy.policy_id === "privacy_policy" + || policy.policy_id === "service_agreement" + || policy.policy_id === "ai_use_notice") + && typeof policy.title === "string" + && typeof policy.public_path === "string" + && typeof policy.version === "string" + && typeof policy.effective_at === "string" + && typeof policy.sha256 === "string" + && typeof policy.size_bytes === "number" + && typeof policy.content === "string"; + return valid ? policy as CurrentPublicPolicy : null; +} diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/required-agreements.test.mjs b/yuqei-contract-platform/apps/contract-web/src/lib/required-agreements.test.mjs new file mode 100644 index 00000000..3c1d9417 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/lib/required-agreements.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildAgreementConsentSubmission, + parseRequiredAgreementStatus, +} from "./required-agreements.ts"; + +const agreement = { + agreement_type: "privacy_policy", + version: "2026-08-12", + title: "隐私政策", + document_path: "/privacy", + sha256: "b".repeat(64), +}; + +test("required agreement status accepts the exact API contract", () => { + assert.deepEqual(parseRequiredAgreementStatus({ + items: [agreement], + total: 1, + required: true, + completed: false, + }), { + items: [agreement], + total: 1, + required: true, + completed: false, + }); +}); + +test("required agreement status rejects malformed agreement items", () => { + assert.equal(parseRequiredAgreementStatus({ + items: [{ agreement_type: "privacy_policy" }], + total: 1, + required: true, + completed: false, + }), null); +}); + +test("legacy status derives completion when the optional field is absent", () => { + assert.deepEqual(parseRequiredAgreementStatus({ + items: [], + total: 0, + required: false, + }), { + items: [], + total: 0, + required: false, + completed: true, + }); +}); + +test("consent submission binds every exact type and version", () => { + assert.deepEqual(buildAgreementConsentSubmission([agreement]), { + items: [{ agreement_type: "privacy_policy", version: "2026-08-12", accepted: true }], + }); +}); diff --git a/yuqei-contract-platform/apps/contract-web/src/lib/required-agreements.ts b/yuqei-contract-platform/apps/contract-web/src/lib/required-agreements.ts new file mode 100644 index 00000000..461186c3 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/lib/required-agreements.ts @@ -0,0 +1,50 @@ +import type { RequiredAgreement } from "@/lib/contract-session"; + +export type RequiredAgreementStatus = { + items: RequiredAgreement[]; + total: number; + required: boolean; + completed: boolean; +}; + +export function parseRequiredAgreementStatus(value: unknown): RequiredAgreementStatus | null { + if (!value || typeof value !== "object") { + return null; + } + const status = value as Partial; + if (!Array.isArray(status.items) + || typeof status.required !== "boolean") { + return null; + } + const items = status.items.filter(isRequiredAgreement); + if (items.length !== status.items.length) { + return null; + } + return { + items, + total: typeof status.total === "number" ? status.total : items.length, + required: status.required, + completed: typeof status.completed === "boolean" ? status.completed : !status.required, + }; +} + +export function buildAgreementConsentSubmission(items: RequiredAgreement[]) { + return { + items: items.map((item) => ({ + agreement_type: item.agreement_type, + version: item.version, + accepted: true as const, + })), + }; +} + +function isRequiredAgreement(value: unknown): value is RequiredAgreement { + if (!value || typeof value !== "object") { + return false; + } + const agreement = value as Partial; + return typeof agreement.agreement_type === "string" + && typeof agreement.version === "string" + && typeof agreement.title === "string" + && typeof agreement.document_path === "string"; +} diff --git a/yuqei-contract-platform/apps/contract-web/src/proxy.ts b/yuqei-contract-platform/apps/contract-web/src/proxy.ts index 893b3ab2..672ea238 100644 --- a/yuqei-contract-platform/apps/contract-web/src/proxy.ts +++ b/yuqei-contract-platform/apps/contract-web/src/proxy.ts @@ -1,6 +1,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { canAccessAdminPath, normalizeUserRole } from "@/lib/access-control"; import { authenticatedEntryRedirect } from "@/lib/auth-entry-routing"; +import { agreementConsentRedirect } from "@/lib/agreement-consent-routing"; import { CONTRACT_SESSION_COOKIE, readContractWebSession } from "@/lib/contract-session"; const publicPaths = [ @@ -17,6 +18,10 @@ const publicPaths = [ export function proxy(request: NextRequest) { const pathname = request.nextUrl.pathname; const session = readContractWebSession(request.cookies.get(CONTRACT_SESSION_COOKIE)?.value); + const consentRedirect = agreementConsentRedirect(pathname, session?.agreement_consent_required === true); + if (consentRedirect) { + return NextResponse.redirect(new URL(consentRedirect, request.url)); + } const entryRedirect = authenticatedEntryRedirect(pathname, Boolean(session)); if (entryRedirect) { return NextResponse.redirect(new URL(entryRedirect, request.url)); diff --git a/yuqei-contract-platform/packages/contract-openapi/openapi.yaml b/yuqei-contract-platform/packages/contract-openapi/openapi.yaml index 1eb775b0..e4a3b363 100644 --- a/yuqei-contract-platform/packages/contract-openapi/openapi.yaml +++ b/yuqei-contract-platform/packages/contract-openapi/openapi.yaml @@ -1222,6 +1222,127 @@ paths: description: File not found "409": description: Stored file path is invalid + /api/v1/identity/agreements/current: + get: + operationId: getCurrentRegistrationAgreements + summary: Get the agreement versions required for new registrations + responses: + "200": + description: Current registration agreement versions + content: + application/json: + schema: + $ref: "#/components/schemas/AgreementVersionPage" + /api/v1/identity/agreements/required: + get: + operationId: getRequiredAgreementConsents + summary: Get the effective agreement versions awaiting consent by the current user + responses: + "200": + description: Required agreement consent status + content: + application/json: + schema: + $ref: "#/components/schemas/AgreementRequiredStatus" + "401": + description: Authenticated user context is required + /api/v1/identity/agreements/consents: + get: + operationId: listAgreementConsents + summary: List recorded agreement consents within the authorized workspace scope + parameters: + - name: tenant_id + in: query + required: false + schema: + type: string + - name: user_id + in: query + required: false + schema: + type: string + responses: + "200": + description: Agreement consent audit page + content: + application/json: + schema: + $ref: "#/components/schemas/AgreementConsentPage" + "403": + description: Requested consent scope is not authorized + post: + operationId: acceptRequiredAgreements + summary: Accept the exact effective agreement versions required for the current user + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AgreementReconsentRequest" + responses: + "200": + description: Updated agreement consent status + content: + application/json: + schema: + $ref: "#/components/schemas/AgreementRequiredStatus" + "400": + description: Submitted agreement set does not exactly match the effective versions + "401": + description: Authenticated user context is required + /api/v1/public-policies/current: + get: + operationId: getCurrentPublicPolicyCatalog + summary: Get the currently effective public policy catalog + responses: + "200": + description: Effective public policy catalog + content: + application/json: + schema: + $ref: "#/components/schemas/PublicPolicyCatalog" + "404": + description: No effective public policy set is active + "503": + description: Public policy repository is unavailable + /api/v1/public-policies/current/{policy_id}: + get: + operationId: getCurrentPublicPolicyDocument + summary: Get one currently effective public policy document + parameters: + - name: policy_id + in: path + required: true + schema: + $ref: "#/components/schemas/PublicPolicyId" + responses: + "200": + description: Effective public policy document + content: + application/json: + schema: + $ref: "#/components/schemas/PublicPolicyDocument" + "404": + description: No effective public policy document is active + "422": + description: Unknown policy identifier + "503": + description: Public policy repository is unavailable + /api/v1/admin/public-policy-promotion/preflight: + get: + operationId: getPublicPolicyPromotionPreflight + summary: Verify policy promotion evidence without authorizing or executing promotion + responses: + "200": + description: Read-only promotion preflight result + content: + application/json: + schema: + $ref: "#/components/schemas/PublicPolicyPromotionPreflight" + "403": + description: Platform administrator permission is required + "503": + description: Public policy governance is unavailable components: parameters: TraceIdHeader: @@ -1256,6 +1377,238 @@ components: maximum: 100 default: 20 schemas: + AgreementType: + type: string + enum: [user_terms, privacy_policy, service_agreement, ai_use_notice] + AgreementVersionInfo: + type: object + required: + - agreement_type + - version + - title + - document_path + - lifecycle_status + - is_effective + - legal_review_required + - required_for_registration + properties: + agreement_type: + $ref: "#/components/schemas/AgreementType" + version: + type: string + title: + type: string + document_path: + type: string + lifecycle_status: + type: string + enum: [draft, effective, superseded] + is_effective: + type: boolean + legal_review_required: + type: boolean + required_for_registration: + type: array + items: + type: string + enum: [personal, organization] + effective_at: + type: [string, "null"] + format: date-time + AgreementVersionPage: + type: object + required: [items, total] + properties: + items: + type: array + items: + $ref: "#/components/schemas/AgreementVersionInfo" + total: + type: integer + minimum: 0 + AgreementRequiredStatus: + type: object + required: [required, items, total] + properties: + required: + type: boolean + items: + type: array + items: + $ref: "#/components/schemas/AgreementVersionInfo" + total: + type: integer + minimum: 0 + AgreementConsentItem: + type: object + required: + - id + - tenant_id + - user_id + - agreement_type + - agreement_version + - agreed_at + - source + - created_at + properties: + id: + type: string + tenant_id: + type: string + user_id: + type: string + agreement_type: + $ref: "#/components/schemas/AgreementType" + agreement_version: + type: string + agreed_at: + type: string + format: date-time + source: + type: string + enum: [personal_registration, organization_registration, policy_reconsent] + client_ip: + type: [string, "null"] + user_agent: + type: [string, "null"] + created_at: + type: string + format: date-time + AgreementConsentPage: + type: object + required: [items, total] + properties: + items: + type: array + items: + $ref: "#/components/schemas/AgreementConsentItem" + total: + type: integer + minimum: 0 + AgreementAcceptanceRequest: + type: object + required: [agreement_type, version, accepted] + properties: + agreement_type: + $ref: "#/components/schemas/AgreementType" + version: + type: string + accepted: + type: boolean + const: true + AgreementReconsentRequest: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: "#/components/schemas/AgreementAcceptanceRequest" + PublicPolicyId: + type: string + enum: [user_terms, privacy_policy, service_agreement, ai_use_notice] + PublicPolicyDocument: + type: object + required: [policy_id, title, public_path, version, effective_at, sha256, size_bytes, content] + properties: + policy_id: + $ref: "#/components/schemas/PublicPolicyId" + title: + type: string + public_path: + type: string + version: + type: string + effective_at: + type: string + format: date-time + sha256: + type: string + pattern: "^[0-9a-f]{64}$" + size_bytes: + type: integer + minimum: 1 + content: + type: string + PublicPolicyCatalog: + type: object + required: [status, version, effective_at, existing_user_reconsent, documents] + properties: + status: + type: string + const: effective + version: + type: string + effective_at: + type: string + format: date-time + existing_user_reconsent: + type: boolean + documents: + type: array + items: + $ref: "#/components/schemas/PublicPolicyDocument" + PublicPolicyStateSnapshot: + type: object + required: [generation, current_set_id, current_event_id, state_hash] + properties: + generation: + type: integer + minimum: 0 + current_set_id: + type: [string, "null"] + current_event_id: + type: [string, "null"] + state_hash: + type: string + pattern: "^[0-9a-f]{64}$" + PublicPolicyPromotionPreflight: + type: object + required: + - status + - checked_at + - documents + - current_state + - activation_authorized + - executor_enabled + - blocking_reasons + properties: + status: + type: string + enum: [pass, blocked] + checked_at: + type: string + format: date-time + policy_version: + type: [string, "null"] + effective_at: + type: [string, "null"] + format: date-time + documents: + type: array + items: + type: object + additionalProperties: + type: [string, integer] + current_state: + $ref: "#/components/schemas/PublicPolicyStateSnapshot" + release_digest: + type: [string, "null"] + bundle: + type: [object, "null"] + additionalProperties: true + bundle_sha256: + type: [string, "null"] + activation_authorized: + type: boolean + const: false + executor_enabled: + type: boolean + const: false + blocking_reasons: + type: array + items: + type: string HealthResponse: type: object required: diff --git a/yuqei-contract-platform/services/contract-api/migrations/versions/20260812_0046_add_public_policy_promotion.py b/yuqei-contract-platform/services/contract-api/migrations/versions/20260812_0046_add_public_policy_promotion.py new file mode 100644 index 00000000..f54a405a --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/migrations/versions/20260812_0046_add_public_policy_promotion.py @@ -0,0 +1,205 @@ +"""add public policy promotion state + +Revision ID: 20260812_0046 +Revises: 20260809_0045 +Create Date: 2026-08-12 +""" + +from __future__ import annotations + +from datetime import UTC, datetime +import hashlib +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260812_0046" +down_revision: Union[str, None] = "20260809_0045" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_IMMUTABLE_TABLES = ( + "public_policy_sets", + "public_policy_documents", + "public_policy_state_events", +) +_POSTGRES_FUNCTION = "public_policy_ledger_immutable" +_BASELINE_STATE_HASH = hashlib.sha256(b"yuqei-public-policy-state-v1:global:0:null").hexdigest() + + +def upgrade() -> None: + op.create_table( + "public_policy_sets", + sa.Column("set_id", sa.String(length=64), nullable=False), + sa.Column("policy_version", sa.String(length=128), nullable=False), + sa.Column("effective_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("manifest_sha256", sa.String(length=64), nullable=False), + sa.Column("document_set_sha256", sa.String(length=64), nullable=False), + sa.Column("legal_approval_id", sa.String(length=128), nullable=False), + sa.Column("legal_approval_sha256", sa.String(length=64), nullable=False), + sa.Column("trusted_keyring_sha256", sa.String(length=64), nullable=False), + sa.Column("trust_approval_sha256", sa.String(length=64), nullable=False), + sa.Column("bundle_sha256", sa.String(length=64), nullable=False), + sa.Column("existing_user_reconsent", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("set_id"), + sa.UniqueConstraint("policy_version", name="ux_public_policy_sets_version"), + sa.UniqueConstraint("bundle_sha256", name="ux_public_policy_sets_bundle"), + ) + op.create_index("ix_public_policy_sets_policy_version", "public_policy_sets", ["policy_version"]) + op.create_table( + "public_policy_documents", + sa.Column("document_id", sa.String(length=64), nullable=False), + sa.Column("set_id", sa.String(length=64), nullable=False), + sa.Column("policy_id", sa.String(length=64), nullable=False), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("public_path", sa.String(length=128), nullable=False), + sa.Column("content_bytes", sa.LargeBinary(), nullable=False), + sa.Column("sha256", sa.String(length=64), nullable=False), + sa.Column("size_bytes", sa.Integer(), nullable=False), + sa.CheckConstraint("size_bytes > 0", name="ck_public_policy_documents_size"), + sa.ForeignKeyConstraint(["set_id"], ["public_policy_sets.set_id"], ondelete="NO ACTION"), + sa.PrimaryKeyConstraint("document_id"), + sa.UniqueConstraint("set_id", "policy_id", name="ux_public_policy_documents_set_policy"), + ) + op.create_index("ix_public_policy_documents_set_id", "public_policy_documents", ["set_id"]) + op.create_index("ix_public_policy_documents_path", "public_policy_documents", ["public_path"]) + op.create_table( + "public_policy_state", + sa.Column("scope_id", sa.String(length=32), nullable=False), + sa.Column("generation", sa.Integer(), nullable=False), + sa.Column("current_set_id", sa.String(length=64), nullable=True), + sa.Column("current_event_id", sa.String(length=64), nullable=True), + sa.Column("state_hash", sa.String(length=64), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint("scope_id = 'global'", name="ck_public_policy_state_global"), + sa.CheckConstraint("generation >= 0", name="ck_public_policy_state_generation"), + sa.ForeignKeyConstraint(["current_set_id"], ["public_policy_sets.set_id"], ondelete="NO ACTION"), + sa.PrimaryKeyConstraint("scope_id"), + ) + op.create_table( + "public_policy_state_events", + sa.Column("event_id", sa.String(length=64), nullable=False), + sa.Column("event_type", sa.String(length=32), nullable=False), + sa.Column("generation", sa.Integer(), nullable=False), + sa.Column("from_set_id", sa.String(length=64), nullable=True), + sa.Column("to_set_id", sa.String(length=64), nullable=True), + sa.Column("expected_generation", sa.Integer(), nullable=False), + sa.Column("bundle_sha256", sa.String(length=64), nullable=False), + sa.Column("authorization_sha256", sa.String(length=64), nullable=False), + sa.Column("trace_id", sa.String(length=128), nullable=False), + sa.Column("previous_event_hash", sa.String(length=64), nullable=True), + sa.Column("event_hash", sa.String(length=64), nullable=False), + sa.Column("idempotency_key", sa.String(length=160), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint("event_type in ('promoted', 'rolled_back')", name="ck_public_policy_state_events_type"), + sa.CheckConstraint("generation >= 1 AND expected_generation >= 0", name="ck_public_policy_state_events_generation"), + sa.ForeignKeyConstraint(["from_set_id"], ["public_policy_sets.set_id"], ondelete="NO ACTION"), + sa.ForeignKeyConstraint(["to_set_id"], ["public_policy_sets.set_id"], ondelete="NO ACTION"), + sa.PrimaryKeyConstraint("event_id"), + sa.UniqueConstraint("generation", name="ux_public_policy_state_events_generation"), + sa.UniqueConstraint("idempotency_key", name="ux_public_policy_state_events_idempotency"), + sa.UniqueConstraint("event_hash", name="ux_public_policy_state_events_hash"), + ) + op.create_index("ix_public_policy_state_events_created", "public_policy_state_events", ["created_at"]) + if op.get_bind().dialect.name == "postgresql": + op.create_foreign_key( + "fk_public_policy_state_current_event", + "public_policy_state", + "public_policy_state_events", + ["current_event_id"], + ["event_id"], + ondelete="NO ACTION", + deferrable=True, + initially="DEFERRED", + ) + op.bulk_insert( + sa.table( + "public_policy_state", + sa.column("scope_id", sa.String()), + sa.column("generation", sa.Integer()), + sa.column("current_set_id", sa.String()), + sa.column("current_event_id", sa.String()), + sa.column("state_hash", sa.String()), + sa.column("updated_at", sa.DateTime(timezone=True)), + ), + [{ + "scope_id": "global", + "generation": 0, + "current_set_id": None, + "current_event_id": None, + "state_hash": _BASELINE_STATE_HASH, + "updated_at": datetime.now(UTC), + }], + ) + _create_immutability_guards() + with op.batch_alter_table("identity_agreement_consents") as batch_op: + batch_op.drop_constraint("ck_identity_agreement_consents_source", type_="check") + batch_op.create_check_constraint( + "ck_identity_agreement_consents_source", + "source in ('personal_registration', 'organization_registration', 'policy_reconsent')", + ) + + +def downgrade() -> None: + with op.batch_alter_table("identity_agreement_consents") as batch_op: + batch_op.drop_constraint("ck_identity_agreement_consents_source", type_="check") + batch_op.create_check_constraint( + "ck_identity_agreement_consents_source", + "source in ('personal_registration', 'organization_registration')", + ) + _drop_immutability_guards() + if op.get_bind().dialect.name == "postgresql": + op.drop_constraint( + "fk_public_policy_state_current_event", + "public_policy_state", + type_="foreignkey", + ) + op.drop_index("ix_public_policy_state_events_created", table_name="public_policy_state_events") + op.drop_table("public_policy_state_events") + op.drop_table("public_policy_state") + op.drop_index("ix_public_policy_documents_path", table_name="public_policy_documents") + op.drop_index("ix_public_policy_documents_set_id", table_name="public_policy_documents") + op.drop_table("public_policy_documents") + op.drop_index("ix_public_policy_sets_policy_version", table_name="public_policy_sets") + op.drop_table("public_policy_sets") + + +def _create_immutability_guards() -> None: + bind = op.get_bind() + if bind.dialect.name == "sqlite": + for table_name in _IMMUTABLE_TABLES: + for action in ("UPDATE", "DELETE"): + op.execute( + sa.text( + f"CREATE TRIGGER trg_{table_name}_{action.lower()}_blocked " + f"BEFORE {action} ON {table_name} BEGIN " + "SELECT RAISE(ABORT, 'public policy ledger is append-only'); END" + ) + ) + elif bind.dialect.name == "postgresql": + op.execute(sa.text(f""" + CREATE OR REPLACE FUNCTION {_POSTGRES_FUNCTION}() RETURNS trigger + LANGUAGE plpgsql AS $$ BEGIN + RAISE EXCEPTION 'public policy ledger is append-only'; + END; $$ + """)) + for table_name in _IMMUTABLE_TABLES: + op.execute(sa.text( + f"CREATE TRIGGER trg_{table_name}_immutable BEFORE UPDATE OR DELETE ON {table_name} " + f"FOR EACH ROW EXECUTE FUNCTION {_POSTGRES_FUNCTION}()" + )) + + +def _drop_immutability_guards() -> None: + bind = op.get_bind() + if bind.dialect.name == "sqlite": + for table_name in _IMMUTABLE_TABLES: + for action in ("update", "delete"): + op.execute(sa.text(f"DROP TRIGGER IF EXISTS trg_{table_name}_{action}_blocked")) + elif bind.dialect.name == "postgresql": + for table_name in _IMMUTABLE_TABLES: + op.execute(sa.text(f"DROP TRIGGER IF EXISTS trg_{table_name}_immutable ON {table_name}")) + op.execute(sa.text(f"DROP FUNCTION IF EXISTS {_POSTGRES_FUNCTION}()")) diff --git a/yuqei-contract-platform/services/contract-api/pyproject.toml b/yuqei-contract-platform/services/contract-api/pyproject.toml index f10966df..fab96d1a 100644 --- a/yuqei-contract-platform/services/contract-api/pyproject.toml +++ b/yuqei-contract-platform/services/contract-api/pyproject.toml @@ -5,6 +5,7 @@ description = "Yuqei AI Contract Platform V2 contract API" requires-python = ">=3.12" dependencies = [ "alembic>=1.16", + "cryptography>=44", "fastapi>=0.115", "httpx>=0.28", "pydantic>=2.10", 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 012f90df..fcfc019d 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 @@ -24,6 +24,8 @@ from yuqei_contract_api.identity import ( AccountSettings, AccountSettingsUpdateRequest, AgreementConsentPage, + AgreementRequiredStatus, + AgreementReconsentRequest, AgreementVersionPage, EmailChallengeItem, EmailVerificationConfirmRequest, @@ -97,6 +99,18 @@ def register_identity_routes( email_delivery = build_identity_email_delivery(settings) logger = get_logger("yuqei_contract_api.identity") + def _with_agreement_status(session: IdentitySession, repository) -> IdentitySession: + checker = getattr(repository, "required_agreement_consents", None) + if not callable(checker): + return session + status = checker(tenant_id=session.tenant.id, user_id=session.user.id) + return session.model_copy( + update={ + "agreement_consent_required": status.required, + "required_agreements": status.items, + } + ) + @app.get( f"{settings.api_prefix}/identity/capabilities", response_model=IdentityCapabilities, @@ -120,7 +134,37 @@ def register_identity_routes( tags=["identity"], ) def get_current_registration_agreements() -> AgreementVersionPage: - return current_registration_agreements() + public_policies = getattr(repository, "public_policies", None) + active = public_policies.registration_agreement_versions() if public_policies is not None else [] + return current_registration_agreements(active or None) + + @app.get( + f"{settings.api_prefix}/identity/agreements/required", + response_model=AgreementRequiredStatus, + tags=["identity"], + ) + def required_agreements() -> AgreementRequiredStatus: + return identity_repository.required_agreement_consents( + tenant_id=_current_tenant_id(), + user_id=_required_agreement_user_id(), + ) + + @app.post( + f"{settings.api_prefix}/identity/agreements/consents", + response_model=AgreementRequiredStatus, + tags=["identity"], + ) + def accept_required_agreements(request: AgreementReconsentRequest, http_request: Request) -> AgreementRequiredStatus: + try: + return identity_repository.accept_required_agreements( + request, + tenant_id=_current_tenant_id(), + user_id=_required_agreement_user_id(), + client_ip=_identity_client_ip(http_request, settings), + user_agent=_registration_user_agent(http_request), + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc @app.get( f"{settings.api_prefix}/identity/tenants", @@ -495,11 +539,12 @@ def register_identity_routes( def register_company(request: TenantRegistrationRequest, http_request: Request) -> IdentitySession: _require_self_service_or_platform_admin(settings, identity_repository) try: - return identity_repository.register_tenant_admin( + session = identity_repository.register_tenant_admin( request, client_ip=_identity_client_ip(http_request, settings), user_agent=_registration_user_agent(http_request), ) + return _with_agreement_status(session, identity_repository) except IdentityConflictError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc except ValueError as exc: @@ -513,11 +558,12 @@ def register_identity_routes( def register_personal(request: PersonalRegistrationRequest, http_request: Request) -> IdentitySession: _require_self_service_or_platform_admin(settings, identity_repository) try: - return identity_repository.register_personal_workspace( + session = identity_repository.register_personal_workspace( request, client_ip=_identity_client_ip(http_request, settings), user_agent=_registration_user_agent(http_request), ) + return _with_agreement_status(session, identity_repository) except IdentityConflictError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc except ValueError as exc: @@ -572,7 +618,7 @@ def register_identity_routes( email=request.email, secret=_identity_rate_limit_secret(settings), ) - return session + return _with_agreement_status(session, identity_repository) except IdentityRateLimitExceeded as exc: raise _rate_limit_http_exception(exc) from exc except (IdentityConflictError, ValueError) as exc: @@ -599,11 +645,12 @@ def register_identity_routes( ) def switch_workspace(request: WorkspaceSwitchRequest) -> IdentitySession: try: - return identity_repository.switch_workspace( + session = identity_repository.switch_workspace( tenant_id=_current_tenant_id(), user_id=_current_user_id(), target_tenant_id=request.tenant_id, ) + return _with_agreement_status(session, identity_repository) except (PermissionError, ValueError) as exc: raise HTTPException(status_code=403, detail="Workspace access is not allowed.") from exc @@ -650,7 +697,7 @@ def register_identity_routes( def accept_invitation(request: TenantInvitationAcceptRequest) -> IdentitySession: _require_invitation_delivery(settings) try: - return identity_repository.accept_invitation(request) + return _with_agreement_status(identity_repository.accept_invitation(request), identity_repository) except IdentityConflictError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc except ValueError as exc: @@ -830,7 +877,7 @@ def register_identity_routes( _require_self_service(settings) _require_email_delivery(settings) try: - return identity_repository.confirm_password_reset(request) + return _with_agreement_status(identity_repository.confirm_password_reset(request), identity_repository) except ValueError as exc: raise HTTPException(status_code=400, detail="Verification code is invalid or expired.") from exc @@ -1003,6 +1050,13 @@ def _current_user_id() -> str: return get_user_context() or "current-admin" +def _required_agreement_user_id() -> str: + user_id = get_user_context() + if not user_id: + raise HTTPException(status_code=401, detail="Authenticated user context is required.") + return user_id + + def _is_platform_admin(identity_repository) -> bool: return identity_repository.has_platform_permission( tenant_id=_current_tenant_id(), diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/public_policies.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/public_policies.py new file mode 100644 index 00000000..40daa152 --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/api/public_policies.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from fastapi import FastAPI, HTTPException + +from yuqei_contract_api.config import ContractApiSettings +from yuqei_contract_api.public_policies import ( + PolicyId, + PublicPolicyCatalog, + PublicPolicyDocument, + PublicPolicyPromotionPreflight, + build_promotion_preflight, +) +from yuqei_contract_api.repository_base import ContractRepository +from yuqei_contract_api.tenant_context import get_tenant_context, get_user_context + + +def register_public_policy_routes( + app: FastAPI, + *, + settings: ContractApiSettings, + repository: ContractRepository, +) -> None: + public_policies = getattr(repository, "public_policies", None) + identity = getattr(repository, "identity", None) + + @app.get( + f"{settings.api_prefix}/public-policies/current", + response_model=PublicPolicyCatalog, + tags=["public-policies"], + ) + def current_catalog() -> PublicPolicyCatalog: + if public_policies is None: + raise HTTPException(status_code=503, detail="Public policy repository is unavailable.") + catalog = public_policies.current_catalog() + if catalog is None: + raise HTTPException(status_code=404, detail="No effective public policy set is active.") + return catalog + + @app.get( + f"{settings.api_prefix}/public-policies/current/{{policy_id}}", + response_model=PublicPolicyDocument, + tags=["public-policies"], + ) + def current_document(policy_id: PolicyId) -> PublicPolicyDocument: + if public_policies is None: + raise HTTPException(status_code=503, detail="Public policy repository is unavailable.") + document = public_policies.current_document(policy_id) + if document is None: + raise HTTPException(status_code=404, detail="No effective public policy document is active.") + return document + + @app.get( + f"{settings.api_prefix}/admin/public-policy-promotion/preflight", + response_model=PublicPolicyPromotionPreflight, + tags=["public-policies"], + ) + def promotion_preflight() -> PublicPolicyPromotionPreflight: + if public_policies is None or identity is None: + raise HTTPException(status_code=503, detail="Public policy governance is unavailable.") + if not identity.has_platform_permission( + tenant_id=get_tenant_context() or "default", + user_id=get_user_context() or "", + permission="platform_admin", + ): + raise HTTPException(status_code=403, detail="Platform administrator access is required.") + return build_promotion_preflight(settings, public_policies) 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 f326113b..698058c2 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 @@ -59,6 +59,11 @@ class ContractApiSettings(BaseSettings): anti_abuse_s4_records_daily_limit: int = 1000 anti_abuse_files_daily_limit: int = 50 anti_abuse_bytes_daily_limit: int = 500 * 1024 * 1024 + public_policy_candidate_dir: str = "" + public_policy_legal_approval_file: str = "" + public_policy_trusted_keyring_file: str = "" + public_policy_release_digest: str = "" + public_policy_executor_enabled: bool = False model_config = SettingsConfigDict( env_file=".env", 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 96d5bfce..516f953e 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 @@ -40,7 +40,7 @@ SsoProvider = Literal["saml", "oidc"] AgreementType = Literal["user_terms", "privacy_policy", "service_agreement", "ai_use_notice"] AgreementLifecycleStatus = Literal["draft", "effective", "retired"] RegistrationKind = Literal["personal", "organization"] -AgreementConsentSource = Literal["personal_registration", "organization_registration"] +AgreementConsentSource = Literal["personal_registration", "organization_registration", "policy_reconsent"] GlobalAccountStatus = Literal["active", "disabled", "archived"] WorkspaceMembershipStatus = Literal["active", "invited", "disabled"] AccountConflictSeverity = Literal["critical", "warning"] @@ -126,6 +126,16 @@ class AgreementConsentPage(BaseModel): total: int +class AgreementRequiredStatus(BaseModel): + required: bool + items: list[AgreementVersionInfo] + total: int + + +class AgreementReconsentRequest(BaseModel): + items: list[AgreementAcceptanceRequest] + + class GlobalAccountInfo(BaseModel): id: str email: str @@ -897,6 +907,8 @@ class IdentitySession(BaseModel): auth_mode: Literal["password", "legacy_email_session", "invitation", "workspace_switch"] = "password" global_account: GlobalAccountInfo | None = None membership: WorkspaceMembershipInfo | None = None + agreement_consent_required: bool = False + required_agreements: list[AgreementVersionInfo] = Field(default_factory=list) class TenantInvitationCreateRequest(BaseModel): @@ -1056,8 +1068,8 @@ _CURRENT_REGISTRATION_AGREEMENTS: tuple[AgreementVersionInfo, ...] = ( ) -def current_registration_agreements() -> AgreementVersionPage: - items = [item.model_copy(deep=True) for item in _CURRENT_REGISTRATION_AGREEMENTS] +def current_registration_agreements(active_items: list[AgreementVersionInfo] | None = None) -> AgreementVersionPage: + items = [item.model_copy(deep=True) for item in (active_items or _CURRENT_REGISTRATION_AGREEMENTS)] return AgreementVersionPage(items=items, total=len(items)) @@ -1065,10 +1077,11 @@ def validate_registration_agreements( acceptances: list[AgreementAcceptanceRequest], *, registration_kind: RegistrationKind, + current_items: list[AgreementVersionInfo] | None = None, ) -> list[AgreementVersionInfo]: required = [ item - for item in _CURRENT_REGISTRATION_AGREEMENTS + for item in (current_items or _CURRENT_REGISTRATION_AGREEMENTS) if registration_kind in item.required_for_registration ] expected = {(item.agreement_type, item.version) for item in required} diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/logging.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/logging.py index c29325c6..831700f5 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/logging.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/logging.py @@ -24,6 +24,10 @@ def set_request_tenant_id(tenant_id: str | None) -> None: _request_tenant_id.set(tenant_id) +def clear_request_tenant_id() -> None: + _request_tenant_id.set(None) + + def get_request_tenant_id() -> str | None: return _request_tenant_id.get() diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/main.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/main.py index c2fd0f14..68c340f2 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/main.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/main.py @@ -20,12 +20,14 @@ from yuqei_contract_api.api.files_search_backup import register_files_search_bac from yuqei_contract_api.api.identity import register_identity_routes from yuqei_contract_api.api.legal_assets import register_legal_asset_routes from yuqei_contract_api.api.review import register_review_routes +from yuqei_contract_api.api.public_policies import register_public_policy_routes from yuqei_contract_api.api.security import register_security_routes from yuqei_contract_api.api.support import register_support_routes from yuqei_contract_api.api.system import register_system_routes from yuqei_contract_api.api.templates import register_template_routes from yuqei_contract_api.config import ContractApiSettings, get_settings from yuqei_contract_api.logging import ( + clear_request_tenant_id, configure_logging, get_logger, set_request_tenant_id, @@ -33,7 +35,12 @@ from yuqei_contract_api.logging import ( ) from yuqei_contract_api.repository_base import ContractRepository from yuqei_contract_api.repository_factory import build_contract_repository -from yuqei_contract_api.proxy_auth import PROXY_HEADER, is_public_path, is_valid_proxy_key +from yuqei_contract_api.proxy_auth import ( + PROXY_HEADER, + PUBLIC_POLICY_IDS, + is_public_path, + is_valid_proxy_key, +) from yuqei_contract_api.tenant_context import clear_tenant_context, clear_user_context, set_tenant_context, set_user_context @@ -99,16 +106,68 @@ def create_app( if resolved_settings.require_web_proxy_auth and not is_public_path(request.url.path, resolved_settings.api_prefix): if not is_valid_proxy_key(request.headers.get(PROXY_HEADER), resolved_settings.web_proxy_shared_secret): - return JSONResponse( + response = JSONResponse( status_code=401, content={"detail": "Authenticated web proxy access is required."}, ) + response.headers["X-Trace-Id"] = trace_id + set_trace_id(None) + return response tenant_id = request.headers.get("X-Tenant-Id") or "default" user_id = request.headers.get("X-User-Id") set_tenant_context(tenant_id) set_user_context(user_id) set_request_tenant_id(tenant_id) + if resolved_settings.require_web_proxy_auth and not _is_policy_reconsent_exempt_path( + request.url.path, + resolved_settings.api_prefix, + ): + if not user_id: + response = JSONResponse( + status_code=401, + content={"detail": "Authenticated user context is required."}, + ) + response.headers["X-Trace-Id"] = trace_id + clear_tenant_context() + clear_user_context() + clear_request_tenant_id() + set_trace_id(None) + return response + identity_repository = getattr(repository, "identity", None) + required_check = getattr(identity_repository, "required_agreement_consents", None) + if callable(required_check): + try: + required = required_check(tenant_id=tenant_id, user_id=user_id) + except Exception: + response = JSONResponse( + status_code=503, + content={ + "code": "policy_reconsent_check_unavailable", + "detail": "Policy re-consent status could not be verified.", + }, + ) + response.headers["X-Trace-Id"] = trace_id + clear_tenant_context() + clear_user_context() + clear_request_tenant_id() + set_trace_id(None) + return response + if required.required: + response = JSONResponse( + status_code=428, + content={ + "code": "policy_reconsent_required", + "detail": "Current public policy versions require re-consent before business access.", + "items": [item.model_dump(mode="json") for item in required.items], + }, + ) + response.headers["X-Trace-Id"] = trace_id + clear_tenant_context() + clear_user_context() + clear_request_tenant_id() + set_trace_id(None) + return response anti_abuse_decision = await anti_abuse_guard.inspect(request, tenant_id=tenant_id, user_id=user_id) estimated_records_count = estimate_records_count(request, anti_abuse_decision) if anti_abuse_decision.decision == "block": @@ -122,7 +181,12 @@ def create_app( ) if anti_abuse_guard.events: persist_security_event(getattr(repository, "engine", None), anti_abuse_guard.events[-1]) - return rate_limit_response(anti_abuse_decision) + response = rate_limit_response(anti_abuse_decision) + clear_tenant_context() + clear_user_context() + clear_request_tenant_id() + set_trace_id(None) + return response anti_abuse_decision = check_egress_budget( getattr(repository, "engine", None), anti_abuse_decision, @@ -146,7 +210,12 @@ def create_app( anti_abuse_guard.events[-1], records_count=0, ) - return egress_budget_response(anti_abuse_decision) + response = egress_budget_response(anti_abuse_decision) + clear_tenant_context() + clear_user_context() + clear_request_tenant_id() + set_trace_id(None) + return response start_time = time.time() endpoint = request.url.path @@ -230,9 +299,11 @@ def create_app( invalidate_read_cache() clear_tenant_context() clear_user_context() + clear_request_tenant_id() set_trace_id(None) register_system_routes(app, settings=resolved_settings, repository=repository) + register_public_policy_routes(app, settings=resolved_settings, repository=repository) register_identity_routes(app, settings=resolved_settings, repository=repository) register_security_routes(app, settings=resolved_settings, repository=repository) register_support_routes(app, settings=resolved_settings, repository=repository) @@ -246,3 +317,23 @@ def create_app( app = create_app() + + +def _is_policy_reconsent_exempt_path(path: str, api_prefix: str) -> bool: + exempt = { + "/health", + f"{api_prefix}/health", + f"{api_prefix}/identity/login", + f"{api_prefix}/identity/capabilities", + f"{api_prefix}/identity/register-company", + f"{api_prefix}/identity/register-personal", + f"{api_prefix}/identity/invitations/accept", + f"{api_prefix}/identity/password-reset/request", + f"{api_prefix}/identity/password-reset/confirm", + f"{api_prefix}/identity/agreements/current", + f"{api_prefix}/identity/agreements/required", + f"{api_prefix}/identity/agreements/consents", + } + current = f"{api_prefix}/public-policies/current" + public_policy_paths = {f"{current}/{policy_id}" for policy_id in PUBLIC_POLICY_IDS} + return path in exempt or path == current or path in public_policy_paths 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 9f64ac92..fbb38930 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 @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta from itertools import islice -from typing import Literal +from typing import Callable, Literal from uuid import uuid4 from sqlalchemy import and_, delete, func, inspect, or_, select, text @@ -29,6 +29,9 @@ from yuqei_contract_api.identity import ( AccountSettingsUpdateRequest, AgreementConsentItem, AgreementConsentPage, + AgreementRequiredStatus, + AgreementReconsentRequest, + AgreementVersionInfo, EmailChallengeItem, EmailChallengePurpose, EmailVerificationConfirmRequest, @@ -104,8 +107,15 @@ from yuqei_contract_api.persistence.privileged_support_access import ( class IdentityRepository: - def __init__(self, session_factory: sessionmaker) -> None: + def __init__( + self, + session_factory: sessionmaker, + current_agreements: Callable[[], list[AgreementVersionInfo]] | None = None, + required_reconsent_agreements: Callable[[], list[AgreementVersionInfo]] | None = None, + ) -> None: self._session_factory = session_factory + self._current_agreements = current_agreements or (lambda: []) + self._required_reconsent_agreements = required_reconsent_agreements or self._current_agreements self.privileged_operations = PrivilegedIdentityOperationRepository(session_factory) self.support_access_policies = PrivilegedSupportAccessPolicyRepository( session_factory, @@ -659,6 +669,7 @@ class IdentityRepository: agreements = validate_registration_agreements( request.agreement_acceptances, registration_kind="organization", + current_items=self._current_agreements() or None, ) now = datetime.now(UTC) tenant_id = _normalize_id(request.tenant_id or f"tenant-{uuid4().hex[:12]}") @@ -779,6 +790,7 @@ class IdentityRepository: agreements = validate_registration_agreements( request.agreement_acceptances, registration_kind="personal", + current_items=self._current_agreements() or None, ) now = datetime.now(UTC) tenant_id = _normalize_id(request.workspace_id or f"personal-{uuid4().hex[:12]}") @@ -925,6 +937,67 @@ class IdentityRepository: total=len(models), ) + def required_agreement_consents(self, *, tenant_id: str, user_id: str) -> AgreementRequiredStatus: + required = [item for item in self._required_reconsent_agreements() if item.is_effective] + if not required: + return AgreementRequiredStatus(required=False, items=[], total=0) + with self._session_factory() as session: + accepted = set(session.execute( + select( + IdentityAgreementConsentModel.agreement_type, + IdentityAgreementConsentModel.agreement_version, + ).where( + IdentityAgreementConsentModel.tenant_id == tenant_id, + IdentityAgreementConsentModel.user_id == user_id, + ) + ).all()) + missing = [item for item in required if (item.agreement_type, item.version) not in accepted] + return AgreementRequiredStatus(required=bool(missing), items=missing, total=len(missing)) + + def accept_required_agreements( + self, + request: AgreementReconsentRequest, + *, + tenant_id: str, + user_id: str, + client_ip: str | None, + user_agent: str | None, + ) -> AgreementRequiredStatus: + required = [item for item in self._required_reconsent_agreements() if item.is_effective] + expected = {(item.agreement_type, item.version) for item in required} + submitted = {(item.agreement_type, item.version) for item in request.items} + if not required or len(submitted) != len(request.items) or submitted != expected: + raise ValueError("Agreement acceptances must exactly match the current effective versions.") + now = datetime.now(UTC) + try: + with self._session_factory.begin() as session: + existing = set(session.execute( + select( + IdentityAgreementConsentModel.agreement_type, + IdentityAgreementConsentModel.agreement_version, + ).where( + IdentityAgreementConsentModel.tenant_id == tenant_id, + IdentityAgreementConsentModel.user_id == user_id, + ) + ).all()) + consents = build_agreement_consents( + [item for item in required if (item.agreement_type, item.version) not in existing], + tenant_id=tenant_id, + user_id=user_id, + source="policy_reconsent", + client_ip=client_ip, + user_agent=user_agent, + now=now, + ) + session.add_all([_agreement_consent_model(item) for item in consents]) + except IntegrityError: + # A concurrent request may have inserted the same unique consent set. + status = self.required_agreement_consents(tenant_id=tenant_id, user_id=user_id) + if status.required: + raise + return status + return self.required_agreement_consents(tenant_id=tenant_id, user_id=user_id) + def has_platform_permission( self, *, 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 2019480e..693050f4 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 @@ -4,6 +4,7 @@ from datetime import date, datetime from sqlalchemy import ( Boolean, + LargeBinary, CheckConstraint, Date, DateTime, @@ -26,6 +27,98 @@ class Base(DeclarativeBase): pass +class PublicPolicySetModel(Base): + __tablename__ = "public_policy_sets" + __table_args__ = ( + UniqueConstraint("policy_version", name="ux_public_policy_sets_version"), + UniqueConstraint("bundle_sha256", name="ux_public_policy_sets_bundle"), + ) + + set_id: Mapped[str] = mapped_column(String(64), primary_key=True) + policy_version: Mapped[str] = mapped_column(String(128), nullable=False, index=True) + effective_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + manifest_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + document_set_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + legal_approval_id: Mapped[str] = mapped_column(String(128), nullable=False) + legal_approval_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + trusted_keyring_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + trust_approval_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + bundle_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + existing_user_reconsent: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + +class PublicPolicyDocumentModel(Base): + __tablename__ = "public_policy_documents" + __table_args__ = ( + UniqueConstraint("set_id", "policy_id", name="ux_public_policy_documents_set_policy"), + Index("ix_public_policy_documents_path", "public_path"), + ) + + document_id: Mapped[str] = mapped_column(String(64), primary_key=True) + set_id: Mapped[str] = mapped_column(String(64), ForeignKey("public_policy_sets.set_id", ondelete="NO ACTION"), nullable=False, index=True) + policy_id: Mapped[str] = mapped_column(String(64), nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + public_path: Mapped[str] = mapped_column(String(128), nullable=False) + content_bytes: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + sha256: Mapped[str] = mapped_column(String(64), nullable=False) + size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + + +class PublicPolicyStateModel(Base): + __tablename__ = "public_policy_state" + scope_id: Mapped[str] = mapped_column(String(32), primary_key=True) + generation: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + current_set_id: Mapped[str | None] = mapped_column(String(64), ForeignKey("public_policy_sets.set_id", ondelete="NO ACTION"), nullable=True) + current_event_id: Mapped[str | None] = mapped_column( + String(64), + ForeignKey( + "public_policy_state_events.event_id", + ondelete="NO ACTION", + deferrable=True, + initially="DEFERRED", + use_alter=True, + name="fk_public_policy_state_current_event", + ), + nullable=True, + ) + state_hash: Mapped[str] = mapped_column(String(64), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + +class PublicPolicyStateEventModel(Base): + __tablename__ = "public_policy_state_events" + __table_args__ = ( + CheckConstraint("event_type in ('promoted', 'rolled_back')", name="ck_public_policy_state_events_type"), + CheckConstraint( + "generation >= 1 AND expected_generation >= 0", + name="ck_public_policy_state_events_generation", + ), + UniqueConstraint("generation", name="ux_public_policy_state_events_generation"), + UniqueConstraint("idempotency_key", name="ux_public_policy_state_events_idempotency"), + UniqueConstraint("event_hash", name="ux_public_policy_state_events_hash"), + Index("ix_public_policy_state_events_created", "created_at"), + ) + + event_id: Mapped[str] = mapped_column(String(64), primary_key=True) + event_type: Mapped[str] = mapped_column(String(32), nullable=False) + generation: Mapped[int] = mapped_column(Integer, nullable=False) + from_set_id: Mapped[str | None] = mapped_column( + String(64), ForeignKey("public_policy_sets.set_id", ondelete="NO ACTION"), nullable=True + ) + to_set_id: Mapped[str | None] = mapped_column( + String(64), ForeignKey("public_policy_sets.set_id", ondelete="NO ACTION"), nullable=True + ) + expected_generation: Mapped[int] = mapped_column(Integer, nullable=False) + bundle_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + authorization_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + trace_id: Mapped[str] = mapped_column(String(128), nullable=False) + previous_event_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) + event_hash: Mapped[str] = mapped_column(String(64), nullable=False) + idempotency_key: Mapped[str] = mapped_column(String(160), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + class GovernanceMixin: created_by: Mapped[str | None] = mapped_column(String(128), nullable=True) updated_by: Mapped[str | None] = mapped_column(String(128), nullable=True) diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/proxy_auth.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/proxy_auth.py index 46172f06..c0017028 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/proxy_auth.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/proxy_auth.py @@ -4,6 +4,7 @@ from hmac import compare_digest PROXY_HEADER = "X-Contract-Proxy-Key" +PUBLIC_POLICY_IDS = ("user_terms", "privacy_policy", "service_agreement", "ai_use_notice") def is_valid_proxy_key(candidate: str | None, expected: str) -> bool: @@ -15,4 +16,6 @@ def is_valid_proxy_key(candidate: str | None, expected: str) -> bool: def is_public_path(path: str, api_prefix: str) -> bool: """Keep liveness endpoints reachable by Docker and load balancers.""" - return path in {"/health", f"{api_prefix}/health"} + current = f"{api_prefix}/public-policies/current" + public_policy_paths = {f"{current}/{policy_id}" for policy_id in PUBLIC_POLICY_IDS} + return path in {"/health", f"{api_prefix}/health", current, *public_policy_paths} diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/public_policies.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/public_policies.py new file mode 100644 index 00000000..80efa5db --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/public_policies.py @@ -0,0 +1,725 @@ +from __future__ import annotations + +import hashlib +import json +import re +import subprocess +import sys +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Literal + +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy import text +from sqlalchemy.orm import sessionmaker + +from yuqei_contract_api.config import ContractApiSettings +from yuqei_contract_api.persistence.models import ( + PublicPolicyDocumentModel, + PublicPolicySetModel, + PublicPolicyStateEventModel, + PublicPolicyStateModel, +) + + +PolicyId = Literal["user_terms", "privacy_policy", "service_agreement", "ai_use_notice"] +EXPECTED_POLICY_IDS = {"user_terms", "privacy_policy", "service_agreement", "ai_use_notice"} +EXPECTED_PUBLIC_PATHS = { + "user_terms": "/terms", + "privacy_policy": "/privacy", + "service_agreement": "/service-agreement", + "ai_use_notice": "/ai-disclaimer", +} +BASELINE_STATE_HASH = hashlib.sha256(b"yuqei-public-policy-state-v1:global:0:null").hexdigest() +SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +RELEASE_DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$") + + +class PublicPolicyError(ValueError): + pass + + +class PublicPolicyDocument(BaseModel): + policy_id: PolicyId + title: str + public_path: str + version: str + effective_at: datetime + sha256: str + size_bytes: int + content: str + + +class PublicPolicyCatalog(BaseModel): + status: Literal["effective"] = "effective" + version: str + effective_at: datetime + existing_user_reconsent: bool + documents: list[PublicPolicyDocument] + + +class PublicPolicyStateSnapshot(BaseModel): + generation: int = 0 + current_set_id: str | None = None + current_event_id: str | None = None + state_hash: str = BASELINE_STATE_HASH + + +class PublicPolicyPromotionPreflight(BaseModel): + status: Literal["pass", "blocked"] + checked_at: datetime + policy_version: str | None = None + effective_at: datetime | None = None + documents: list[dict[str, str | int]] = Field(default_factory=list) + current_state: PublicPolicyStateSnapshot + release_digest: str | None = None + bundle: dict[str, Any] | None = None + bundle_sha256: str | None = None + activation_authorized: Literal[False] = False + executor_enabled: Literal[False] = False + blocking_reasons: list[str] = Field(default_factory=list) + + +def canonical_json(value: Any) -> bytes: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8") + + +def sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +class PublicPolicyRepository: + def __init__(self, session_factory: sessionmaker) -> None: + self._session_factory = session_factory + + def state(self) -> PublicPolicyStateSnapshot: + with self._session_factory() as session: + row = session.get(PublicPolicyStateModel, "global") + if row is None: + return PublicPolicyStateSnapshot() + _validate_state_pointer(session, row) + return PublicPolicyStateSnapshot( + generation=row.generation, + current_set_id=row.current_set_id, + current_event_id=row.current_event_id, + state_hash=row.state_hash, + ) + + def current_catalog(self) -> PublicPolicyCatalog | None: + with self._session_factory() as session: + state = session.get(PublicPolicyStateModel, "global") + if state is None: + return None + _validate_state_pointer(session, state) + if state.current_set_id is None: + return None + policy_set = session.get(PublicPolicySetModel, state.current_set_id) + if policy_set is None: + raise PublicPolicyError("Active public policy set is missing.") + effective_at = _as_utc(policy_set.effective_at) + if effective_at > datetime.now(UTC): + return None + rows = session.scalars( + select(PublicPolicyDocumentModel) + .where(PublicPolicyDocumentModel.set_id == policy_set.set_id) + .order_by(PublicPolicyDocumentModel.policy_id) + ).all() + if {row.policy_id for row in rows} != EXPECTED_POLICY_IDS: + raise PublicPolicyError("Active public policy set is incomplete.") + documents = [ + PublicPolicyDocument( + policy_id=row.policy_id, # type: ignore[arg-type] + title=row.title, + public_path=row.public_path, + version=policy_set.policy_version, + effective_at=effective_at, + sha256=row.sha256, + size_bytes=row.size_bytes, + content=row.content_bytes.decode("utf-8"), + ) + for row in rows + ] + return PublicPolicyCatalog( + version=policy_set.policy_version, + effective_at=effective_at, + existing_user_reconsent=policy_set.existing_user_reconsent, + documents=documents, + ) + + def current_document(self, policy_id: PolicyId) -> PublicPolicyDocument | None: + catalog = self.current_catalog() + if catalog is None: + return None + return next((item for item in catalog.documents if item.policy_id == policy_id), None) + + def registration_agreement_versions(self): + from yuqei_contract_api.identity import AgreementVersionInfo + + catalog = self.current_catalog() + if catalog is None: + return [] + return [ + AgreementVersionInfo( + agreement_type=item.policy_id, + version=item.version, + title=item.title, + document_path=item.public_path, + lifecycle_status="effective", + is_effective=True, + legal_review_required=False, + required_for_registration=["personal", "organization"], + effective_at=item.effective_at, + ) + for item in catalog.documents + ] + + def current_agreement_versions(self): + catalog = self.current_catalog() + if catalog is None or not catalog.existing_user_reconsent: + return [] + return self.registration_agreement_versions() + + def promote_verified_bundle( + self, + *, + preflight: PublicPolicyPromotionPreflight, + policy_dir: Path, + authorization_sha256: str, + idempotency_key: str, + trace_id: str, + now: datetime, + approval_file: Path, + keyring_file: Path, + release_digest: str, + ) -> PublicPolicyStateSnapshot: + """Atomically install and point to one verified set. This is never exposed by HTTP.""" + if preflight.status != "pass" or not preflight.bundle or not preflight.bundle_sha256: + raise PublicPolicyError("A passing promotion preflight is required.") + _require_sha256(authorization_sha256, "activation authorization") + _require_nonempty(trace_id, "trace_id") + _require_nonempty(idempotency_key, "idempotency_key") + _require_release_digest(release_digest) + policy_dir = policy_dir.resolve() + approval_file = approval_file.resolve() + keyring_file = keyring_file.resolve() + expected = preflight.current_state + rebuilt_bundle, rebuilt_documents, rebuilt_effective_at = _build_verified_bundle( + policy_dir=policy_dir, + approval_file=approval_file, + keyring_file=keyring_file, + release_digest=release_digest, + current_state=expected, + checked_at=now, + ) + rebuilt_sha256 = sha256_bytes(canonical_json(rebuilt_bundle)) + if ( + preflight.bundle != rebuilt_bundle + or preflight.bundle_sha256 != rebuilt_sha256 + or preflight.documents != rebuilt_documents + or preflight.policy_version != rebuilt_bundle["policy_version"] + or preflight.effective_at != rebuilt_effective_at + or preflight.release_digest != release_digest + ): + raise PublicPolicyError("Policy evidence or canonical bundle changed after preflight.") + if rebuilt_effective_at > _as_utc(now): + raise PublicPolicyError("A policy set cannot be promoted before its approved effective_at.") + bundle = rebuilt_bundle + set_id = f"policy-set-{preflight.bundle_sha256[:24]}" + with self._session_factory.begin() as session: + state = session.execute( + select(PublicPolicyStateModel).where(PublicPolicyStateModel.scope_id == "global").with_for_update() + ).scalar_one_or_none() + actual_generation = state.generation if state else 0 + actual_set = state.current_set_id if state else None + actual_hash = state.state_hash if state else BASELINE_STATE_HASH + if state is None: + raise PublicPolicyError("Public policy baseline state is missing; run the current migration first.") + _validate_state_pointer(session, state) + existing_event = session.scalar( + select(PublicPolicyStateEventModel).where( + PublicPolicyStateEventModel.idempotency_key == idempotency_key + ) + ) + if existing_event is not None: + if ( + existing_event.event_type != "promoted" + or existing_event.bundle_sha256 != preflight.bundle_sha256 + or existing_event.authorization_sha256 != authorization_sha256 + or existing_event.trace_id != trace_id + ): + raise PublicPolicyError("Idempotency key was already used for different material.") + return PublicPolicyStateSnapshot( + generation=existing_event.generation, + current_set_id=existing_event.to_set_id, + current_event_id=existing_event.event_id, + state_hash=sha256_bytes(canonical_json({ + "generation": existing_event.generation, + "set_id": existing_event.to_set_id, + "event_hash": existing_event.event_hash, + })), + ) + if (actual_generation, actual_set, actual_hash) != ( + expected.generation, + expected.current_set_id, + expected.state_hash, + ): + raise PublicPolicyError("Public policy state changed after preflight.") + policy_set = session.get(PublicPolicySetModel, set_id) + if policy_set is None: + policy_set = PublicPolicySetModel( + set_id=set_id, + policy_version=str(bundle["policy_version"]), + effective_at=_as_utc(datetime.fromisoformat(str(bundle["effective_at"]))), + manifest_sha256=str(bundle["manifest_sha256"]), + document_set_sha256=str(bundle["document_set_sha256"]), + legal_approval_id=str(bundle["legal_approval_id"]), + legal_approval_sha256=str(bundle["legal_approval_sha256"]), + trusted_keyring_sha256=str(bundle["trusted_keyring_sha256"]), + trust_approval_sha256=str(bundle["trust_approval_sha256"]), + bundle_sha256=preflight.bundle_sha256, + existing_user_reconsent=bundle["existing_user_reconsent"] == "required_on_next_login", + created_at=now, + ) + session.add(policy_set) + for document in bundle["documents"]: + source_path = _resolve_source_file(policy_dir, str(document["source_file"])) + raw = source_path.read_bytes() + if sha256_bytes(raw) != document["sha256"]: + raise PublicPolicyError("Policy bytes changed after preflight.") + session.add(PublicPolicyDocumentModel( + document_id=f"policy-doc-{sha256_bytes((set_id + str(document['policy_id'])).encode())[:24]}", + set_id=set_id, + policy_id=str(document["policy_id"]), + title=str(document["title"]), + public_path=str(document["public_path"]), + content_bytes=raw, + sha256=str(document["sha256"]), + size_bytes=len(raw), + )) + generation = actual_generation + 1 + event_material = { + "event_type": "promoted", + "generation": generation, + "from_set_id": actual_set, + "to_set_id": set_id, + "expected_generation": actual_generation, + "bundle_sha256": preflight.bundle_sha256, + "authorization_sha256": authorization_sha256, + "trace_id": trace_id, + "previous_event_hash": _event_hash(session, state.current_event_id), + "idempotency_key": idempotency_key, + } + event_hash = sha256_bytes(canonical_json(event_material)) + event_id = f"policy-event-{event_hash[:24]}" + session.add(PublicPolicyStateEventModel( + event_id=event_id, + event_type="promoted", + generation=generation, + from_set_id=actual_set, + to_set_id=set_id, + expected_generation=actual_generation, + bundle_sha256=preflight.bundle_sha256, + authorization_sha256=authorization_sha256, + trace_id=trace_id, + previous_event_hash=event_material["previous_event_hash"], + event_hash=event_hash, + idempotency_key=idempotency_key, + created_at=now, + )) + state_hash = sha256_bytes(canonical_json({"generation": generation, "set_id": set_id, "event_hash": event_hash})) + if state is None: + state = PublicPolicyStateModel( + scope_id="global", generation=generation, current_set_id=set_id, + current_event_id=event_id, state_hash=state_hash, updated_at=now, + ) + session.add(state) + else: + state.generation = generation + state.current_set_id = set_id + state.current_event_id = event_id + state.state_hash = state_hash + state.updated_at = now + return PublicPolicyStateSnapshot( + generation=generation, current_set_id=set_id, current_event_id=event_id, state_hash=state_hash + ) + + def rollback_verified_set( + self, + *, + expected_state: PublicPolicyStateSnapshot, + target_set_id: str, + authorization_sha256: str, + idempotency_key: str, + trace_id: str, + now: datetime, + ) -> PublicPolicyStateSnapshot: + """Append a rollback pointer internally; never exposed as an HTTP execute route.""" + _require_sha256(authorization_sha256, "rollback authorization") + _require_nonempty(trace_id, "trace_id") + _require_nonempty(idempotency_key, "idempotency_key") + with self._session_factory.begin() as session: + state = session.execute( + select(PublicPolicyStateModel).where(PublicPolicyStateModel.scope_id == "global").with_for_update() + ).scalar_one_or_none() + if state is not None: + _validate_state_pointer(session, state) + existing = session.scalar(select(PublicPolicyStateEventModel).where( + PublicPolicyStateEventModel.idempotency_key == idempotency_key + )) + if existing is not None: + if ( + existing.event_type != "rolled_back" + or existing.to_set_id != target_set_id + or existing.authorization_sha256 != authorization_sha256 + or existing.trace_id != trace_id + ): + raise PublicPolicyError("Idempotency key was already used for different rollback material.") + return _snapshot_from_event(existing) + if state is None or (state.generation, state.current_set_id, state.state_hash) != ( + expected_state.generation, expected_state.current_set_id, expected_state.state_hash + ): + raise PublicPolicyError("Public policy state changed after rollback preflight.") + target = session.get(PublicPolicySetModel, target_set_id) + if target is None: + raise PublicPolicyError("Rollback target policy set is unavailable.") + if target_set_id == state.current_set_id: + raise PublicPolicyError("Rollback target must differ from the current policy set.") + was_previously_active = session.scalar(select(PublicPolicyStateEventModel.event_id).where( + PublicPolicyStateEventModel.to_set_id == target_set_id + ).limit(1)) + if was_previously_active is None: + raise PublicPolicyError("Rollback target was never an active policy set.") + generation = state.generation + 1 + material = { + "event_type": "rolled_back", "generation": generation, + "from_set_id": state.current_set_id, "to_set_id": target_set_id, + "expected_generation": state.generation, "bundle_sha256": target.bundle_sha256, + "authorization_sha256": authorization_sha256, + "trace_id": trace_id, + "previous_event_hash": _event_hash(session, state.current_event_id), + "idempotency_key": idempotency_key, + } + event_hash = sha256_bytes(canonical_json(material)) + event_id = f"policy-event-{event_hash[:24]}" + session.add(PublicPolicyStateEventModel( + event_id=event_id, event_type="rolled_back", generation=generation, + from_set_id=state.current_set_id, to_set_id=target_set_id, + expected_generation=state.generation, bundle_sha256=target.bundle_sha256, + authorization_sha256=authorization_sha256, trace_id=trace_id, + previous_event_hash=material["previous_event_hash"], event_hash=event_hash, + idempotency_key=idempotency_key, created_at=now, + )) + state.current_set_id = target_set_id + state.current_event_id = event_id + state.generation = generation + state.state_hash = sha256_bytes(canonical_json({"generation": generation, "set_id": target_set_id, "event_hash": event_hash})) + state.updated_at = now + return PublicPolicyStateSnapshot( + generation=generation, current_set_id=target_set_id, + current_event_id=event_id, state_hash=state.state_hash, + ) + + +def build_promotion_preflight( + settings: ContractApiSettings, + repository: PublicPolicyRepository, + *, + checked_at: datetime | None = None, +) -> PublicPolicyPromotionPreflight: + now = checked_at or datetime.now(UTC) + state = repository.state() + required = { + "policy candidate directory": settings.public_policy_candidate_dir, + "legal approval file": settings.public_policy_legal_approval_file, + "trusted keyring file": settings.public_policy_trusted_keyring_file, + "release digest": settings.public_policy_release_digest, + } + missing = [f"{label} is not configured" for label, value in required.items() if not value.strip()] + if settings.public_policy_executor_enabled: + missing.append("public policy executor must remain disabled during read-only preflight") + try: + _require_release_digest(settings.public_policy_release_digest) + except PublicPolicyError as exc: + if settings.public_policy_release_digest.strip(): + missing.append(str(exc)) + if missing: + return PublicPolicyPromotionPreflight(status="blocked", checked_at=now, current_state=state, blocking_reasons=missing) + policy_dir = Path(settings.public_policy_candidate_dir).resolve() + approval_file = Path(settings.public_policy_legal_approval_file).resolve() + keyring_file = Path(settings.public_policy_trusted_keyring_file).resolve() + for label, path in (("policy candidate directory", policy_dir), ("legal approval file", approval_file), ("trusted keyring file", keyring_file)): + if not path.exists(): + missing.append(f"{label} is unavailable") + if missing: + return PublicPolicyPromotionPreflight(status="blocked", checked_at=now, current_state=state, blocking_reasons=missing) + try: + bundle, documents, effective_at = _build_verified_bundle( + policy_dir=policy_dir, + approval_file=approval_file, + keyring_file=keyring_file, + release_digest=settings.public_policy_release_digest, + current_state=state, + checked_at=now, + ) + return PublicPolicyPromotionPreflight( + status="pass", + checked_at=now, + policy_version=str(bundle["policy_version"]), + effective_at=effective_at, + documents=documents, + current_state=state, + release_digest=settings.public_policy_release_digest, + bundle=bundle, + bundle_sha256=sha256_bytes(canonical_json(bundle)), + ) + except (OSError, ValueError, subprocess.SubprocessError, json.JSONDecodeError) as exc: + return PublicPolicyPromotionPreflight( + status="blocked", checked_at=now, current_state=state, + blocking_reasons=[f"policy evidence verification failed: {type(exc).__name__}"], + ) + + +def _build_verified_bundle( + *, + policy_dir: Path, + approval_file: Path, + keyring_file: Path, + release_digest: str, + current_state: PublicPolicyStateSnapshot, + checked_at: datetime, +) -> tuple[dict[str, Any], list[dict[str, str | int]], datetime]: + _require_release_digest(release_digest) + verification = _run_policy_verifier( + policy_dir=policy_dir, + approval_file=approval_file, + keyring_file=keyring_file, + checked_at=checked_at, + ) + approval_raw = approval_file.read_bytes() + keyring_raw = keyring_file.read_bytes() + manifest_path = policy_dir / "manifest.json" + manifest_raw = manifest_path.read_bytes() + approval = _strict_json(approval_raw, "legal approval") + keyring = _strict_json(keyring_raw, "trusted keyring") + manifest = _strict_json(manifest_raw, "policy manifest") + documents_value = verification.get("documents") + if not isinstance(documents_value, list): + raise PublicPolicyError("Policy verifier documents are invalid.") + documents: list[dict[str, str | int]] = [] + seen_ids: set[str] = set() + seen_sources: set[str] = set() + for value in documents_value: + if not isinstance(value, dict): + raise PublicPolicyError("Policy verifier document is invalid.") + policy_id = value.get("policy_id") + source_file = value.get("source_file") + public_path = value.get("public_path") + title = value.get("title") + digest = value.get("sha256") + size_bytes = value.get("size_bytes") + if ( + not isinstance(policy_id, str) + or policy_id not in EXPECTED_POLICY_IDS + or policy_id in seen_ids + or public_path != EXPECTED_PUBLIC_PATHS[policy_id] + or not isinstance(title, str) + or not title.strip() + or not isinstance(source_file, str) + or source_file in seen_sources + or not isinstance(digest, str) + or not SHA256_RE.fullmatch(digest) + or not isinstance(size_bytes, int) + or isinstance(size_bytes, bool) + or size_bytes <= 0 + ): + raise PublicPolicyError("Policy verifier document set is invalid.") + raw = _resolve_source_file(policy_dir, source_file).read_bytes() + if len(raw) != size_bytes or sha256_bytes(raw) != digest: + raise PublicPolicyError("Policy document bytes do not match verified metadata.") + seen_ids.add(policy_id) + seen_sources.add(source_file) + documents.append({ + "policy_id": policy_id, + "title": title, + "public_path": public_path, + "source_file": source_file, + "sha256": digest, + "size_bytes": size_bytes, + }) + if seen_ids != EXPECTED_POLICY_IDS: + raise PublicPolicyError("Policy verifier document set is incomplete.") + documents.sort(key=lambda item: str(item["policy_id"])) + policy_version = verification.get("policy_version") + effective_at_value = verification.get("effective_at") + if ( + not isinstance(policy_version, str) + or not policy_version.strip() + or manifest.get("version") != policy_version + or manifest.get("effective_at") != effective_at_value + ): + raise PublicPolicyError("Policy manifest does not match verified policy metadata.") + effective_at = _as_utc(datetime.fromisoformat(str(effective_at_value))) + reconsent = manifest.get("existing_user_reconsent") + if reconsent not in {"required_on_next_login", "not_required"}: + raise PublicPolicyError("Policy re-consent mode is invalid.") + approval_id = approval.get("approval_id") + trust_basis = keyring.get("trust_basis") + if not isinstance(approval_id, str) or not approval_id.strip(): + raise PublicPolicyError("Legal approval identifier is invalid.") + if not isinstance(trust_basis, dict) or trust_basis.get("policy_activation_authorized") is not False: + raise PublicPolicyError("Trusted keyring does not preserve activation separation.") + trust_approval_sha256 = trust_basis.get("trust_approval_sha256") + if not isinstance(trust_approval_sha256, str): + raise PublicPolicyError("Trusted keyring approval digest is invalid.") + _require_sha256(trust_approval_sha256, "trusted keyring approval") + bundle = { + "schema_version": "yuqei-public-policy-promotion-bundle-v1", + "operation": "promote", + "policy_version": policy_version, + "effective_at": effective_at_value, + "documents": documents, + "manifest_sha256": sha256_bytes(manifest_raw), + "document_set_sha256": sha256_bytes(canonical_json(documents)), + "legal_approval_id": approval_id, + "legal_approval_sha256": sha256_bytes(approval_raw), + "trusted_keyring_sha256": sha256_bytes(keyring_raw), + "trust_approval_sha256": trust_approval_sha256, + "existing_user_reconsent": reconsent, + "current_state": current_state.model_dump(mode="json"), + "release_digest": release_digest, + "activation_authorized": False, + "executor_enabled": False, + } + return bundle, documents, effective_at + + +def _run_policy_verifier(*, policy_dir: Path, approval_file: Path, keyring_file: Path, checked_at: datetime) -> dict[str, Any]: + candidates = ( + Path("/app/yuqei-infra/scripts/prepare-policy-legal-approval.py"), + Path(__file__).resolve().parents[5] / "yuqei-infra" / "scripts" / "prepare-policy-legal-approval.py", + ) + script = next((candidate for candidate in candidates if candidate.is_file()), None) + if script is None: + raise PublicPolicyError("Policy verifier is unavailable.") + completed = subprocess.run( + [sys.executable, str(script), "verify", "--approval-file", str(approval_file), "--keyring-file", str(keyring_file), "--policy-dir", str(policy_dir), "--checked-at", _as_utc(checked_at).isoformat()], + check=False, capture_output=True, text=True, timeout=20, + ) + if completed.returncode != 0: + raise PublicPolicyError("Policy verifier rejected the configured evidence.") + value = json.loads(completed.stdout) + if not isinstance(value, dict) or value.get("status") != "verified": + raise PublicPolicyError("Policy verifier response is invalid.") + return value + + +def _strict_json(raw: bytes, label: str) -> dict[str, Any]: + value = json.loads(raw.decode("utf-8")) + if not isinstance(value, dict): + raise PublicPolicyError(f"{label} must be a JSON object") + return value + + +def _resolve_source_file(policy_dir: Path, source_file: str) -> Path: + relative = Path(source_file) + if not source_file.strip() or relative.is_absolute() or ".." in relative.parts: + raise PublicPolicyError("Policy source_file must stay inside the candidate directory.") + root = policy_dir.resolve() + candidate = (root / relative).resolve() + if not candidate.is_relative_to(root) or not candidate.is_file(): + raise PublicPolicyError("Policy source_file is unavailable or outside the candidate directory.") + return candidate + + +def _require_sha256(value: str, label: str) -> None: + if not SHA256_RE.fullmatch(value): + raise PublicPolicyError(f"{label} SHA-256 must be 64 lowercase hexadecimal characters.") + + +def _require_release_digest(value: str) -> None: + if not RELEASE_DIGEST_RE.fullmatch(value): + raise PublicPolicyError("release digest must use sha256:<64 lowercase hexadecimal characters>.") + + +def _require_nonempty(value: str, label: str) -> None: + if not value.strip(): + raise PublicPolicyError(f"{label} is required.") + + +def _as_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + +def _snapshot_from_event(event: PublicPolicyStateEventModel) -> PublicPolicyStateSnapshot: + return PublicPolicyStateSnapshot( + generation=event.generation, + current_set_id=event.to_set_id, + current_event_id=event.event_id, + state_hash=sha256_bytes(canonical_json({ + "generation": event.generation, + "set_id": event.to_set_id, + "event_hash": event.event_hash, + })), + ) + + +def _event_hash(session, event_id: str | None) -> str | None: + if event_id is None: + return None + event = session.get(PublicPolicyStateEventModel, event_id) + return event.event_hash if event else None + + +def _validate_state_pointer(session, state: PublicPolicyStateModel) -> None: + if state.generation == 0: + if ( + state.current_set_id is not None + or state.current_event_id is not None + or state.state_hash != BASELINE_STATE_HASH + ): + raise PublicPolicyError("Public policy baseline state is invalid.") + return + if not state.current_set_id or not state.current_event_id: + raise PublicPolicyError("Public policy state pointer is incomplete.") + event = session.get(PublicPolicyStateEventModel, state.current_event_id) + if ( + event is None + or event.generation != state.generation + or event.to_set_id != state.current_set_id + or state.state_hash != sha256_bytes(canonical_json({ + "generation": state.generation, + "set_id": state.current_set_id, + "event_hash": event.event_hash, + })) + ): + raise PublicPolicyError("Public policy state pointer failed integrity validation.") + + +def install_public_policy_immutability_guards(engine) -> None: + tables = ("public_policy_sets", "public_policy_documents", "public_policy_state_events") + if engine.dialect.name == "sqlite": + with engine.begin() as connection: + for table_name in tables: + for action in ("UPDATE", "DELETE"): + connection.execute(text( + f"CREATE TRIGGER IF NOT EXISTS trg_{table_name}_{action.lower()}_blocked " + f"BEFORE {action} ON {table_name} BEGIN " + "SELECT RAISE(ABORT, 'public policy ledger is append-only'); END" + )) + elif engine.dialect.name == "postgresql": + with engine.begin() as connection: + connection.execute(text(""" + CREATE OR REPLACE FUNCTION public_policy_ledger_immutable() RETURNS trigger + LANGUAGE plpgsql AS $$ BEGIN + RAISE EXCEPTION 'public policy ledger is append-only'; + END; $$ + """)) + for table_name in tables: + connection.execute(text(f"DROP TRIGGER IF EXISTS trg_{table_name}_immutable ON {table_name}")) + connection.execute(text( + f"CREATE TRIGGER trg_{table_name}_immutable BEFORE UPDATE OR DELETE ON {table_name} " + "FOR EACH ROW EXECUTE FUNCTION public_policy_ledger_immutable()" + )) 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 aeb778bb..cfd74c76 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 @@ -26,6 +26,10 @@ EXPECTED_DATABASE_NAME = "yuqei_contract" VERSION_TABLE = "contract_api_alembic_version" REQUIRED_POSTGRES_EXTENSIONS = ("pg_trgm",) CORE_TABLES = ( + "public_policy_sets", + "public_policy_documents", + "public_policy_state", + "public_policy_state_events", "tenants", "tenant_users", "account_settings", 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 56d351e7..37163b9a 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 @@ -90,6 +90,7 @@ from yuqei_contract_api.persistence.review_results import StructuredReviewCitati from yuqei_contract_api.persistence.review_tasks import ReviewSourceDocument, ReviewTask, ReviewTaskRequest from yuqei_contract_api.persistence.search import SearchResultPage, SearchResultType, SearchSort from yuqei_contract_api.repository_base import ContractRepository, ContractWorkflowError +from yuqei_contract_api.public_policies import install_public_policy_immutability_guards from yuqei_contract_api.review_report import ReviewReportGenerateRequest, ReviewReportStatus from yuqei_contract_api.sqlalchemy_approval_orchestration import ( SqlAlchemyApprovalOrchestration as _SqlAlchemyApprovalOrchestration, @@ -170,6 +171,7 @@ class SqlAlchemyContractRepository(ContractRepository): self._files = components.files self._review_tasks = components.review_tasks self.identity = components.identity + self.public_policies = components.public_policies self._backup_orchestration = _SqlAlchemyBackupOrchestration( session_factory=self._session_factory, file_storage_dir=components.file_storage_dir, @@ -251,6 +253,7 @@ class SqlAlchemyContractRepository(ContractRepository): if auto_create: Base.metadata.create_all(self.engine) install_privileged_identity_operation_event_guards(self.engine) + install_public_policy_immutability_guards(self.engine) if auto_create or seed_demo_data: self.identity.seed_defaults() if seed_demo_data: diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/sqlalchemy_repository_wiring.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/sqlalchemy_repository_wiring.py index 56a83782..3934f020 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/sqlalchemy_repository_wiring.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/sqlalchemy_repository_wiring.py @@ -18,6 +18,7 @@ from yuqei_contract_api.persistence.review_tasks import ContractReviewTaskReposi from yuqei_contract_api.persistence.search import ContractSearchRepository from yuqei_contract_api.persistence.support import ContractSupportRepository from yuqei_contract_api.persistence.templates import ContractTemplatePersistence +from yuqei_contract_api.public_policies import PublicPolicyRepository from yuqei_contract_api.sqlalchemy_lifecycle_orchestration import SqlAlchemyLifecycleOrchestration from yuqei_contract_api.sqlalchemy_search_orchestration import SqlAlchemySearchOrchestration @@ -40,6 +41,7 @@ class SqlAlchemyRepositoryComponents: files: ContractFileRepository review_tasks: ContractReviewTaskRepository identity: IdentityRepository + public_policies: PublicPolicyRepository def build_sqlalchemy_repository_components( @@ -82,7 +84,12 @@ def build_sqlalchemy_repository_components( session_factory, read_stored_file=read_stored_file, ) - identity = IdentityRepository(session_factory) + public_policies = PublicPolicyRepository(session_factory) + identity = IdentityRepository( + session_factory, + current_agreements=public_policies.registration_agreement_versions, + required_reconsent_agreements=public_policies.current_agreement_versions, + ) return SqlAlchemyRepositoryComponents( engine=engine, session_factory=session_factory, @@ -100,4 +107,5 @@ def build_sqlalchemy_repository_components( files=files, review_tasks=review_tasks, identity=identity, + public_policies=public_policies, ) diff --git a/yuqei-contract-platform/services/contract-api/tests/test_privileged_identity_migration.py b/yuqei-contract-platform/services/contract-api/tests/test_privileged_identity_migration.py index 5f61227e..7ffdb5a9 100644 --- a/yuqei-contract-platform/services/contract-api/tests/test_privileged_identity_migration.py +++ b/yuqei-contract-platform/services/contract-api/tests/test_privileged_identity_migration.py @@ -166,7 +166,16 @@ def test_privileged_identity_operation_migration_upgrade_and_downgrade( with engine.connect() as connection: assert connection.execute( text("SELECT version_num FROM contract_api_alembic_version") - ).scalar_one() == "20260809_0045" + ).scalar_one() == "20260812_0046" + assert connection.execute( + text("SELECT generation FROM public_policy_state WHERE scope_id = 'global'") + ).scalar_one() == 0 + assert { + "public_policy_sets", + "public_policy_documents", + "public_policy_state", + "public_policy_state_events", + } <= set(inspector.get_table_names()) assert "privileged_identity_operations" in inspector.get_table_names() assert "privileged_identity_operation_approvals" in inspector.get_table_names() assert "privileged_identity_operation_events" in inspector.get_table_names() @@ -760,7 +769,7 @@ def test_privileged_identity_operation_migration_upgrade_and_downgrade( with engine.connect() as connection: assert connection.execute( text("SELECT version_num FROM contract_api_alembic_version") - ).scalar_one() == "20260809_0045" + ).scalar_one() == "20260812_0046" engine.dispose() finally: get_settings.cache_clear() diff --git a/yuqei-contract-platform/services/contract-api/tests/test_privileged_identity_postgres_concurrency.py b/yuqei-contract-platform/services/contract-api/tests/test_privileged_identity_postgres_concurrency.py index 8d50257f..2ee3871f 100644 --- a/yuqei-contract-platform/services/contract-api/tests/test_privileged_identity_postgres_concurrency.py +++ b/yuqei-contract-platform/services/contract-api/tests/test_privileged_identity_postgres_concurrency.py @@ -19,10 +19,17 @@ from sqlalchemy.engine import URL, make_url from sqlalchemy.exc import DBAPIError from sqlalchemy.orm import Session, sessionmaker +from yuqei_contract_api.identity import ( + AgreementAcceptanceRequest, + AgreementReconsentRequest, + AgreementVersionInfo, +) from yuqei_contract_api.logging import set_trace_id +from yuqei_contract_api.persistence.identity import IdentityRepository from yuqei_contract_api.persistence.models import ( ContractModel, GlobalAccountModel, + IdentityAgreementConsentModel, PrivilegedIdentityExecutionAttemptEventModel, PrivilegedIdentityExecutionAttemptModel, PrivilegedIdentityOperationApprovalModel, @@ -182,7 +189,7 @@ def postgres_governance() -> Iterator[PostgresGovernanceHarness]: with isolated_engine.connect() as connection: assert connection.scalar( text("SELECT version_num FROM contract_api_alembic_version") - ) == "20260809_0045" + ) == "20260812_0046" migrated_tables = set( connection.execute( text("SELECT tablename FROM pg_tables WHERE schemaname = :schema_name"), @@ -204,6 +211,10 @@ def postgres_governance() -> Iterator[PostgresGovernanceHarness]: "privileged_support_access_control_events", "privileged_support_access_request_audits", "privileged_support_access_audit_outbox", + "public_policy_sets", + "public_policy_documents", + "public_policy_state", + "public_policy_state_events", } <= migrated_tables operation_columns = { row[0] @@ -2086,3 +2097,74 @@ def test_postgresql_support_access_validation_ledger_is_concurrent_and_immutable with pytest.raises(DBAPIError): with session_factory.begin() as session: session.execute(text(f"DELETE FROM {table_name}")) + + +def test_postgresql_policy_reconsent_is_concurrently_idempotent( + postgres_governance: PostgresGovernanceHarness, +) -> None: + session_factory = postgres_governance.session_factory + user_id = "user-policy-reconsent" + now = datetime.now(UTC) + with session_factory.begin() as session: + if session.get(TenantUserModel, user_id) is None: + _seed_identity( + session, + account_id="account-policy-reconsent", + user_id=user_id, + platform_admin=False, + now=now, + ) + agreements = [ + AgreementVersionInfo( + agreement_type=agreement_type, + version="2026-08-12", + title=title, + document_path=path, + lifecycle_status="effective", + is_effective=True, + legal_review_required=False, + required_for_registration=["personal", "organization"], + effective_at=now, + ) + for agreement_type, title, path in ( + ("user_terms", "User Terms", "/terms"), + ("privacy_policy", "Privacy Policy", "/privacy"), + ("service_agreement", "Service Agreement", "/service-agreement"), + ("ai_use_notice", "AI Use Notice", "/ai-disclaimer"), + ) + ] + repository = IdentityRepository( + session_factory, + current_agreements=lambda: agreements, + required_reconsent_agreements=lambda: agreements, + ) + request = AgreementReconsentRequest(items=[ + AgreementAcceptanceRequest( + agreement_type=item.agreement_type, + version=item.version, + accepted=True, + ) + for item in agreements + ]) + + def accept(_: int) -> bool: + return repository.accept_required_agreements( + request, + tenant_id=_TENANT_ID, + user_id=user_id, + client_ip="127.0.0.1", + user_agent="postgres-concurrency-test", + ).required + + with ThreadPoolExecutor(max_workers=8) as executor: + results = list(executor.map(accept, range(8))) + + assert results == [False] * 8 + with session_factory() as session: + assert session.scalar( + select(func.count(IdentityAgreementConsentModel.id)).where( + IdentityAgreementConsentModel.tenant_id == _TENANT_ID, + IdentityAgreementConsentModel.user_id == user_id, + IdentityAgreementConsentModel.agreement_version == "2026-08-12", + ) + ) == 4 diff --git a/yuqei-contract-platform/services/contract-api/tests/test_proxy_auth.py b/yuqei-contract-platform/services/contract-api/tests/test_proxy_auth.py index 7e8ae7cd..fbd907eb 100644 --- a/yuqei-contract-platform/services/contract-api/tests/test_proxy_auth.py +++ b/yuqei-contract-platform/services/contract-api/tests/test_proxy_auth.py @@ -46,6 +46,10 @@ def test_non_public_requests_require_the_server_side_proxy_key(tmp_path: Path) - missing = client.get("/api/v1/contracts") wrong = client.get("/api/v1/contracts", headers={"X-Contract-Proxy-Key": "wrong"}) + missing_user = client.get( + "/api/v1/contracts", + headers={"X-Contract-Proxy-Key": AUTH_VALUE, "X-Tenant-Id": "default"}, + ) valid = client.get( "/api/v1/contracts", headers={ @@ -57,9 +61,45 @@ def test_non_public_requests_require_the_server_side_proxy_key(tmp_path: Path) - assert missing.status_code == 401 assert wrong.status_code == 401 + assert missing_user.status_code == 401 + assert missing_user.json()["detail"] == "Authenticated user context is required." assert valid.status_code == 200 +def test_public_policy_proxy_bypass_is_limited_to_exact_read_routes(tmp_path: Path) -> None: + client = TestClient(create_app(make_settings(tmp_path))) + + assert client.get("/api/v1/public-policies/current").status_code == 404 + assert client.get("/api/v1/public-policies/current/user_terms").status_code == 404 + assert client.get("/api/v1/public-policies/currentness").status_code == 401 + assert client.get("/api/v1/public-policies/current/not_a_policy").status_code == 401 + assert client.get("/api/v1/public-policies/current/user_terms/extra").status_code == 401 + + +def test_required_agreement_routes_reject_missing_user_context(tmp_path: Path) -> None: + client = TestClient(create_app(make_settings(tmp_path))) + headers = {"X-Contract-Proxy-Key": AUTH_VALUE, "X-Tenant-Id": "default"} + + required = client.get("/api/v1/identity/agreements/required", headers=headers) + accepted = client.post( + "/api/v1/identity/agreements/consents", + headers=headers, + json={"items": []}, + ) + + assert required.status_code == 401 + assert accepted.status_code == 401 + + +def test_public_policy_preflight_requires_platform_admin_even_without_proxy_auth(tmp_path: Path) -> None: + settings = make_settings(tmp_path).model_copy(update={"require_web_proxy_auth": False}) + client = TestClient(create_app(settings)) + + response = client.get("/api/v1/admin/public-policy-promotion/preflight") + + assert response.status_code == 403 + + def test_production_config_requires_proxy_auth() -> None: settings = ContractApiSettings( environment="production", diff --git a/yuqei-contract-platform/services/contract-api/tests/test_public_policy_promotion.py b/yuqei-contract-platform/services/contract-api/tests/test_public_policy_promotion.py new file mode 100644 index 00000000..bc0b5ff4 --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/tests/test_public_policy_promotion.py @@ -0,0 +1,478 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime +from pathlib import Path + +import pytest +from sqlalchemy import create_engine, select, text +from sqlalchemy.exc import DatabaseError +from sqlalchemy.orm import sessionmaker + +from yuqei_contract_api.config import ContractApiSettings +from yuqei_contract_api.persistence.models import ( + Base, + PublicPolicyDocumentModel, + PublicPolicySetModel, + PublicPolicyStateEventModel, + PublicPolicyStateModel, +) +from yuqei_contract_api.public_policies import ( + BASELINE_STATE_HASH, + PublicPolicyError, + PublicPolicyRepository, + build_promotion_preflight, + canonical_json, + install_public_policy_immutability_guards, + sha256_bytes, +) + + +RELEASE_DIGEST = f"sha256:{'9' * 64}" + + +def _repository(tmp_path: Path) -> tuple[PublicPolicyRepository, sessionmaker]: + engine = create_engine(f"sqlite+pysqlite:///{(tmp_path / 'policies.sqlite3').as_posix()}") + Base.metadata.create_all(engine) + install_public_policy_immutability_guards(engine) + factory = sessionmaker(bind=engine, expire_on_commit=False, future=True) + with factory.begin() as session: + session.add(PublicPolicyStateModel( + scope_id="global", + generation=0, + current_set_id=None, + current_event_id=None, + state_hash=BASELINE_STATE_HASH, + updated_at=datetime.now(UTC), + )) + return PublicPolicyRepository(factory), factory + + +def _evidence( + policy_dir: Path, + *, + version: str = "2026-08-12", + effective_at: str = "2026-08-11T00:00:00+08:00", + suffix: str = "", + reconsent: str = "required_on_next_login", +) -> tuple[Path, Path, list[dict[str, str | int]]]: + documents: list[dict[str, str | int]] = [] + manifest_documents: list[dict[str, str]] = [] + for policy_id, title, public_path in ( + ("user_terms", "User Terms", "/terms"), + ("privacy_policy", "Privacy Policy", "/privacy"), + ("service_agreement", "Service Agreement", "/service-agreement"), + ("ai_use_notice", "AI Use Notice", "/ai-disclaimer"), + ): + source_file = f"{policy_id}.md" + raw = f"# {title}\n\nExact policy text for {policy_id}.{suffix}\n".encode() + (policy_dir / source_file).write_bytes(raw) + documents.append({ + "policy_id": policy_id, + "title": title, + "public_path": public_path, + "source_file": source_file, + "sha256": sha256_bytes(raw), + "size_bytes": len(raw), + }) + manifest_documents.append({ + "policy_id": policy_id, + "title": title, + "public_path": public_path, + "source_file": source_file, + }) + manifest = { + "schema_version": "yuqei-public-policy-candidate-v1", + "version": version, + "effective_at": effective_at, + "existing_user_reconsent": reconsent, + "documents": manifest_documents, + } + (policy_dir / "manifest.json").write_text( + json.dumps(manifest, ensure_ascii=False, sort_keys=True), encoding="utf-8" + ) + approval_file = policy_dir / "approval.json" + approval_file.write_text(json.dumps({"approval_id": f"policy-approval-{version}"}), encoding="utf-8") + keyring_file = policy_dir / "keyring.json" + keyring_file.write_text(json.dumps({ + "trust_basis": { + "policy_activation_authorized": False, + "trust_approval_sha256": "5" * 64, + } + }), encoding="utf-8") + return approval_file, keyring_file, sorted(documents, key=lambda item: str(item["policy_id"])) + + +def _mock_verifier(monkeypatch, *, version: str, effective_at: str, documents) -> None: + monkeypatch.setattr( + "yuqei_contract_api.public_policies._run_policy_verifier", + lambda **_: { + "status": "verified", + "policy_version": version, + "effective_at": effective_at, + "documents": documents, + }, + ) + + +def _preflight(repository, policy_dir, approval_file, keyring_file, checked_at): + return build_promotion_preflight( + ContractApiSettings( + public_policy_candidate_dir=str(policy_dir), + public_policy_legal_approval_file=str(approval_file), + public_policy_trusted_keyring_file=str(keyring_file), + public_policy_release_digest=RELEASE_DIGEST, + ), + repository, + checked_at=checked_at, + ) + + +def _promote(repository, preflight, policy_dir, approval_file, keyring_file, *, key, trace, now): + return repository.promote_verified_bundle( + preflight=preflight, + policy_dir=policy_dir, + approval_file=approval_file, + keyring_file=keyring_file, + release_digest=RELEASE_DIGEST, + authorization_sha256="a" * 64, + idempotency_key=key, + trace_id=trace, + now=now, + ) + + +def test_preflight_without_evidence_is_blocked_and_redacted(tmp_path: Path) -> None: + repository, _ = _repository(tmp_path) + result = build_promotion_preflight(ContractApiSettings(), repository) + + assert result.status == "blocked" + assert result.activation_authorized is False + assert result.executor_enabled is False + assert result.bundle is None + assert all("private" not in reason.lower() for reason in result.blocking_reasons) + + +def test_preflight_rejects_noncanonical_release_digest(tmp_path: Path) -> None: + repository, _ = _repository(tmp_path) + result = build_promotion_preflight( + ContractApiSettings( + public_policy_candidate_dir=str(tmp_path), + public_policy_legal_approval_file=str(tmp_path / "approval.json"), + public_policy_trusted_keyring_file=str(tmp_path / "keyring.json"), + public_policy_release_digest="latest", + ), + repository, + ) + + assert result.status == "blocked" + assert any("sha256:" in reason for reason in result.blocking_reasons) + + +def test_internal_promotion_is_atomic_idempotent_and_append_only(tmp_path: Path, monkeypatch) -> None: + repository, factory = _repository(tmp_path) + policy_dir = tmp_path / "candidate" + policy_dir.mkdir() + effective_at = "2026-08-11T00:00:00+08:00" + approval_file, keyring_file, documents = _evidence(policy_dir, effective_at=effective_at) + _mock_verifier(monkeypatch, version="2026-08-12", effective_at=effective_at, documents=documents) + now = datetime.now(UTC) + preflight = _preflight(repository, policy_dir, approval_file, keyring_file, now) + assert preflight.status == "pass" + + promoted = _promote( + repository, preflight, policy_dir, approval_file, keyring_file, + key="policy-promotion-test", trace="trace-policy-test", now=now, + ) + repeated = _promote( + repository, preflight, policy_dir, approval_file, keyring_file, + key="policy-promotion-test", trace="trace-policy-test", now=now, + ) + + assert repeated == promoted + assert promoted.generation == 1 + assert repository.current_catalog() is not None + with factory() as session: + events = session.scalars(select(PublicPolicyStateEventModel)).all() + assert len(events) == 1 + assert events[0].trace_id == "trace-policy-test" + assert len(session.scalars(select(PublicPolicyDocumentModel)).all()) == 4 + with pytest.raises(DatabaseError): + with factory.begin() as session: + session.execute(text("update public_policy_sets set policy_version = 'tampered'")) + + +def test_internal_promotion_rejects_forged_bundle_without_partial_state(tmp_path: Path, monkeypatch) -> None: + repository, factory = _repository(tmp_path) + policy_dir = tmp_path / "candidate" + policy_dir.mkdir() + effective_at = "2026-08-11T00:00:00+08:00" + approval_file, keyring_file, documents = _evidence(policy_dir, effective_at=effective_at) + _mock_verifier(monkeypatch, version="2026-08-12", effective_at=effective_at, documents=documents) + preflight = _preflight(repository, policy_dir, approval_file, keyring_file, datetime.now(UTC)) + forged = preflight.model_copy(deep=True) + assert forged.bundle is not None + forged.bundle["release_digest"] = f"sha256:{'8' * 64}" + + with pytest.raises(PublicPolicyError, match="canonical bundle changed"): + _promote( + repository, forged, policy_dir, approval_file, keyring_file, + key="policy-promotion-forged", trace="trace-forged", now=datetime.now(UTC), + ) + with factory() as session: + assert session.scalar(select(PublicPolicySetModel)) is None + assert session.scalar(select(PublicPolicyStateEventModel)) is None + + +def test_preflight_rejects_source_traversal(tmp_path: Path, monkeypatch) -> None: + repository, _ = _repository(tmp_path) + policy_dir = tmp_path / "candidate" + policy_dir.mkdir() + effective_at = "2026-08-11T00:00:00+08:00" + approval_file, keyring_file, documents = _evidence(policy_dir, effective_at=effective_at) + outside = tmp_path / "outside.md" + outside.write_text("outside", encoding="utf-8") + documents[0] = { + **documents[0], + "source_file": "../outside.md", + "sha256": sha256_bytes(outside.read_bytes()), + "size_bytes": len(outside.read_bytes()), + } + _mock_verifier(monkeypatch, version="2026-08-12", effective_at=effective_at, documents=documents) + + result = _preflight(repository, policy_dir, approval_file, keyring_file, datetime.now(UTC)) + assert result.status == "blocked" + + +def test_future_policy_set_is_not_public_before_effective_at(tmp_path: Path, monkeypatch) -> None: + repository, factory = _repository(tmp_path) + policy_dir = tmp_path / "candidate" + policy_dir.mkdir() + effective_at = "2099-08-13T00:00:00+08:00" + approval_file, keyring_file, documents = _evidence( + policy_dir, version="2099-08-12", effective_at=effective_at + ) + _mock_verifier(monkeypatch, version="2099-08-12", effective_at=effective_at, documents=documents) + now = datetime.now(UTC) + preflight = _preflight(repository, policy_dir, approval_file, keyring_file, now) + with pytest.raises(PublicPolicyError, match="before its approved effective_at"): + _promote( + repository, preflight, policy_dir, approval_file, keyring_file, + key="future-policy", trace="trace-future", now=now, + ) + + # Defense in depth: a future pointer inserted by a migration or operator + # still cannot be published by the read path. + with factory.begin() as session: + policy_set = PublicPolicySetModel( + set_id="policy-set-future", + policy_version="2099-08-12", + effective_at=datetime.fromisoformat(effective_at), + manifest_sha256="1" * 64, + document_set_sha256="2" * 64, + legal_approval_id="policy-approval-future", + legal_approval_sha256="3" * 64, + trusted_keyring_sha256="4" * 64, + trust_approval_sha256="5" * 64, + bundle_sha256="6" * 64, + existing_user_reconsent=True, + created_at=now, + ) + session.add(policy_set) + session.flush() + event_hash = "7" * 64 + event = PublicPolicyStateEventModel( + event_id="policy-event-future", + event_type="promoted", + generation=1, + from_set_id=None, + to_set_id=policy_set.set_id, + expected_generation=0, + bundle_sha256=policy_set.bundle_sha256, + authorization_sha256="8" * 64, + trace_id="trace-future-defense", + previous_event_hash=None, + event_hash=event_hash, + idempotency_key="future-defense", + created_at=now, + ) + session.add(event) + session.flush() + state = session.get(PublicPolicyStateModel, "global") + assert state is not None + state.generation = 1 + state.current_set_id = policy_set.set_id + state.current_event_id = event.event_id + state.state_hash = sha256_bytes(canonical_json({ + "generation": 1, + "set_id": policy_set.set_id, + "event_hash": event_hash, + })) + + assert repository.current_catalog() is None + assert repository.current_agreement_versions() == [] + + +def test_reconsent_flag_does_not_remove_registration_agreements(tmp_path: Path, monkeypatch) -> None: + repository, _ = _repository(tmp_path) + policy_dir = tmp_path / "candidate" + policy_dir.mkdir() + effective_at = "2026-08-11T00:00:00+08:00" + approval_file, keyring_file, documents = _evidence( + policy_dir, effective_at=effective_at, reconsent="not_required" + ) + _mock_verifier(monkeypatch, version="2026-08-12", effective_at=effective_at, documents=documents) + preflight = _preflight(repository, policy_dir, approval_file, keyring_file, datetime.now(UTC)) + _promote( + repository, preflight, policy_dir, approval_file, keyring_file, + key="no-existing-reconsent", trace="trace-no-reconsent", now=datetime.now(UTC), + ) + + assert len(repository.registration_agreement_versions()) == 4 + assert repository.current_agreement_versions() == [] + + +def test_internal_promotion_rejects_stale_generation(tmp_path: Path, monkeypatch) -> None: + repository, _ = _repository(tmp_path) + policy_dir = tmp_path / "candidate" + policy_dir.mkdir() + effective_at = "2026-08-11T00:00:00+08:00" + approval_file, keyring_file, documents = _evidence(policy_dir, effective_at=effective_at) + _mock_verifier(monkeypatch, version="2026-08-12", effective_at=effective_at, documents=documents) + first = _preflight(repository, policy_dir, approval_file, keyring_file, datetime.now(UTC)) + _promote( + repository, first, policy_dir, approval_file, keyring_file, + key="policy-promotion-first", trace="trace-first", now=datetime.now(UTC), + ) + + with pytest.raises(PublicPolicyError, match="changed after preflight"): + _promote( + repository, first, policy_dir, approval_file, keyring_file, + key="policy-promotion-stale", trace="trace-stale", now=datetime.now(UTC), + ) + + +def test_internal_rollback_is_historical_and_idempotent(tmp_path: Path, monkeypatch) -> None: + repository, factory = _repository(tmp_path) + policy_dir = tmp_path / "candidate" + policy_dir.mkdir() + effective_at = "2026-08-11T00:00:00+08:00" + approval_file, keyring_file, documents = _evidence(policy_dir, effective_at=effective_at) + _mock_verifier(monkeypatch, version="2026-08-12", effective_at=effective_at, documents=documents) + first_preflight = _preflight(repository, policy_dir, approval_file, keyring_file, datetime.now(UTC)) + first = _promote( + repository, first_preflight, policy_dir, approval_file, keyring_file, + key="promotion-v1", trace="trace-v1", now=datetime.now(UTC), + ) + + approval_file, keyring_file, documents = _evidence( + policy_dir, version="2026-08-13", effective_at=effective_at, suffix=" v2" + ) + _mock_verifier(monkeypatch, version="2026-08-13", effective_at=effective_at, documents=documents) + second_preflight = _preflight(repository, policy_dir, approval_file, keyring_file, datetime.now(UTC)) + second = _promote( + repository, second_preflight, policy_dir, approval_file, keyring_file, + key="promotion-v2", trace="trace-v2", now=datetime.now(UTC), + ) + + rolled_back = repository.rollback_verified_set( + expected_state=second, + target_set_id=first.current_set_id or "", + authorization_sha256="b" * 64, + idempotency_key="rollback-v1", + trace_id="trace-rollback", + now=datetime.now(UTC), + ) + repeated = repository.rollback_verified_set( + expected_state=second, + target_set_id=first.current_set_id or "", + authorization_sha256="b" * 64, + idempotency_key="rollback-v1", + trace_id="trace-rollback", + now=datetime.now(UTC), + ) + + assert repeated == rolled_back + assert rolled_back.generation == 3 + assert rolled_back.current_set_id == first.current_set_id + with factory() as session: + events = session.scalars(select(PublicPolicyStateEventModel).order_by( + PublicPolicyStateEventModel.generation + )).all() + assert [event.event_type for event in events] == ["promoted", "promoted", "rolled_back"] + assert events[-1].trace_id == "trace-rollback" + + +def test_internal_operations_require_lowercase_hash_and_trace(tmp_path: Path, monkeypatch) -> None: + repository, _ = _repository(tmp_path) + policy_dir = tmp_path / "candidate" + policy_dir.mkdir() + effective_at = "2026-08-11T00:00:00+08:00" + approval_file, keyring_file, documents = _evidence(policy_dir, effective_at=effective_at) + _mock_verifier(monkeypatch, version="2026-08-12", effective_at=effective_at, documents=documents) + preflight = _preflight(repository, policy_dir, approval_file, keyring_file, datetime.now(UTC)) + + with pytest.raises(PublicPolicyError, match="lowercase"): + repository.promote_verified_bundle( + preflight=preflight, + policy_dir=policy_dir, + approval_file=approval_file, + keyring_file=keyring_file, + release_digest=RELEASE_DIGEST, + authorization_sha256="A" * 64, + idempotency_key="invalid-hash", + trace_id="trace-invalid", + now=datetime.now(UTC), + ) + with pytest.raises(PublicPolicyError, match="trace_id"): + _promote( + repository, preflight, policy_dir, approval_file, keyring_file, + key="missing-trace", trace=" ", now=datetime.now(UTC), + ) + + +def test_state_pointer_integrity_failure_is_closed(tmp_path: Path) -> None: + repository, factory = _repository(tmp_path) + with factory.begin() as session: + state = session.get(PublicPolicyStateModel, "global") + assert state is not None + state.generation = 1 + state.current_set_id = "missing-set" + state.current_event_id = "missing-event" + state.state_hash = "0" * 64 + + with pytest.raises(PublicPolicyError, match="integrity validation"): + repository.state() + + +def test_rollback_rejects_missing_and_current_targets(tmp_path: Path, monkeypatch) -> None: + repository, _ = _repository(tmp_path) + policy_dir = tmp_path / "candidate" + policy_dir.mkdir() + effective_at = "2026-08-11T00:00:00+08:00" + approval_file, keyring_file, documents = _evidence(policy_dir, effective_at=effective_at) + _mock_verifier(monkeypatch, version="2026-08-12", effective_at=effective_at, documents=documents) + preflight = _preflight(repository, policy_dir, approval_file, keyring_file, datetime.now(UTC)) + current = _promote( + repository, preflight, policy_dir, approval_file, keyring_file, + key="promotion-current", trace="trace-current", now=datetime.now(UTC), + ) + + with pytest.raises(PublicPolicyError, match="unavailable"): + repository.rollback_verified_set( + expected_state=current, + target_set_id="policy-set-missing", + authorization_sha256="b" * 64, + idempotency_key="rollback-missing", + trace_id="trace-missing", + now=datetime.now(UTC), + ) + with pytest.raises(PublicPolicyError, match="must differ"): + repository.rollback_verified_set( + expected_state=current, + target_set_id=current.current_set_id or "", + authorization_sha256="c" * 64, + idempotency_key="rollback-current", + trace_id="trace-current-target", + now=datetime.now(UTC), + ) diff --git a/yuqei-infra/docker/contract-api.Dockerfile b/yuqei-infra/docker/contract-api.Dockerfile index fbab9410..eeb44df2 100644 --- a/yuqei-infra/docker/contract-api.Dockerfile +++ b/yuqei-infra/docker/contract-api.Dockerfile @@ -10,10 +10,11 @@ ARG PIP_INDEX_URL=https://pypi.org/simple COPY yuqei-contract-platform/services/contract-api/pyproject.toml /tmp/contract-api-pyproject.toml COPY yuqei-ai-sdk-python/pyproject.toml /tmp/yuqei-ai-sdk-python-pyproject.toml -RUN pip install --no-cache-dir --index-url "$PIP_INDEX_URL" alembic fastapi httpx pydantic pydantic-settings "psycopg[binary]" python-docx reportlab sqlalchemy uvicorn +RUN pip install --no-cache-dir --index-url "$PIP_INDEX_URL" alembic cryptography fastapi httpx pydantic pydantic-settings "psycopg[binary]" python-docx reportlab sqlalchemy uvicorn COPY yuqei-contract-platform/services/contract-api yuqei-contract-platform/services/contract-api COPY yuqei-ai-sdk-python yuqei-ai-sdk-python +COPY yuqei-infra/scripts/prepare-policy-legal-approval.py yuqei-infra/scripts/prepare-policy-legal-approval.py EXPOSE 8100 CMD ["sh", "-c", "python -m uvicorn yuqei_contract_api.main:app --host 0.0.0.0 --port 8100 --workers ${CONTRACT_API_UVICORN_WORKERS:-2} --no-access-log"] diff --git a/yuqei-infra/scripts/prepare-policy-legal-approval.py b/yuqei-infra/scripts/prepare-policy-legal-approval.py index 605ed760..f1c04a02 100644 --- a/yuqei-infra/scripts/prepare-policy-legal-approval.py +++ b/yuqei-infra/scripts/prepare-policy-legal-approval.py @@ -157,8 +157,8 @@ def load_policy_material(policy_dir: Path) -> tuple[dict[str, Any], bytes, list[ "privacy_contact_email", ): _required_text(publisher, field) - if manifest.get("existing_user_reconsent") != "required_on_next_login": - raise PolicyApprovalError("existing users must re-consent on next login") + if manifest.get("existing_user_reconsent") not in {"required_on_next_login", "not_required"}: + raise PolicyApprovalError("existing_user_reconsent is invalid") if manifest.get("approval_window_days") != 7: raise PolicyApprovalError("policy approval window must be exactly seven days") @@ -411,11 +411,51 @@ def verify_approval( if checked < approved or checked > expires: raise PolicyApprovalError("policy approval is not valid at checked_at") - manifest, _, documents = load_policy_material(policy_dir) + manifest, manifest_raw, documents = load_policy_material(policy_dir) if documents != payload.get("documents"): raise PolicyApprovalError("policy document bytes do not match the signed payload") - if manifest.get("version") != payload.get("policy_version") or manifest.get("effective_at") != payload.get("effective_at"): - raise PolicyApprovalError("policy manifest version binding does not match the signed payload") + manifest_bindings = { + "policy_version": manifest.get("version"), + "effective_at": manifest.get("effective_at"), + "publisher": manifest.get("publisher"), + "governing_law": manifest.get("governing_law"), + "dispute_resolution": manifest.get("dispute_resolution"), + "existing_user_reconsent": manifest.get("existing_user_reconsent"), + } + if any(payload.get(field) != value for field, value in manifest_bindings.items()): + raise PolicyApprovalError("policy manifest legal terms do not match the signed payload") + request = payload.get("approval_request") + if not isinstance(request, dict): + raise PolicyApprovalError("policy approval_request is invalid") + expected_review_package = { + "schema_version": PACKAGE_SCHEMA, + "status": "pending_named_legal_review", + "prepared_at": _required_text(payload, "prepared_at"), + "policy_dir_name": policy_dir.resolve().name, + "policy_version": manifest["version"], + "effective_at": manifest["effective_at"], + "manifest_sha256": sha256_bytes(manifest_raw), + "documents": documents, + "publisher": manifest["publisher"], + "governing_law": manifest["governing_law"], + "dispute_resolution": manifest["dispute_resolution"], + "existing_user_reconsent": manifest["existing_user_reconsent"], + "approval_window_days": manifest["approval_window_days"], + "approver": { + field: request.get(field) + for field in ("approver_id", "reviewer", "role", "organization") + }, + "key": { + "key_id": request.get("key_id"), + "algorithm": request.get("algorithm"), + "public_key_file_sha256": request.get("public_key_file_sha256"), + "public_key_raw_sha256": request.get("public_key_raw_sha256"), + "not_before": request.get("key_not_before"), + "not_after": request.get("key_not_after"), + }, + } + if payload.get("review_package_sha256") != sha256_bytes(canonical_json(expected_review_package)): + raise PolicyApprovalError("policy review package does not match the signed payload") if keyring.get("schema_version") != KEYRING_SCHEMA: raise PolicyApprovalError("policy keyring schema_version is invalid") @@ -426,9 +466,6 @@ def verify_approval( keys = keyring.get("keys") if not isinstance(keys, list): raise PolicyApprovalError("policy keyring keys are invalid") - request = payload.get("approval_request") - if not isinstance(request, dict): - raise PolicyApprovalError("policy approval_request is invalid") matches = [item for item in keys if isinstance(item, dict) and item.get("key_id") == request.get("key_id")] if len(matches) != 1: raise PolicyApprovalError("policy signing key must resolve uniquely") diff --git a/yuqei-infra/tests/test_policy_legal_signing_helper.py b/yuqei-infra/tests/test_policy_legal_signing_helper.py index 1e46e2b9..33d16a86 100644 --- a/yuqei-infra/tests/test_policy_legal_signing_helper.py +++ b/yuqei-infra/tests/test_policy_legal_signing_helper.py @@ -178,6 +178,40 @@ def test_verify_rejects_policy_byte_tampering(tmp_path: Path) -> None: ) +def test_verify_rejects_unsigned_manifest_legal_semantic_tampering(tmp_path: Path) -> None: + policy_dir = tmp_path / "policies" + shutil.copytree(POLICY_SOURCE, policy_dir) + _package, pending, keyring, private, _public, password = prepare(tmp_path, policy_dir=policy_dir) + final_payload = policy.finalize_payload( + pending, + approved_at="2026-08-12T13:00:00+08:00", + expires_at="2026-08-19T13:00:00+08:00", + ) + approval = policy.sign_payload( + final_payload, + private_key_file=private, + password_file=password, + confirmation=policy.SIGN_CONFIRMATION, + ) + manifest_path = policy_dir / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["existing_user_reconsent"] = "not_required" + manifest_path.write_text( + json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + newline="\n", + ) + + with pytest.raises(policy.PolicyApprovalError, match="manifest legal terms"): + policy.verify_approval( + approval, + keyring=keyring, + policy_dir=policy_dir, + checked_at="2026-08-12T14:00:00+08:00", + allow_candidate_keyring=True, + ) + + def test_sign_rejects_a_different_private_key(tmp_path: Path) -> None: _package, pending, _keyring, _private, _public, _password = prepare(tmp_path) other_private, _other_public, other_password = make_key_files(tmp_path, stem="other")