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

This commit is contained in:
2026-08-12 21:28:40 +08:00
parent 73ea037bef
commit c69e0633c5
45 changed files with 3091 additions and 53 deletions
@@ -103,4 +103,4 @@ jobs:
python -m alembic upgrade head python -m alembic upgrade head
python -m alembic downgrade 20260710_0030 python -m alembic downgrade 20260710_0030
python -m alembic upgrade head 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", "start": "next start -p 3000",
"test:auth-entry-routing": "node --no-warnings --experimental-strip-types --test src/lib/auth-entry-routing.test.mjs", "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: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: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", "test:api-base-url": "node --no-warnings --experimental-strip-types --test src/lib/api-base-url.test.mjs",
"typecheck": "tsc --noEmit" "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 { 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 ( return (
<LegalPolicyPage <LegalPolicyPage
title="AI 使用说明" title={policy?.title ?? "AI 使用说明"}
policy={policy}
unavailable={lookup.status === "unavailable"}
retryPath="/ai-disclaimer"
summary="说明智能审查、法律问答和自动建议的适用范围与人工复核要求。" summary="说明智能审查、法律问答和自动建议的适用范围与人工复核要求。"
sections={[ sections={[
{ {
@@ -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 },
);
}
}
@@ -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; 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 { .button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1,9 +1,15 @@
import { LegalPolicyPage, PolicyParagraph } from "@/components/legal-policy-page"; 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 ( return (
<LegalPolicyPage <LegalPolicyPage
title="隐私政策" title={policy?.title ?? "隐私政策"}
policy={policy}
unavailable={lookup.status === "unavailable"}
retryPath="/privacy"
summary="说明账号、合同材料和运行数据的处理目的与用户权利。" summary="说明账号、合同材料和运行数据的处理目的与用户权利。"
sections={[ sections={[
{ {
@@ -1,9 +1,15 @@
import { LegalPolicyPage, PolicyParagraph } from "@/components/legal-policy-page"; 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 ( return (
<LegalPolicyPage <LegalPolicyPage
title="服务协议" title={policy?.title ?? "服务协议"}
policy={policy}
unavailable={lookup.status === "unavailable"}
retryPath="/service-agreement"
summary="说明试用、订阅、组织服务和交付支持的产品规则。" summary="说明试用、订阅、组织服务和交付支持的产品规则。"
sections={[ sections={[
{ {
@@ -1,9 +1,15 @@
import { LegalPolicyPage, PolicyParagraph } from "@/components/legal-policy-page"; 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 ( return (
<LegalPolicyPage <LegalPolicyPage
title="用户协议" title={policy?.title ?? "用户协议"}
policy={policy}
unavailable={lookup.status === "unavailable"}
retryPath="/terms"
summary="说明钰麒合同账号、工作空间和智能合同能力的基本使用规则。" summary="说明钰麒合同账号、工作空间和智能合同能力的基本使用规则。"
sections={[ sections={[
{ {
@@ -10,16 +10,29 @@ export function LegalPolicyPage({
title, title,
summary, summary,
sections, sections,
policy,
unavailable = false,
retryPath,
}: { }: {
title: string; title: string;
summary: string; summary: string;
sections: PolicySection[]; sections: PolicySection[];
policy?: {
version: string;
effective_at: string;
sha256: string;
content: string;
} | null;
unavailable?: boolean;
retryPath: string;
}) { }) {
return ( return (
<div className="page-layout"> <div className="page-layout">
<div className="page-heading"> <div className="page-heading">
<div> <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> <h1>{title}</h1>
<p className="muted">{summary}</p> <p className="muted">{summary}</p>
</div> </div>
@@ -27,19 +40,37 @@ export function LegalPolicyPage({
</div> </div>
<div className="empty-state empty-state-compact"> <div className="empty-state empty-state-compact">
本页面用于产品开发和内部验收,正式发布前必须完成具名法务审核、版本确认和生效审批。 {unavailable
? "当前无法核验正式政策版本。为避免展示未经确认的内容,本页已暂停显示政策正文。"
: policy
? `正式版本 ${policy.version}。文档指纹 SHA-256:${policy.sha256}`
: "本页面当前展示产品开发与内部验收草案,正式发布前必须完成具名法务审核、版本确认和生效审批。"}
</div> </div>
<div className="list"> {unavailable ? (
{sections.map((section) => ( <div className="empty-state empty-state-error" role="alert">
<section className="panel" key={section.title}> <span>正式政策服务暂时不可用,请稍后重新核验。</span>
<div className="panel-header"> <Link className="button button-soft" href={retryPath}>重新加载</Link>
<h2 className="panel-title">{section.title}</h2> </div>
</div> ) : policy ? (
<div className="panel-body list">{section.content}</div> <section className="panel">
</section> <div className="panel-header">
))} <h2 className="panel-title">正式政策文本</h2>
</div> </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> </div>
); );
} }
@@ -47,3 +78,8 @@ export function LegalPolicyPage({
export function PolicyParagraph({ children }: { children: ReactNode }) { export function PolicyParagraph({ children }: { children: ReactNode }) {
return <p className="muted">{children}</p>; 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"; export const CONTRACT_SESSION_COOKIE = "yuqei_contract_session";
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; 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 = { export type ContractWebSession = {
tenant_id: string; tenant_id: string;
tenant_name: string; tenant_name: string;
@@ -14,6 +23,8 @@ export type ContractWebSession = {
role: string; role: string;
platform_permissions?: string[]; platform_permissions?: string[];
email_verified_at?: string | null; email_verified_at?: string | null;
agreement_consent_required?: boolean;
required_agreements?: RequiredAgreement[];
}; };
export async function getContractWebSession(): Promise<ContractWebSession | null> { 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") ? parsed.platform_permissions.filter((item): item is string => typeof item === "string")
: undefined, : undefined,
email_verified_at: typeof parsed.email_verified_at === "string" ? parsed.email_verified_at : null, 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 { } catch {
return null; 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) { export async function setContractWebSession(session: ContractWebSession) {
const cookieStore = await cookies(); const cookieStore = await cookies();
cookieStore.set(CONTRACT_SESSION_COOKIE, encodeSession(session), { cookieStore.set(CONTRACT_SESSION_COOKIE, encodeSession(session), {
@@ -30,15 +30,42 @@ test("session mapping carries the global email verification timestamp", () => {
role: "business", role: "business",
platform_permissions: ["platform_admin"], platform_permissions: ["platform_admin"],
email_verified_at: "2026-08-02T03:00:00Z", email_verified_at: "2026-08-02T03:00:00Z",
agreement_consent_required: false,
required_agreements: [],
}); });
}); });
test("legacy identity sessions remain valid and explicitly unverified", () => { test("legacy identity sessions remain valid and explicitly unverified", () => {
assert.equal(isIdentitySessionPayload(basePayload), true); assert.equal(isIdentitySessionPayload(basePayload), true);
assert.equal(toContractWebSession(basePayload).email_verified_at, null); 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); 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", () => { test("legacy identity sessions keep platform permissions absent", () => {
const legacyPayload = { const legacyPayload = {
...basePayload, ...basePayload,
@@ -1,4 +1,4 @@
import type { ContractWebSession } from "@/lib/contract-session"; import type { ContractWebSession, RequiredAgreement } from "@/lib/contract-session";
export type IdentitySessionPayload = { export type IdentitySessionPayload = {
tenant: { tenant: {
@@ -16,6 +16,8 @@ export type IdentitySessionPayload = {
global_account?: { global_account?: {
email_verified_at?: string | null; email_verified_at?: string | null;
} | null; } | null;
agreement_consent_required?: boolean;
required_agreements?: RequiredAgreement[];
}; };
export function isIdentitySessionPayload(value: unknown): value is IdentitySessionPayload { 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") ? payload.user.platform_permissions.filter((item): item is string => typeof item === "string")
: undefined, : undefined,
email_verified_at: typeof emailVerifiedAt === "string" ? emailVerifiedAt : null, 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 { NextResponse, type NextRequest } from "next/server";
import { canAccessAdminPath, normalizeUserRole } from "@/lib/access-control"; import { canAccessAdminPath, normalizeUserRole } from "@/lib/access-control";
import { authenticatedEntryRedirect } from "@/lib/auth-entry-routing"; import { authenticatedEntryRedirect } from "@/lib/auth-entry-routing";
import { agreementConsentRedirect } from "@/lib/agreement-consent-routing";
import { CONTRACT_SESSION_COOKIE, readContractWebSession } from "@/lib/contract-session"; import { CONTRACT_SESSION_COOKIE, readContractWebSession } from "@/lib/contract-session";
const publicPaths = [ const publicPaths = [
@@ -17,6 +18,10 @@ const publicPaths = [
export function proxy(request: NextRequest) { export function proxy(request: NextRequest) {
const pathname = request.nextUrl.pathname; const pathname = request.nextUrl.pathname;
const session = readContractWebSession(request.cookies.get(CONTRACT_SESSION_COOKIE)?.value); 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)); const entryRedirect = authenticatedEntryRedirect(pathname, Boolean(session));
if (entryRedirect) { if (entryRedirect) {
return NextResponse.redirect(new URL(entryRedirect, request.url)); return NextResponse.redirect(new URL(entryRedirect, request.url));
@@ -1222,6 +1222,127 @@ paths:
description: File not found description: File not found
"409": "409":
description: Stored file path is invalid 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: components:
parameters: parameters:
TraceIdHeader: TraceIdHeader:
@@ -1256,6 +1377,238 @@ components:
maximum: 100 maximum: 100
default: 20 default: 20
schemas: 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: HealthResponse:
type: object type: object
required: required:
@@ -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" requires-python = ">=3.12"
dependencies = [ dependencies = [
"alembic>=1.16", "alembic>=1.16",
"cryptography>=44",
"fastapi>=0.115", "fastapi>=0.115",
"httpx>=0.28", "httpx>=0.28",
"pydantic>=2.10", "pydantic>=2.10",
@@ -24,6 +24,8 @@ from yuqei_contract_api.identity import (
AccountSettings, AccountSettings,
AccountSettingsUpdateRequest, AccountSettingsUpdateRequest,
AgreementConsentPage, AgreementConsentPage,
AgreementRequiredStatus,
AgreementReconsentRequest,
AgreementVersionPage, AgreementVersionPage,
EmailChallengeItem, EmailChallengeItem,
EmailVerificationConfirmRequest, EmailVerificationConfirmRequest,
@@ -97,6 +99,18 @@ def register_identity_routes(
email_delivery = build_identity_email_delivery(settings) email_delivery = build_identity_email_delivery(settings)
logger = get_logger("yuqei_contract_api.identity") 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( @app.get(
f"{settings.api_prefix}/identity/capabilities", f"{settings.api_prefix}/identity/capabilities",
response_model=IdentityCapabilities, response_model=IdentityCapabilities,
@@ -120,7 +134,37 @@ def register_identity_routes(
tags=["identity"], tags=["identity"],
) )
def get_current_registration_agreements() -> AgreementVersionPage: 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( @app.get(
f"{settings.api_prefix}/identity/tenants", f"{settings.api_prefix}/identity/tenants",
@@ -495,11 +539,12 @@ def register_identity_routes(
def register_company(request: TenantRegistrationRequest, http_request: Request) -> IdentitySession: def register_company(request: TenantRegistrationRequest, http_request: Request) -> IdentitySession:
_require_self_service_or_platform_admin(settings, identity_repository) _require_self_service_or_platform_admin(settings, identity_repository)
try: try:
return identity_repository.register_tenant_admin( session = identity_repository.register_tenant_admin(
request, request,
client_ip=_identity_client_ip(http_request, settings), client_ip=_identity_client_ip(http_request, settings),
user_agent=_registration_user_agent(http_request), user_agent=_registration_user_agent(http_request),
) )
return _with_agreement_status(session, identity_repository)
except IdentityConflictError as exc: except IdentityConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValueError as exc: except ValueError as exc:
@@ -513,11 +558,12 @@ def register_identity_routes(
def register_personal(request: PersonalRegistrationRequest, http_request: Request) -> IdentitySession: def register_personal(request: PersonalRegistrationRequest, http_request: Request) -> IdentitySession:
_require_self_service_or_platform_admin(settings, identity_repository) _require_self_service_or_platform_admin(settings, identity_repository)
try: try:
return identity_repository.register_personal_workspace( session = identity_repository.register_personal_workspace(
request, request,
client_ip=_identity_client_ip(http_request, settings), client_ip=_identity_client_ip(http_request, settings),
user_agent=_registration_user_agent(http_request), user_agent=_registration_user_agent(http_request),
) )
return _with_agreement_status(session, identity_repository)
except IdentityConflictError as exc: except IdentityConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValueError as exc: except ValueError as exc:
@@ -572,7 +618,7 @@ def register_identity_routes(
email=request.email, email=request.email,
secret=_identity_rate_limit_secret(settings), secret=_identity_rate_limit_secret(settings),
) )
return session return _with_agreement_status(session, identity_repository)
except IdentityRateLimitExceeded as exc: except IdentityRateLimitExceeded as exc:
raise _rate_limit_http_exception(exc) from exc raise _rate_limit_http_exception(exc) from exc
except (IdentityConflictError, ValueError) as exc: except (IdentityConflictError, ValueError) as exc:
@@ -599,11 +645,12 @@ def register_identity_routes(
) )
def switch_workspace(request: WorkspaceSwitchRequest) -> IdentitySession: def switch_workspace(request: WorkspaceSwitchRequest) -> IdentitySession:
try: try:
return identity_repository.switch_workspace( session = identity_repository.switch_workspace(
tenant_id=_current_tenant_id(), tenant_id=_current_tenant_id(),
user_id=_current_user_id(), user_id=_current_user_id(),
target_tenant_id=request.tenant_id, target_tenant_id=request.tenant_id,
) )
return _with_agreement_status(session, identity_repository)
except (PermissionError, ValueError) as exc: except (PermissionError, ValueError) as exc:
raise HTTPException(status_code=403, detail="Workspace access is not allowed.") from 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: def accept_invitation(request: TenantInvitationAcceptRequest) -> IdentitySession:
_require_invitation_delivery(settings) _require_invitation_delivery(settings)
try: try:
return identity_repository.accept_invitation(request) return _with_agreement_status(identity_repository.accept_invitation(request), identity_repository)
except IdentityConflictError as exc: except IdentityConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValueError as exc: except ValueError as exc:
@@ -830,7 +877,7 @@ def register_identity_routes(
_require_self_service(settings) _require_self_service(settings)
_require_email_delivery(settings) _require_email_delivery(settings)
try: try:
return identity_repository.confirm_password_reset(request) return _with_agreement_status(identity_repository.confirm_password_reset(request), identity_repository)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail="Verification code is invalid or expired.") from 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" 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: def _is_platform_admin(identity_repository) -> bool:
return identity_repository.has_platform_permission( return identity_repository.has_platform_permission(
tenant_id=_current_tenant_id(), tenant_id=_current_tenant_id(),
@@ -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_s4_records_daily_limit: int = 1000
anti_abuse_files_daily_limit: int = 50 anti_abuse_files_daily_limit: int = 50
anti_abuse_bytes_daily_limit: int = 500 * 1024 * 1024 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( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
@@ -40,7 +40,7 @@ SsoProvider = Literal["saml", "oidc"]
AgreementType = Literal["user_terms", "privacy_policy", "service_agreement", "ai_use_notice"] AgreementType = Literal["user_terms", "privacy_policy", "service_agreement", "ai_use_notice"]
AgreementLifecycleStatus = Literal["draft", "effective", "retired"] AgreementLifecycleStatus = Literal["draft", "effective", "retired"]
RegistrationKind = Literal["personal", "organization"] RegistrationKind = Literal["personal", "organization"]
AgreementConsentSource = Literal["personal_registration", "organization_registration"] AgreementConsentSource = Literal["personal_registration", "organization_registration", "policy_reconsent"]
GlobalAccountStatus = Literal["active", "disabled", "archived"] GlobalAccountStatus = Literal["active", "disabled", "archived"]
WorkspaceMembershipStatus = Literal["active", "invited", "disabled"] WorkspaceMembershipStatus = Literal["active", "invited", "disabled"]
AccountConflictSeverity = Literal["critical", "warning"] AccountConflictSeverity = Literal["critical", "warning"]
@@ -126,6 +126,16 @@ class AgreementConsentPage(BaseModel):
total: int total: int
class AgreementRequiredStatus(BaseModel):
required: bool
items: list[AgreementVersionInfo]
total: int
class AgreementReconsentRequest(BaseModel):
items: list[AgreementAcceptanceRequest]
class GlobalAccountInfo(BaseModel): class GlobalAccountInfo(BaseModel):
id: str id: str
email: str email: str
@@ -897,6 +907,8 @@ class IdentitySession(BaseModel):
auth_mode: Literal["password", "legacy_email_session", "invitation", "workspace_switch"] = "password" auth_mode: Literal["password", "legacy_email_session", "invitation", "workspace_switch"] = "password"
global_account: GlobalAccountInfo | None = None global_account: GlobalAccountInfo | None = None
membership: WorkspaceMembershipInfo | None = None membership: WorkspaceMembershipInfo | None = None
agreement_consent_required: bool = False
required_agreements: list[AgreementVersionInfo] = Field(default_factory=list)
class TenantInvitationCreateRequest(BaseModel): class TenantInvitationCreateRequest(BaseModel):
@@ -1056,8 +1068,8 @@ _CURRENT_REGISTRATION_AGREEMENTS: tuple[AgreementVersionInfo, ...] = (
) )
def current_registration_agreements() -> AgreementVersionPage: def current_registration_agreements(active_items: list[AgreementVersionInfo] | None = None) -> AgreementVersionPage:
items = [item.model_copy(deep=True) for item in _CURRENT_REGISTRATION_AGREEMENTS] items = [item.model_copy(deep=True) for item in (active_items or _CURRENT_REGISTRATION_AGREEMENTS)]
return AgreementVersionPage(items=items, total=len(items)) return AgreementVersionPage(items=items, total=len(items))
@@ -1065,10 +1077,11 @@ def validate_registration_agreements(
acceptances: list[AgreementAcceptanceRequest], acceptances: list[AgreementAcceptanceRequest],
*, *,
registration_kind: RegistrationKind, registration_kind: RegistrationKind,
current_items: list[AgreementVersionInfo] | None = None,
) -> list[AgreementVersionInfo]: ) -> list[AgreementVersionInfo]:
required = [ required = [
item item
for item in _CURRENT_REGISTRATION_AGREEMENTS for item in (current_items or _CURRENT_REGISTRATION_AGREEMENTS)
if registration_kind in item.required_for_registration if registration_kind in item.required_for_registration
] ]
expected = {(item.agreement_type, item.version) for item in required} 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) _request_tenant_id.set(tenant_id)
def clear_request_tenant_id() -> None:
_request_tenant_id.set(None)
def get_request_tenant_id() -> str | None: def get_request_tenant_id() -> str | None:
return _request_tenant_id.get() 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.identity import register_identity_routes
from yuqei_contract_api.api.legal_assets import register_legal_asset_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.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.security import register_security_routes
from yuqei_contract_api.api.support import register_support_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.system import register_system_routes
from yuqei_contract_api.api.templates import register_template_routes from yuqei_contract_api.api.templates import register_template_routes
from yuqei_contract_api.config import ContractApiSettings, get_settings from yuqei_contract_api.config import ContractApiSettings, get_settings
from yuqei_contract_api.logging import ( from yuqei_contract_api.logging import (
clear_request_tenant_id,
configure_logging, configure_logging,
get_logger, get_logger,
set_request_tenant_id, 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_base import ContractRepository
from yuqei_contract_api.repository_factory import build_contract_repository 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 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 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): if not is_valid_proxy_key(request.headers.get(PROXY_HEADER), resolved_settings.web_proxy_shared_secret):
return JSONResponse( response = JSONResponse(
status_code=401, status_code=401,
content={"detail": "Authenticated web proxy access is required."}, 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" tenant_id = request.headers.get("X-Tenant-Id") or "default"
user_id = request.headers.get("X-User-Id") user_id = request.headers.get("X-User-Id")
set_tenant_context(tenant_id) set_tenant_context(tenant_id)
set_user_context(user_id) set_user_context(user_id)
set_request_tenant_id(tenant_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) 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) estimated_records_count = estimate_records_count(request, anti_abuse_decision)
if anti_abuse_decision.decision == "block": if anti_abuse_decision.decision == "block":
@@ -122,7 +181,12 @@ def create_app(
) )
if anti_abuse_guard.events: if anti_abuse_guard.events:
persist_security_event(getattr(repository, "engine", None), anti_abuse_guard.events[-1]) 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( anti_abuse_decision = check_egress_budget(
getattr(repository, "engine", None), getattr(repository, "engine", None),
anti_abuse_decision, anti_abuse_decision,
@@ -146,7 +210,12 @@ def create_app(
anti_abuse_guard.events[-1], anti_abuse_guard.events[-1],
records_count=0, 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() start_time = time.time()
endpoint = request.url.path endpoint = request.url.path
@@ -230,9 +299,11 @@ def create_app(
invalidate_read_cache() invalidate_read_cache()
clear_tenant_context() clear_tenant_context()
clear_user_context() clear_user_context()
clear_request_tenant_id()
set_trace_id(None) set_trace_id(None)
register_system_routes(app, settings=resolved_settings, repository=repository) 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_identity_routes(app, settings=resolved_settings, repository=repository)
register_security_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) register_support_routes(app, settings=resolved_settings, repository=repository)
@@ -246,3 +317,23 @@ def create_app(
app = 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
@@ -3,7 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from itertools import islice from itertools import islice
from typing import Literal from typing import Callable, Literal
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import and_, delete, func, inspect, or_, select, text from sqlalchemy import and_, delete, func, inspect, or_, select, text
@@ -29,6 +29,9 @@ from yuqei_contract_api.identity import (
AccountSettingsUpdateRequest, AccountSettingsUpdateRequest,
AgreementConsentItem, AgreementConsentItem,
AgreementConsentPage, AgreementConsentPage,
AgreementRequiredStatus,
AgreementReconsentRequest,
AgreementVersionInfo,
EmailChallengeItem, EmailChallengeItem,
EmailChallengePurpose, EmailChallengePurpose,
EmailVerificationConfirmRequest, EmailVerificationConfirmRequest,
@@ -104,8 +107,15 @@ from yuqei_contract_api.persistence.privileged_support_access import (
class IdentityRepository: 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._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.privileged_operations = PrivilegedIdentityOperationRepository(session_factory)
self.support_access_policies = PrivilegedSupportAccessPolicyRepository( self.support_access_policies = PrivilegedSupportAccessPolicyRepository(
session_factory, session_factory,
@@ -659,6 +669,7 @@ class IdentityRepository:
agreements = validate_registration_agreements( agreements = validate_registration_agreements(
request.agreement_acceptances, request.agreement_acceptances,
registration_kind="organization", registration_kind="organization",
current_items=self._current_agreements() or None,
) )
now = datetime.now(UTC) now = datetime.now(UTC)
tenant_id = _normalize_id(request.tenant_id or f"tenant-{uuid4().hex[:12]}") tenant_id = _normalize_id(request.tenant_id or f"tenant-{uuid4().hex[:12]}")
@@ -779,6 +790,7 @@ class IdentityRepository:
agreements = validate_registration_agreements( agreements = validate_registration_agreements(
request.agreement_acceptances, request.agreement_acceptances,
registration_kind="personal", registration_kind="personal",
current_items=self._current_agreements() or None,
) )
now = datetime.now(UTC) now = datetime.now(UTC)
tenant_id = _normalize_id(request.workspace_id or f"personal-{uuid4().hex[:12]}") tenant_id = _normalize_id(request.workspace_id or f"personal-{uuid4().hex[:12]}")
@@ -925,6 +937,67 @@ class IdentityRepository:
total=len(models), 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( def has_platform_permission(
self, self,
*, *,
@@ -4,6 +4,7 @@ from datetime import date, datetime
from sqlalchemy import ( from sqlalchemy import (
Boolean, Boolean,
LargeBinary,
CheckConstraint, CheckConstraint,
Date, Date,
DateTime, DateTime,
@@ -26,6 +27,98 @@ class Base(DeclarativeBase):
pass 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: class GovernanceMixin:
created_by: Mapped[str | None] = mapped_column(String(128), nullable=True) created_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
updated_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" 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: 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: def is_public_path(path: str, api_prefix: str) -> bool:
"""Keep liveness endpoints reachable by Docker and load balancers.""" """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}
@@ -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" VERSION_TABLE = "contract_api_alembic_version"
REQUIRED_POSTGRES_EXTENSIONS = ("pg_trgm",) REQUIRED_POSTGRES_EXTENSIONS = ("pg_trgm",)
CORE_TABLES = ( CORE_TABLES = (
"public_policy_sets",
"public_policy_documents",
"public_policy_state",
"public_policy_state_events",
"tenants", "tenants",
"tenant_users", "tenant_users",
"account_settings", "account_settings",
@@ -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.review_tasks import ReviewSourceDocument, ReviewTask, ReviewTaskRequest
from yuqei_contract_api.persistence.search import SearchResultPage, SearchResultType, SearchSort from yuqei_contract_api.persistence.search import SearchResultPage, SearchResultType, SearchSort
from yuqei_contract_api.repository_base import ContractRepository, ContractWorkflowError 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.review_report import ReviewReportGenerateRequest, ReviewReportStatus
from yuqei_contract_api.sqlalchemy_approval_orchestration import ( from yuqei_contract_api.sqlalchemy_approval_orchestration import (
SqlAlchemyApprovalOrchestration as _SqlAlchemyApprovalOrchestration, SqlAlchemyApprovalOrchestration as _SqlAlchemyApprovalOrchestration,
@@ -170,6 +171,7 @@ class SqlAlchemyContractRepository(ContractRepository):
self._files = components.files self._files = components.files
self._review_tasks = components.review_tasks self._review_tasks = components.review_tasks
self.identity = components.identity self.identity = components.identity
self.public_policies = components.public_policies
self._backup_orchestration = _SqlAlchemyBackupOrchestration( self._backup_orchestration = _SqlAlchemyBackupOrchestration(
session_factory=self._session_factory, session_factory=self._session_factory,
file_storage_dir=components.file_storage_dir, file_storage_dir=components.file_storage_dir,
@@ -251,6 +253,7 @@ class SqlAlchemyContractRepository(ContractRepository):
if auto_create: if auto_create:
Base.metadata.create_all(self.engine) Base.metadata.create_all(self.engine)
install_privileged_identity_operation_event_guards(self.engine) install_privileged_identity_operation_event_guards(self.engine)
install_public_policy_immutability_guards(self.engine)
if auto_create or seed_demo_data: if auto_create or seed_demo_data:
self.identity.seed_defaults() self.identity.seed_defaults()
if seed_demo_data: if seed_demo_data:
@@ -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.search import ContractSearchRepository
from yuqei_contract_api.persistence.support import ContractSupportRepository from yuqei_contract_api.persistence.support import ContractSupportRepository
from yuqei_contract_api.persistence.templates import ContractTemplatePersistence 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_lifecycle_orchestration import SqlAlchemyLifecycleOrchestration
from yuqei_contract_api.sqlalchemy_search_orchestration import SqlAlchemySearchOrchestration from yuqei_contract_api.sqlalchemy_search_orchestration import SqlAlchemySearchOrchestration
@@ -40,6 +41,7 @@ class SqlAlchemyRepositoryComponents:
files: ContractFileRepository files: ContractFileRepository
review_tasks: ContractReviewTaskRepository review_tasks: ContractReviewTaskRepository
identity: IdentityRepository identity: IdentityRepository
public_policies: PublicPolicyRepository
def build_sqlalchemy_repository_components( def build_sqlalchemy_repository_components(
@@ -82,7 +84,12 @@ def build_sqlalchemy_repository_components(
session_factory, session_factory,
read_stored_file=read_stored_file, 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( return SqlAlchemyRepositoryComponents(
engine=engine, engine=engine,
session_factory=session_factory, session_factory=session_factory,
@@ -100,4 +107,5 @@ def build_sqlalchemy_repository_components(
files=files, files=files,
review_tasks=review_tasks, review_tasks=review_tasks,
identity=identity, identity=identity,
public_policies=public_policies,
) )
@@ -166,7 +166,16 @@ def test_privileged_identity_operation_migration_upgrade_and_downgrade(
with engine.connect() as connection: with engine.connect() as connection:
assert connection.execute( assert connection.execute(
text("SELECT version_num FROM contract_api_alembic_version") 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_operations" in inspector.get_table_names()
assert "privileged_identity_operation_approvals" 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() 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: with engine.connect() as connection:
assert connection.execute( assert connection.execute(
text("SELECT version_num FROM contract_api_alembic_version") text("SELECT version_num FROM contract_api_alembic_version")
).scalar_one() == "20260809_0045" ).scalar_one() == "20260812_0046"
engine.dispose() engine.dispose()
finally: finally:
get_settings.cache_clear() get_settings.cache_clear()
@@ -19,10 +19,17 @@ from sqlalchemy.engine import URL, make_url
from sqlalchemy.exc import DBAPIError from sqlalchemy.exc import DBAPIError
from sqlalchemy.orm import Session, sessionmaker 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.logging import set_trace_id
from yuqei_contract_api.persistence.identity import IdentityRepository
from yuqei_contract_api.persistence.models import ( from yuqei_contract_api.persistence.models import (
ContractModel, ContractModel,
GlobalAccountModel, GlobalAccountModel,
IdentityAgreementConsentModel,
PrivilegedIdentityExecutionAttemptEventModel, PrivilegedIdentityExecutionAttemptEventModel,
PrivilegedIdentityExecutionAttemptModel, PrivilegedIdentityExecutionAttemptModel,
PrivilegedIdentityOperationApprovalModel, PrivilegedIdentityOperationApprovalModel,
@@ -182,7 +189,7 @@ def postgres_governance() -> Iterator[PostgresGovernanceHarness]:
with isolated_engine.connect() as connection: with isolated_engine.connect() as connection:
assert connection.scalar( assert connection.scalar(
text("SELECT version_num FROM contract_api_alembic_version") text("SELECT version_num FROM contract_api_alembic_version")
) == "20260809_0045" ) == "20260812_0046"
migrated_tables = set( migrated_tables = set(
connection.execute( connection.execute(
text("SELECT tablename FROM pg_tables WHERE schemaname = :schema_name"), 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_control_events",
"privileged_support_access_request_audits", "privileged_support_access_request_audits",
"privileged_support_access_audit_outbox", "privileged_support_access_audit_outbox",
"public_policy_sets",
"public_policy_documents",
"public_policy_state",
"public_policy_state_events",
} <= migrated_tables } <= migrated_tables
operation_columns = { operation_columns = {
row[0] row[0]
@@ -2086,3 +2097,74 @@ def test_postgresql_support_access_validation_ledger_is_concurrent_and_immutable
with pytest.raises(DBAPIError): with pytest.raises(DBAPIError):
with session_factory.begin() as session: with session_factory.begin() as session:
session.execute(text(f"DELETE FROM {table_name}")) 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") missing = client.get("/api/v1/contracts")
wrong = client.get("/api/v1/contracts", headers={"X-Contract-Proxy-Key": "wrong"}) 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( valid = client.get(
"/api/v1/contracts", "/api/v1/contracts",
headers={ 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 missing.status_code == 401
assert wrong.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 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: def test_production_config_requires_proxy_auth() -> None:
settings = ContractApiSettings( settings = ContractApiSettings(
environment="production", 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),
)
+2 -1
View File
@@ -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-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 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-contract-platform/services/contract-api yuqei-contract-platform/services/contract-api
COPY yuqei-ai-sdk-python yuqei-ai-sdk-python 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 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"] 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", "privacy_contact_email",
): ):
_required_text(publisher, field) _required_text(publisher, field)
if manifest.get("existing_user_reconsent") != "required_on_next_login": if manifest.get("existing_user_reconsent") not in {"required_on_next_login", "not_required"}:
raise PolicyApprovalError("existing users must re-consent on next login") raise PolicyApprovalError("existing_user_reconsent is invalid")
if manifest.get("approval_window_days") != 7: if manifest.get("approval_window_days") != 7:
raise PolicyApprovalError("policy approval window must be exactly seven days") raise PolicyApprovalError("policy approval window must be exactly seven days")
@@ -411,11 +411,51 @@ def verify_approval(
if checked < approved or checked > expires: if checked < approved or checked > expires:
raise PolicyApprovalError("policy approval is not valid at checked_at") 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"): if documents != payload.get("documents"):
raise PolicyApprovalError("policy document bytes do not match the signed payload") 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"): manifest_bindings = {
raise PolicyApprovalError("policy manifest version binding does not match the signed payload") "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: if keyring.get("schema_version") != KEYRING_SCHEMA:
raise PolicyApprovalError("policy keyring schema_version is invalid") raise PolicyApprovalError("policy keyring schema_version is invalid")
@@ -426,9 +466,6 @@ def verify_approval(
keys = keyring.get("keys") keys = keyring.get("keys")
if not isinstance(keys, list): if not isinstance(keys, list):
raise PolicyApprovalError("policy keyring keys are invalid") 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")] matches = [item for item in keys if isinstance(item, dict) and item.get("key_id") == request.get("key_id")]
if len(matches) != 1: if len(matches) != 1:
raise PolicyApprovalError("policy signing key must resolve uniquely") 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: def test_sign_rejects_a_different_private_key(tmp_path: Path) -> None:
_package, pending, _keyring, _private, _public, _password = prepare(tmp_path) _package, pending, _keyring, _private, _public, _password = prepare(tmp_path)
other_private, _other_public, other_password = make_key_files(tmp_path, stem="other") other_private, _other_public, other_password = make_key_files(tmp_path, stem="other")