feat: add verified public policy promotion and re-consent
Build and Test Quality Gate / Contract Web typecheck and build (pull_request) Successful in 4m30s
Build and Test Quality Gate / Python tests and Contract API migration (pull_request) Successful in 18m55s
Security Quality Gate / npm audit, Bandit, pip-audit, detect-secrets (pull_request) Failing after 6m10s
Build and Test Quality Gate / Contract Web typecheck and build (pull_request) Successful in 4m30s
Build and Test Quality Gate / Python tests and Contract API migration (pull_request) Successful in 18m55s
Security Quality Gate / npm audit, Bandit, pip-audit, detect-secrets (pull_request) Failing after 6m10s
This commit is contained in:
@@ -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)"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<RequiredAgreementStatus | null>();
|
||||
const [accepted, setAccepted] = useState<Set<string>>(() => new Set());
|
||||
const [message, setMessage] = useState<string | null>(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<HTMLFormElement>) {
|
||||
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 <div className="page-layout auth-page">正在核对必需协议...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-layout auth-page agreement-review-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Agreement update</p>
|
||||
<h1>确认更新后的服务协议</h1>
|
||||
<p className="muted">继续使用工作空间前,请逐份阅读并确认当前有效版本。</p>
|
||||
</div>
|
||||
<button className="button button-soft" onClick={() => void logout()} type="button">
|
||||
<LogOut size={16} />
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message && <div className="empty-state empty-state-error" role="alert">{message}</div>}
|
||||
|
||||
{status === null ? (
|
||||
<div className="empty-state">
|
||||
<button className="button button-primary" onClick={() => void loadRequiredAgreements()} type="button">
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<section className="panel agreement-review-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Required agreements</p>
|
||||
<h2 className="panel-title">待确认协议({status.total})</h2>
|
||||
</div>
|
||||
<FileCheck2 size={20} />
|
||||
</div>
|
||||
<form className="panel-body agreement-review-form" onSubmit={submit}>
|
||||
{status.items.map((agreement) => (
|
||||
<label className="registration-agreement-item agreement-review-item" key={agreementKey(agreement)}>
|
||||
<input
|
||||
checked={accepted.has(agreementKey(agreement))}
|
||||
onChange={(event) => toggleAgreement(agreement, event.target.checked)}
|
||||
type="checkbox"
|
||||
required
|
||||
/>
|
||||
<span>
|
||||
我已阅读并同意
|
||||
<Link href={agreement.document_path} target="_blank" rel="noreferrer">
|
||||
《{agreement.title}》
|
||||
</Link>
|
||||
<small>
|
||||
版本 {agreement.version}
|
||||
{agreement.effective_at ? ` · 生效时间 ${formatEffectiveAt(agreement.effective_at)}` : ""}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
<button className="button button-primary" disabled={!allAccepted || saving} type="submit">
|
||||
<FileCheck2 size={16} />
|
||||
{saving ? "正在确认..." : "确认并继续"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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 (
|
||||
<LegalPolicyPage
|
||||
title="AI 使用说明"
|
||||
title={policy?.title ?? "AI 使用说明"}
|
||||
policy={policy}
|
||||
unavailable={lookup.status === "unavailable"}
|
||||
retryPath="/ai-disclaimer"
|
||||
summary="说明智能审查、法律问答和自动建议的适用范围与人工复核要求。"
|
||||
sections={[
|
||||
{
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { buildContractApiUrl, buildIdentityProxyHeaders, fetchContractApi } from "@/lib/contract-api";
|
||||
import { getContractWebSession, setContractWebSession } from "@/lib/contract-session";
|
||||
import { parseRequiredAgreementStatus } from "@/lib/required-agreements";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getContractWebSession();
|
||||
if (!session) {
|
||||
return Response.json({ message: "请先登录。" }, { status: 401 });
|
||||
}
|
||||
try {
|
||||
const response = await fetchContractApi(buildContractApiUrl("/api/v1/identity/agreements/consents"), {
|
||||
method: "POST",
|
||||
headers: buildIdentityProxyHeaders(request, { "Content-Type": "application/json" }),
|
||||
body: await request.text(),
|
||||
cache: "no-store",
|
||||
});
|
||||
const payload: unknown = await response.json().catch(() => 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
+30
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<LegalPolicyPage
|
||||
title="隐私政策"
|
||||
title={policy?.title ?? "隐私政策"}
|
||||
policy={policy}
|
||||
unavailable={lookup.status === "unavailable"}
|
||||
retryPath="/privacy"
|
||||
summary="说明账号、合同材料和运行数据的处理目的与用户权利。"
|
||||
sections={[
|
||||
{
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { LegalPolicyPage, PolicyParagraph } from "@/components/legal-policy-page";
|
||||
import { getCurrentPublicPolicy } from "@/lib/public-policies";
|
||||
|
||||
export default function ServiceAgreementPage() {
|
||||
export default async function ServiceAgreementPage() {
|
||||
const lookup = await getCurrentPublicPolicy("service_agreement");
|
||||
const policy = lookup.status === "effective" ? lookup.document : null;
|
||||
return (
|
||||
<LegalPolicyPage
|
||||
title="服务协议"
|
||||
title={policy?.title ?? "服务协议"}
|
||||
policy={policy}
|
||||
unavailable={lookup.status === "unavailable"}
|
||||
retryPath="/service-agreement"
|
||||
summary="说明试用、订阅、组织服务和交付支持的产品规则。"
|
||||
sections={[
|
||||
{
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { LegalPolicyPage, PolicyParagraph } from "@/components/legal-policy-page";
|
||||
import { getCurrentPublicPolicy } from "@/lib/public-policies";
|
||||
|
||||
export default function TermsPage() {
|
||||
export default async function TermsPage() {
|
||||
const lookup = await getCurrentPublicPolicy("user_terms");
|
||||
const policy = lookup.status === "effective" ? lookup.document : null;
|
||||
return (
|
||||
<LegalPolicyPage
|
||||
title="用户协议"
|
||||
title={policy?.title ?? "用户协议"}
|
||||
policy={policy}
|
||||
unavailable={lookup.status === "unavailable"}
|
||||
retryPath="/terms"
|
||||
summary="说明钰麒合同账号、工作空间和智能合同能力的基本使用规则。"
|
||||
sections={[
|
||||
{
|
||||
|
||||
@@ -10,16 +10,29 @@ export function LegalPolicyPage({
|
||||
title,
|
||||
summary,
|
||||
sections,
|
||||
policy,
|
||||
unavailable = false,
|
||||
retryPath,
|
||||
}: {
|
||||
title: string;
|
||||
summary: string;
|
||||
sections: PolicySection[];
|
||||
policy?: {
|
||||
version: string;
|
||||
effective_at: string;
|
||||
sha256: string;
|
||||
content: string;
|
||||
} | null;
|
||||
unavailable?: boolean;
|
||||
retryPath: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="page-layout">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<p className="eyebrow">版本 2026-08-01 / 产品草案</p>
|
||||
<p className="eyebrow">
|
||||
版本 {policy?.version ?? "2026-08-01"} / {policy ? `生效于 ${formatEffectiveAt(policy.effective_at)}` : "产品草案"}
|
||||
</p>
|
||||
<h1>{title}</h1>
|
||||
<p className="muted">{summary}</p>
|
||||
</div>
|
||||
@@ -27,19 +40,37 @@ export function LegalPolicyPage({
|
||||
</div>
|
||||
|
||||
<div className="empty-state empty-state-compact">
|
||||
本页面用于产品开发和内部验收,正式发布前必须完成具名法务审核、版本确认和生效审批。
|
||||
{unavailable
|
||||
? "当前无法核验正式政策版本。为避免展示未经确认的内容,本页已暂停显示政策正文。"
|
||||
: policy
|
||||
? `正式版本 ${policy.version}。文档指纹 SHA-256:${policy.sha256}`
|
||||
: "本页面当前展示产品开发与内部验收草案,正式发布前必须完成具名法务审核、版本确认和生效审批。"}
|
||||
</div>
|
||||
|
||||
<div className="list">
|
||||
{sections.map((section) => (
|
||||
<section className="panel" key={section.title}>
|
||||
<div className="panel-header">
|
||||
<h2 className="panel-title">{section.title}</h2>
|
||||
</div>
|
||||
<div className="panel-body list">{section.content}</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
{unavailable ? (
|
||||
<div className="empty-state empty-state-error" role="alert">
|
||||
<span>正式政策服务暂时不可用,请稍后重新核验。</span>
|
||||
<Link className="button button-soft" href={retryPath}>重新加载</Link>
|
||||
</div>
|
||||
) : policy ? (
|
||||
<section className="panel">
|
||||
<div className="panel-header">
|
||||
<h2 className="panel-title">正式政策文本</h2>
|
||||
</div>
|
||||
<div className="panel-body policy-document-content">{policy.content}</div>
|
||||
</section>
|
||||
) : (
|
||||
<div className="list">
|
||||
{sections.map((section) => (
|
||||
<section className="panel" key={section.title}>
|
||||
<div className="panel-header">
|
||||
<h2 className="panel-title">{section.title}</h2>
|
||||
</div>
|
||||
<div className="panel-body list">{section.content}</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -47,3 +78,8 @@ export function LegalPolicyPage({
|
||||
export function PolicyParagraph({ children }: { children: ReactNode }) {
|
||||
return <p className="muted">{children}</p>;
|
||||
}
|
||||
|
||||
function formatEffectiveAt(value: string) {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}/`));
|
||||
}
|
||||
@@ -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<ContractWebSession | null> {
|
||||
@@ -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<RequiredAgreement>;
|
||||
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), {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<RequiredAgreement>;
|
||||
return typeof agreement.agreement_type === "string"
|
||||
&& typeof agreement.version === "string"
|
||||
&& typeof agreement.title === "string"
|
||||
&& typeof agreement.document_path === "string";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<CurrentPublicPolicyLookup> {
|
||||
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" };
|
||||
}
|
||||
}
|
||||
@@ -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<CurrentPublicPolicy>;
|
||||
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;
|
||||
}
|
||||
@@ -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 }],
|
||||
});
|
||||
});
|
||||
@@ -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<RequiredAgreementStatus>;
|
||||
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<RequiredAgreement>;
|
||||
return typeof agreement.agreement_type === "string"
|
||||
&& typeof agreement.version === "string"
|
||||
&& typeof agreement.title === "string"
|
||||
&& typeof agreement.document_path === "string";
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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:
|
||||
|
||||
+205
@@ -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}()"))
|
||||
@@ -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",
|
||||
|
||||
+61
-7
@@ -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(),
|
||||
|
||||
+66
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+75
-2
@@ -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,
|
||||
*,
|
||||
|
||||
+93
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
+725
@@ -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()"
|
||||
))
|
||||
@@ -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",
|
||||
|
||||
+3
@@ -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:
|
||||
|
||||
+9
-1
@@ -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,
|
||||
)
|
||||
|
||||
+11
-2
@@ -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()
|
||||
|
||||
+83
-1
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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"]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user