feat: harden identity diagnostics and SMTP delivery
Build and Test Quality Gate / Contract Web typecheck and build (pull_request) Successful in 6m12s
Build and Test Quality Gate / Python tests and Contract API migration (pull_request) Failing after 9m2s
Security Quality Gate / npm audit, Bandit, pip-audit, detect-secrets (pull_request) Failing after 10m21s

This commit is contained in:
2026-08-02 17:02:32 +08:00
parent 96e456abcc
commit 69c582892f
21 changed files with 2206 additions and 128 deletions
@@ -449,3 +449,22 @@ legal_name: string | null
4. **P1 平台管理员完整治理与并发验收**:继续补充受控授予/撤销、支持访问原因和时限、双人审批及专用审计。最后有效管理员保护虽已通过本地定向回归,但仍需在真实 PostgreSQL 上执行双请求并发集成验收,不能以 SQLite 或单请求结果替代。
5. **原 9.18 第 5 项已完成并移出阻断**:开发环境工作空间口径、组织创建命令和管理员密码输入语义已修正;已登录访问 `/login` 自动返回工作台,未登录及其他公开身份入口保持原行为。Web 路由 `4/4`、工作空间状态 `3/3`、身份页源码 `1/1`、会话映射 `2/2`、TypeScript 和登录态浏览器刷新复核均通过;后续如改变登录或切换账号策略,应作为独立会话需求重新评审,不回退本次已验收语义。
6. **P2 运营与测试效率**:补齐个人套餐、配额、用量、支付、数据导出/删除和客户成功指标;在不降低密码哈希参数的前提下并行化身份测试夹具,并建立账号冲突诊断的规模化查询和回归性能基线。
### 9.21 第七阶段自动化开发结果(2026-08-02)
- **账号冲突诊断扩展性收口**:SQL 仓储不再把全局账号、租户用户、成员关系、工作空间和两类凭据整表加载为 ORM 对象,而是先以 SQL 发现异常候选,再沿账号、用户、成员关系和规范化邮箱组成的关系图加载候选详情。健康规模数据保持零误报,异常详情继续复用原冲突码、严重度、稳定诊断 ID 和最小披露模型,不增加身份写操作或迁移。
- **规范化与高基数边界**:数据库诊断表达式使用与 Python `str.strip()` 等价的完整 Unicode 首尾空白集合;SQLite 对非 ASCII 大小写差异增加兼容候选扩展,PostgreSQL 使用 `lower(btrim(...))`。账号、用户、成员、凭据、邮箱和工作空间关联加载统一按每批 400 个值执行,避免大量异常候选超过 SQLite/PostgreSQL 绑定参数上限。缺失全局账号发现由相关子查询改为规范化账号邮箱集合的反连接,避免 SQLite 对每个用户重复扫描账号表。
- **SMTP 协议与配置安全**:验证码和邀请统一走一条 SMTP 发送路径;STARTTLS 与 SMTPS 互斥,生产禁止明文 SMTP,用户名与密码必须成对出现,环境密码与密码文件必须二选一。密码文件必须为可读、非空 UTF-8,SMTP 密码不进入对象 `repr`;发件人、收件人和显示名执行地址及头注入校验,邮件增加 `Date`、`Message-ID`、自动回复抑制头,底层 SMTP、网络和 Unicode 编码错误统一脱敏为 `SMTP delivery failed.`。
- **生产 SMTP secret 闭环**:默认 `disabled` 模式继续使用管理员线下交付邀请链接,不要求 SMTP secret。新增可选 `docker-compose.production.smtp.yml`,仅在启用 SMTP 时把宿主机受保护密码文件只读挂载到固定容器路径,并清空直接密码环境变量。生产 preflight 同步校验 TLS 模式、主机、端口、超时、发件邮箱、头注入、HTTPS 非 loopback 公网地址、宿主机 secret 的绝对路径、非空和 POSIX owner-only 权限。
- **组织工作空间代开权限闭环**:产品语义确定为“管理后台仅由平台管理员代开组织工作空间”,不改变公开自助注册这一独立旅程。签名 Web 会话兼容携带 `platform_permissions`;身份页只有会话能力与平台管理员名册实时验证同时成功时才显示代开表单,提交函数再次防护,Next.js 身份代理对 `register-company` 再执行实时平台权限复核。企业和个人工作空间管理员仍可管理本空间成员,但不能从管理后台代开新组织后落入旧会话导致 `403`。
### 9.22 第七阶段验证证据与后续优先项
- Contract API 使用全新隔离 SQLite 执行全量 `211/211` 通过;最终账号冲突、SMTP 和身份安全定向回归 `32/32`,生产发布工具 `24/24`,Web 定向回归 `14/14`,TypeScript 类型检查和实现 Agent 的 100 路由生产构建均通过。Python 编译、三份 Compose YAML 解析、生产示例 preflight 和 `git diff --check` 通过;差异检查仅有工作区既有 CRLF 提示。
- 账号冲突回归覆盖 300 组健康身份零误报、孤立成员、孤立全局/旧凭据、多旧凭据无统一凭据、Unicode/NBSP/Tab 规范化、关系图闭包稳定性,以及 1,001 个同时冲突候选。高基数用例确认单条 SELECT 绑定参数保持在分片上限内;响应递归检查继续不含密码哈希、token、验证码或会话秘密。
- SMTP 验收使用本机 TCP 假 SMTP 服务与真实 Python `smtplib` 完成协议命令和邮件内容往返;这证明应用 SMTP 客户端路径可运行,不代表外部邮件服务已接受、送达、退信或重试成功。当前机器没有 Docker CLI,未执行真实 `docker compose config` 或容器 secret 挂载;没有生产 SMTP 主机和凭据,未进行外部邮件投递。
- **P0 法律签批与共享预发布交付**仍未完成:需具名法务复核知识卡和黄金结果,建立权威法条、正式版本、生效记录、批准责任人,并在共享预发布环境持久化验收运行 ID。技术门禁、开发浏览器和本机协议测试均不能替代法律批准。
- **P1 外部邮件与账号恢复运营**:接入实际选定的 SMTP/邮件服务后,执行发送、送达、退信、重试、限流和浏览器端注册/验证/邀请/重置全旅程;继续设计全局登录邮箱变更的旧邮箱确认、新邮箱验证、冲突检查和恢复流程。
- **P1 PostgreSQL 性能与并发验收**:在共享预发布 PostgreSQL 上记录账号候选 SQL 的 `EXPLAIN (ANALYZE, BUFFERS)`、排序规则和高冲突基数基线,按证据决定是否新增规范化邮箱列或表达式索引;同时执行最后有效平台管理员保护的真实双请求并发测试。当前 SQLite、SQL 编译和分片测试不能替代该验收。
- **P1 高权限写操作治理**:账号合并、拆分、成员恢复以及平台管理员授予/撤销仍必须先定义 preview、幂等 operation ID、支持访问原因与时限、双人审批、回滚策略和 before/after 审计,不得从当前只读诊断直接扩展为无审批破坏性写接口。
- **P2 产品表达与自动化**:工作空间类型在单空间和多空间状态下仍需以独立“系统/个人/组织”标签一致表达;平台专属诊断页应增加前端精确路由守卫和真实 Route Handler 测试,覆盖当前权限、已撤权、旧会话和后端不可用四种状态。
@@ -3,6 +3,10 @@ import { readFile } from "node:fs/promises";
import test from "node:test";
const pageSource = await readFile(new URL("./page.tsx", import.meta.url), "utf8");
const identityProxySource = await readFile(
new URL("../../api/contract-platform/identity/[...path]/route.ts", import.meta.url),
"utf8",
);
test("organization workspace creation does not expose a default administrator password", () => {
assert.doesNotMatch(pageSource, /admin654321/i);
@@ -13,3 +17,18 @@ test("organization workspace creation does not expose a default administrator pa
assert.doesNotMatch(pageSource, /placeholder="公司名称"/);
assert.doesNotMatch(pageSource, />\s*创建公司\s*</);
});
test("organization workspace provisioning is gated by platform identity management", () => {
assert.match(pageSource, /canManagePlatformIdentity\(/);
assert.match(pageSource, /platformAdministrators !== null/);
assert.match(pageSource, /if \(!canCreateOrganizationWorkspace\)/);
assert.match(pageSource, /\{canCreateOrganizationWorkspace && <form/);
assert.match(identityProxySource, /denyUnauthorizedOrganizationProvisioning\(path\)/);
assert.match(identityProxySource, /platform-administrators/);
assert.match(identityProxySource, /canManagePlatformIdentity\(session\.platform_permissions, liveAccess\)/);
const provisionFormStart = pageSource.indexOf("{canCreateOrganizationWorkspace && <form");
const provisionFormEnd = pageSource.indexOf("</form>}", provisionFormStart);
const memberManagementStart = pageSource.indexOf("<span>新增用户</span>", provisionFormEnd);
assert.ok(provisionFormStart >= 0 && provisionFormEnd > provisionFormStart);
assert.ok(memberManagementStart > provisionFormEnd, "member management must remain outside the platform-only form");
});
@@ -17,7 +17,8 @@ import {
getIdentityUsers,
getPlatformAdministrators,
} from "@/services/contracts-client";
import { getRoleLabel, type UserRole } from "@/lib/access-control";
import { canManagePlatformIdentity, getRoleLabel, type UserRole } from "@/lib/access-control";
import { useContractSession } from "@/components/app-shell";
import { getIdentityCapabilities, type IdentityCapabilities } from "@/lib/identity-capabilities";
import { workspaceDisplayName } from "@/lib/workspace-access-state";
@@ -89,6 +90,7 @@ const tenantStatusOptions: TenantStatus[] = ["active", "suspended", "archived"];
const ssoProviderOptions: SsoProvider[] = ["saml", "oidc"];
export default function IdentityAdminPage() {
const session = useContractSession();
const [tenants, setTenants] = useState<TenantInfo[]>([]);
const [users, setUsers] = useState<TenantUserItem[]>([]);
const [roles, setRoles] = useState<RolePermissionsPage>({ roles: [] });
@@ -147,6 +149,10 @@ export default function IdentityAdminPage() {
() => tenants.find((tenant) => tenant.id === selectedTenantId) ?? tenants[0] ?? null,
[tenants, selectedTenantId],
);
const canCreateOrganizationWorkspace = canManagePlatformIdentity(
session?.platform_permissions,
platformAdministrators !== null,
);
useEffect(() => {
void loadInitialData();
@@ -224,6 +230,10 @@ export default function IdentityAdminPage() {
async function createTenant(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!canCreateOrganizationWorkspace) {
setMessage("仅平台管理员可以代开组织工作空间。");
return;
}
setSaving("tenant-create");
setMessage(null);
try {
@@ -617,7 +627,7 @@ export default function IdentityAdminPage() {
</button>
))}
</div>
<form autoComplete="off" className="panel-body identity-form" onSubmit={createTenant}>
{canCreateOrganizationWorkspace && <form autoComplete="off" className="panel-body identity-form" onSubmit={createTenant}>
<div className="identity-form-title">
<Plus size={16} />
<span>新建组织工作空间</span>
@@ -690,7 +700,7 @@ export default function IdentityAdminPage() {
<Plus size={16} />
创建组织工作空间
</button>
</form>
</form>}
</section>
<section className="panel">
@@ -1,4 +1,11 @@
import { buildContractApiUrl, buildIdentityProxyHeaders, proxyContractApiResponse } from "@/lib/contract-api";
import { canManagePlatformIdentity } from "@/lib/access-control";
import {
buildContractApiUrl,
buildIdentityProxyHeaders,
fetchContractApi,
proxyContractApiResponse,
} from "@/lib/contract-api";
import { getContractWebSession } from "@/lib/contract-session";
type RouteContext = {
params: Promise<{ path?: string[] }>;
@@ -20,6 +27,10 @@ export async function GET(request: Request, { params }: RouteContext) {
export async function POST(request: Request, { params }: RouteContext) {
const { path } = await params;
const accessDenied = await denyUnauthorizedOrganizationProvisioning(path);
if (accessDenied) {
return accessDenied;
}
const body = await request.text();
return proxyContractApiResponse(buildIdentityTargetUrl(request, path), {
method: "POST",
@@ -28,6 +39,23 @@ export async function POST(request: Request, { params }: RouteContext) {
});
}
async function denyUnauthorizedOrganizationProvisioning(path: string[] | undefined) {
if (path?.join("/") !== "register-company") {
return null;
}
const session = await getContractWebSession();
if (!session) {
return Response.json({ message: "请先登录。" }, { status: 401 });
}
const liveAccess = await fetchContractApi(
buildContractApiUrl("/api/v1/identity/platform-administrators"),
).then((response) => response.ok).catch(() => false);
if (!canManagePlatformIdentity(session.platform_permissions, liveAccess)) {
return Response.json({ message: "仅平台管理员可以代开组织工作空间。" }, { status: 403 });
}
return null;
}
export async function PATCH(request: Request, { params }: RouteContext) {
const { path } = await params;
const body = await request.text();
@@ -0,0 +1,16 @@
import assert from "node:assert/strict";
import test from "node:test";
import { canManagePlatformIdentity, hasPlatformCapability } from "./access-control.ts";
test("platform identity management requires the explicit platform permission", () => {
assert.equal(hasPlatformCapability(["platform_admin"], "platform.identity.manage"), true);
assert.equal(hasPlatformCapability([], "platform.identity.manage"), false);
});
test("platform identity management also requires a live platform access check", () => {
assert.equal(canManagePlatformIdentity(["platform_admin"], true), true);
assert.equal(canManagePlatformIdentity(["platform_admin"], false), false);
assert.equal(canManagePlatformIdentity([], true), false);
assert.equal(canManagePlatformIdentity(undefined, true), true);
assert.equal(canManagePlatformIdentity(undefined, false), false);
});
@@ -15,6 +15,8 @@ export type AdminCapability =
| "ai_runtime"
| "system_settings";
export type PlatformCapability = "platform.identity.manage";
export type AdminSectionAccess = {
capability: AdminCapability;
roles: UserRole[];
@@ -130,3 +132,24 @@ export function canAccessAdminCapability(
const routeAccess = Object.values(adminRouteAccess).find((item) => item.capability === capability);
return routeAccess ? routeAccess.roles.includes(role) : role === "admin";
}
export function hasPlatformCapability(
platformPermissions: readonly string[] | undefined,
capability: PlatformCapability,
): boolean {
if (capability === "platform.identity.manage") {
return platformPermissions?.includes("platform_admin") ?? false;
}
return false;
}
export function canManagePlatformIdentity(
platformPermissions: readonly string[] | undefined,
platformAccessValidated: boolean,
): boolean {
if (!platformAccessValidated) {
return false;
}
return platformPermissions === undefined
|| hasPlatformCapability(platformPermissions, "platform.identity.manage");
}
@@ -11,6 +11,7 @@ export type ContractWebSession = {
email: string;
display_name: string;
role: string;
platform_permissions?: string[];
email_verified_at?: string | null;
};
@@ -46,6 +47,9 @@ export function readContractWebSession(rawValue: string | null | undefined): Con
email: parsed.email,
display_name: parsed.display_name || parsed.email,
role: parsed.role || "business",
platform_permissions: Array.isArray(parsed.platform_permissions)
? parsed.platform_permissions.filter((item): item is string => typeof item === "string")
: undefined,
email_verified_at: typeof parsed.email_verified_at === "string" ? parsed.email_verified_at : null,
};
} catch {
@@ -9,6 +9,7 @@ const basePayload = {
email: "user@example.com",
display_name: "Current User",
role: "business",
platform_permissions: ["platform_admin"],
},
};
@@ -26,6 +27,7 @@ test("session mapping carries the global email verification timestamp", () => {
email: "user@example.com",
display_name: "Current User",
role: "business",
platform_permissions: ["platform_admin"],
email_verified_at: "2026-08-02T03:00:00Z",
});
});
@@ -35,3 +37,12 @@ test("legacy identity sessions remain valid and explicitly unverified", () => {
assert.equal(toContractWebSession(basePayload).email_verified_at, null);
assert.equal(isIdentitySessionPayload({ tenant: {}, user: {} }), false);
});
test("legacy identity sessions keep platform permissions absent", () => {
const legacyPayload = {
...basePayload,
user: { ...basePayload.user, platform_permissions: undefined },
};
assert.equal(toContractWebSession(legacyPayload).platform_permissions, undefined);
});
@@ -10,6 +10,7 @@ export type IdentitySessionPayload = {
email: string;
display_name: string;
role: string;
platform_permissions?: string[];
};
global_account?: {
email_verified_at?: string | null;
@@ -42,6 +43,9 @@ export function toContractWebSession(payload: IdentitySessionPayload): ContractW
email: payload.user.email,
display_name: payload.user.display_name,
role: payload.user.role,
platform_permissions: Array.isArray(payload.user.platform_permissions)
? payload.user.platform_permissions.filter((item): item is string => typeof item === "string")
: undefined,
email_verified_at: typeof emailVerifiedAt === "string" ? emailVerifiedAt : null,
};
}
@@ -57,11 +57,13 @@ CONTRACT_API_BATCH_DOWNLOAD_MAX_BYTES=104857600
# CONTRACT_API_IDENTITY_SMTP_HOST=smtp.example.com
# CONTRACT_API_IDENTITY_SMTP_PORT=587
# CONTRACT_API_IDENTITY_SMTP_USERNAME=mailer@example.com
# Configure exactly one password source. Password files must be UTF-8, readable, and non-empty.
# CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE=/run/secrets/contract_smtp_password
# CONTRACT_API_IDENTITY_SMTP_FROM_EMAIL=mailer@example.com
# CONTRACT_API_IDENTITY_SMTP_FROM_NAME=AI 合同平台
# CONTRACT_API_IDENTITY_SMTP_USE_STARTTLS=true
# CONTRACT_API_IDENTITY_SMTP_USE_SSL=false
# Production requires exactly one encrypted transport mode; plaintext is for isolated local SMTP acceptance only.
# CONTRACT_API_IDENTITY_SMTP_TIMEOUT_SECONDS=10
# CONTRACT_API_IDENTITY_PUBLIC_WEB_BASE_URL=https://contracts.example.com
# CONTRACT_API_IDENTITY_RATE_LIMIT_SECRET=CHANGE_ME_USE_A_RANDOM_32_BYTE_SECRET
@@ -1,3 +1,4 @@
from email.headerregistry import Address
from functools import lru_cache
from pathlib import Path
from typing import Literal
@@ -279,25 +280,53 @@ def _check_identity_delivery_config(settings: ContractApiSettings) -> RuntimeCon
)
if mode == "smtp":
password_file = settings.identity_smtp_password_file.strip()
password_configured = bool(settings.identity_smtp_password.get_secret_value())
username = settings.identity_smtp_username.strip()
errors: list[str] = []
if not settings.identity_smtp_host.strip():
smtp_host = settings.identity_smtp_host
if (
not smtp_host.strip()
or smtp_host != smtp_host.strip()
or any(character.isspace() for character in smtp_host)
or _contains_control_character(smtp_host)
):
errors.append("smtp_host")
if not settings.identity_smtp_from_email.strip() or "@" not in settings.identity_smtp_from_email:
if not _is_valid_email_address(settings.identity_smtp_from_email):
errors.append("smtp_from_email")
if _contains_control_character(settings.identity_smtp_from_name):
errors.append("smtp_from_name")
if not 1 <= settings.identity_smtp_port <= 65535:
errors.append("smtp_port")
if settings.identity_smtp_timeout_seconds <= 0:
if not 0 < settings.identity_smtp_timeout_seconds <= 120:
errors.append("smtp_timeout_seconds")
if settings.identity_smtp_use_starttls and settings.identity_smtp_use_ssl:
errors.append("smtp_tls_mode")
if settings.identity_smtp_username.strip() and not (
settings.identity_smtp_password.get_secret_value() or password_file
if settings.environment == "production" and not (
settings.identity_smtp_use_starttls or settings.identity_smtp_use_ssl
):
errors.append("smtp_tls_mode")
if username and _contains_control_character(username):
errors.append("smtp_username")
if password_file and password_configured:
errors.append("smtp_password_source")
if username and not (password_configured or password_file):
errors.append("smtp_password")
if password_file and not Path(password_file).is_file():
errors.append("smtp_password_file")
if not username and (password_configured or password_file):
errors.append("smtp_username")
if password_file:
try:
password_file_value = Path(password_file).read_text(encoding="utf-8").strip()
except (OSError, UnicodeError):
errors.append("smtp_password_file")
else:
if not password_file_value:
errors.append("smtp_password_file")
public_url = urlparse(settings.identity_public_web_base_url)
if public_url.scheme not in {"http", "https"} or not public_url.netloc:
if (
_contains_control_character(settings.identity_public_web_base_url)
or public_url.scheme not in {"http", "https"}
or not public_url.netloc
):
errors.append("public_web_base_url")
if settings.environment == "production" and (
public_url.scheme != "https" or public_url.hostname in {"localhost", "127.0.0.1", "::1"}
@@ -308,7 +337,7 @@ def _check_identity_delivery_config(settings: ContractApiSettings) -> RuntimeCon
name="identity_email_delivery",
status="error",
message="SMTP identity email delivery configuration is incomplete or invalid.",
details={**details, "invalid_fields": ",".join(errors)},
details={**details, "invalid_fields": ",".join(dict.fromkeys(errors))},
)
return RuntimeConfigCheck(
name="identity_email_delivery",
@@ -333,6 +362,22 @@ def _check_identity_delivery_config(settings: ContractApiSettings) -> RuntimeCon
)
def _is_valid_email_address(value: str) -> bool:
if not value or value != value.strip() or any(character.isspace() for character in value):
return False
if _contains_control_character(value):
return False
try:
address = Address(addr_spec=value)
except (TypeError, ValueError):
return False
return bool(address.username and address.domain)
def _contains_control_character(value: str) -> bool:
return any(ord(character) < 32 or ord(character) == 127 for character in value)
def _check_identity_rate_limit_config(settings: ContractApiSettings) -> RuntimeConfigCheck:
secret_length = len(settings.identity_rate_limit_secret.strip())
values = {
@@ -3,11 +3,12 @@ from __future__ import annotations
import html
import smtplib
import ssl
from dataclasses import dataclass
from dataclasses import dataclass, field
from email.headerregistry import Address
from email.message import EmailMessage
from email.utils import formataddr
from email.utils import formataddr, formatdate, make_msgid
from pathlib import Path
from urllib.parse import quote
from urllib.parse import quote, urlparse
from yuqei_contract_api.config import ContractApiSettings
from yuqei_contract_api.identity import EmailChallengeItem, TenantInvitationItem
@@ -38,7 +39,7 @@ class SmtpIdentityEmailDelivery(IdentityEmailDelivery):
host: str
port: int
username: str
password: str
password: str = field(repr=False)
from_email: str
from_name: str
use_starttls: bool
@@ -59,6 +60,14 @@ class SmtpIdentityEmailDelivery(IdentityEmailDelivery):
from_email=self.from_email,
from_name=self.from_name,
)
self._send_message(message)
def _authenticate_and_send(self, client: smtplib.SMTP, message: EmailMessage) -> None:
if self.username:
client.login(self.username, self.password)
client.send_message(message)
def _send_message(self, message: EmailMessage) -> None:
try:
if self.use_ssl:
with smtplib.SMTP_SSL(
@@ -76,14 +85,9 @@ class SmtpIdentityEmailDelivery(IdentityEmailDelivery):
client.starttls(context=ssl.create_default_context())
client.ehlo()
self._authenticate_and_send(client, message)
except (OSError, smtplib.SMTPException) as exc:
except (OSError, UnicodeError, smtplib.SMTPException) as exc:
raise IdentityEmailDeliveryError("SMTP delivery failed.") from exc
def _authenticate_and_send(self, client: smtplib.SMTP, message: EmailMessage) -> None:
if self.username:
client.login(self.username, self.password)
client.send_message(message)
def send_invitation(self, invitation: TenantInvitationItem) -> None:
token = invitation.invite_token
if not token:
@@ -98,24 +102,7 @@ class SmtpIdentityEmailDelivery(IdentityEmailDelivery):
from_email=self.from_email,
from_name=self.from_name,
)
try:
if self.use_ssl:
with smtplib.SMTP_SSL(
self.host,
self.port,
timeout=self.timeout_seconds,
context=ssl.create_default_context(),
) as client:
self._authenticate_and_send(client, message)
return
with smtplib.SMTP(self.host, self.port, timeout=self.timeout_seconds) as client:
client.ehlo()
if self.use_starttls:
client.starttls(context=ssl.create_default_context())
client.ehlo()
self._authenticate_and_send(client, message)
except (OSError, smtplib.SMTPException) as exc:
raise IdentityEmailDeliveryError("SMTP delivery failed.") from exc
self._send_message(message)
def build_identity_email_delivery(settings: ContractApiSettings) -> IdentityEmailDelivery:
@@ -123,11 +110,13 @@ def build_identity_email_delivery(settings: ContractApiSettings) -> IdentityEmai
return DisabledIdentityEmailDelivery()
if settings.identity_email_delivery_mode == "development":
return IdentityEmailDelivery()
password = _resolve_smtp_password(settings)
_validate_smtp_runtime_settings(settings, password=password)
return SmtpIdentityEmailDelivery(
host=settings.identity_smtp_host.strip(),
port=settings.identity_smtp_port,
username=settings.identity_smtp_username.strip(),
password=_resolve_smtp_password(settings),
password=password,
from_email=settings.identity_smtp_from_email.strip(),
from_name=settings.identity_smtp_from_name.strip(),
use_starttls=settings.identity_smtp_use_starttls,
@@ -139,12 +128,61 @@ def build_identity_email_delivery(settings: ContractApiSettings) -> IdentityEmai
def _resolve_smtp_password(settings: ContractApiSettings) -> str:
secret_file = settings.identity_smtp_password_file.strip()
configured_password = settings.identity_smtp_password.get_secret_value()
if secret_file and configured_password:
raise IdentityEmailDeliveryError("SMTP password must use exactly one configured source.")
if secret_file:
try:
return Path(secret_file).read_text(encoding="utf-8").strip()
except OSError as exc:
password = Path(secret_file).read_text(encoding="utf-8").strip()
except (OSError, UnicodeError) as exc:
raise IdentityEmailDeliveryError("Unable to read the SMTP password file.") from exc
return settings.identity_smtp_password.get_secret_value()
if not password:
raise IdentityEmailDeliveryError("SMTP password file is empty.")
return password
return configured_password
def _validate_smtp_runtime_settings(settings: ContractApiSettings, *, password: str) -> None:
raw_host = settings.identity_smtp_host
host = raw_host.strip()
username = settings.identity_smtp_username.strip()
if (
not host
or raw_host != host
or _contains_control_character(host)
or any(character.isspace() for character in host)
):
raise IdentityEmailDeliveryError("SMTP host configuration is invalid.")
if not 1 <= settings.identity_smtp_port <= 65535:
raise IdentityEmailDeliveryError("SMTP port configuration is invalid.")
if not 0 < settings.identity_smtp_timeout_seconds <= 120:
raise IdentityEmailDeliveryError("SMTP timeout configuration is invalid.")
if settings.identity_smtp_use_starttls and settings.identity_smtp_use_ssl:
raise IdentityEmailDeliveryError("SMTP TLS modes are mutually exclusive.")
if settings.environment == "production" and not (
settings.identity_smtp_use_starttls or settings.identity_smtp_use_ssl
):
raise IdentityEmailDeliveryError("Production SMTP requires transport encryption.")
if bool(username) != bool(password):
raise IdentityEmailDeliveryError("SMTP authentication configuration is incomplete.")
_validate_mailbox(settings.identity_smtp_from_email)
_validate_header_text(settings.identity_smtp_from_name.strip())
raw_public_url = settings.identity_public_web_base_url
public_url = urlparse(raw_public_url)
if (
raw_public_url != raw_public_url.strip()
or _contains_control_character(raw_public_url)
or public_url.scheme not in {"http", "https"}
or not public_url.netloc
or (
settings.environment == "production"
and (
public_url.scheme != "https"
or public_url.hostname in {"localhost", "127.0.0.1", "::1"}
)
)
):
raise IdentityEmailDeliveryError("Identity public web URL is invalid.")
def _build_challenge_message(
@@ -154,6 +192,9 @@ def _build_challenge_message(
from_email: str,
from_name: str,
) -> EmailMessage:
_validate_mailbox(challenge.email)
_validate_mailbox(from_email)
_validate_header_text(from_name)
is_password_reset = challenge.purpose == "password_reset"
action = "重置密码" if is_password_reset else "验证邮箱"
subject = f"AI 合同平台{action}验证码"
@@ -180,6 +221,9 @@ def _build_challenge_message(
message["From"] = formataddr((from_name, from_email)) if from_name else from_email
message["To"] = challenge.email
message["Auto-Submitted"] = "auto-generated"
message["Date"] = formatdate(localtime=False, usegmt=True)
message["Message-ID"] = make_msgid(domain=from_email.rsplit("@", 1)[1])
message["X-Auto-Response-Suppress"] = "All"
message.set_content(text)
message.add_alternative(html_body, subtype="html")
return message
@@ -192,6 +236,9 @@ def _build_invitation_message(
from_email: str,
from_name: str,
) -> EmailMessage:
_validate_mailbox(invitation.email)
_validate_mailbox(from_email)
_validate_header_text(from_name)
safe_url = html.escape(accept_url, quote=True)
text = (
"您已被邀请加入 AI 合同平台。\n\n"
@@ -213,6 +260,31 @@ def _build_invitation_message(
message["From"] = formataddr((from_name, from_email)) if from_name else from_email
message["To"] = invitation.email
message["Auto-Submitted"] = "auto-generated"
message["Date"] = formatdate(localtime=False, usegmt=True)
message["Message-ID"] = make_msgid(domain=from_email.rsplit("@", 1)[1])
message["X-Auto-Response-Suppress"] = "All"
message.set_content(text)
message.add_alternative(html_body, subtype="html")
return message
def _validate_mailbox(value: str) -> None:
if not value or value != value.strip() or any(character.isspace() for character in value):
raise IdentityEmailDeliveryError("Identity email address is invalid.")
if _contains_control_character(value):
raise IdentityEmailDeliveryError("Identity email address is invalid.")
try:
address = Address(addr_spec=value)
except (TypeError, ValueError) as exc:
raise IdentityEmailDeliveryError("Identity email address is invalid.") from exc
if not address.username or not address.domain:
raise IdentityEmailDeliveryError("Identity email address is invalid.")
def _validate_header_text(value: str) -> None:
if _contains_control_character(value):
raise IdentityEmailDeliveryError("Identity email header configuration is invalid.")
def _contains_control_character(value: str) -> bool:
return any(ord(character) < 32 or ord(character) == 127 for character in value)
@@ -1,10 +1,12 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from itertools import islice
from typing import Literal
from uuid import uuid4
from sqlalchemy import delete, func, inspect, select, text
from sqlalchemy import and_, delete, func, inspect, or_, select, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import sessionmaker
@@ -590,81 +592,10 @@ class IdentityRepository:
def _account_conflict_details(self) -> list[AccountConflictDetail]:
with self._session_factory() as session:
account_models = session.execute(select(GlobalAccountModel)).scalars().all()
user_models = session.execute(select(TenantUserModel)).scalars().all()
membership_models = session.execute(select(WorkspaceMembershipModel)).scalars().all()
tenant_models = session.execute(select(TenantModel)).scalars().all()
global_credential_models = session.execute(
select(GlobalAccountCredentialModel)
).scalars().all()
legacy_credential_models = session.execute(
select(TenantUserCredentialModel).where(
TenantUserCredentialModel.deleted_at.is_(None)
)
).scalars().all()
return build_account_conflict_details(
accounts=[_global_account_from_model(item) for item in account_models],
users=[
AccountDiagnosticUserRecord(
id=item.id,
tenant_id=item.tenant_id,
email=item.email,
display_name=item.display_name,
role=normalize_role(item.role),
status=item.status,
deleted=item.deleted_at is not None,
updated_at=item.updated_at,
)
for item in user_models
],
memberships=[
_workspace_membership_from_model(item)
for item in membership_models
],
tenants=[
AccountDiagnosticTenantRecord(
id=item.id,
owner_user_id=item.owner_user_id,
deleted=item.deleted_at is not None,
updated_at=item.updated_at,
)
for item in tenant_models
],
credentials=[
*[
AccountDiagnosticCredentialRecord(
id=item.id,
scope="global",
account_id=item.account_id,
tenant_id=None,
user_id=None,
email=None,
status=item.status,
scheme=item.password_scheme,
password_updated_at=item.password_updated_at,
password_reset_required=False,
updated_at=item.updated_at,
)
for item in global_credential_models
],
*[
AccountDiagnosticCredentialRecord(
id=item.id,
scope="legacy",
account_id=None,
tenant_id=item.tenant_id,
user_id=item.user_id,
email=item.email,
status="active",
scheme=item.password_scheme,
password_updated_at=item.password_updated_at,
password_reset_required=item.password_reset_required,
updated_at=item.updated_at,
)
for item in legacy_credential_models
],
],
)
candidates = _discover_account_conflict_candidates(session)
if candidates.empty:
return []
return _load_account_conflict_candidate_details(session, candidates)
def create_user(self, request: TenantUserCreateRequest, *, tenant_id: str = DEFAULT_TENANT_ID) -> TenantUserItem:
now = datetime.now(UTC)
@@ -2183,6 +2114,723 @@ class IdentityRepository:
return global_account, current_membership
@dataclass
class _AccountConflictCandidates:
normalized_emails: set[str] = field(default_factory=set)
account_ids: set[str] = field(default_factory=set)
user_ids: set[str] = field(default_factory=set)
membership_ids: set[str] = field(default_factory=set)
global_credential_ids: set[str] = field(default_factory=set)
legacy_credential_ids: set[str] = field(default_factory=set)
@property
def empty(self) -> bool:
return not any(
(
self.normalized_emails,
self.account_ids,
self.user_ids,
self.membership_ids,
self.global_credential_ids,
self.legacy_credential_ids,
)
)
def add_email(self, value: object) -> None:
if value is not None:
self.normalized_emails.add(str(value).strip().lower())
def add_account_id(self, value: object) -> None:
if value is not None:
self.account_ids.add(str(value))
def add_user_id(self, value: object) -> None:
if value is not None:
self.user_ids.add(str(value))
def add_membership_id(self, value: object) -> None:
if value is not None:
self.membership_ids.add(str(value))
def add_global_credential_id(self, value: object) -> None:
if value is not None:
self.global_credential_ids.add(str(value))
def add_legacy_credential_id(self, value: object) -> None:
if value is not None:
self.legacy_credential_ids.add(str(value))
def _diagnostic_email_expression(column, *, dialect_name: str):
trim_function = func.btrim if dialect_name == "postgresql" else func.trim
return func.lower(trim_function(column, _DIAGNOSTIC_EMAIL_TRIM_CHARACTERS))
_DIAGNOSTIC_EMAIL_TRIM_CHARACTERS = (
" \t\n\r\v\f\x1c\x1d\x1e\x1f\x85\xa0\u1680\u2000\u2001\u2002\u2003"
"\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000"
)
_ACCOUNT_CONFLICT_QUERY_BATCH_SIZE = 400
def _candidate_batch(values: set[str]) -> set[str]:
return set(islice(values, _ACCOUNT_CONFLICT_QUERY_BATCH_SIZE))
def _candidate_batches(values) -> list[set[str]]:
remaining = set(values)
batches: list[set[str]] = []
while remaining:
batch = _candidate_batch(remaining)
remaining.difference_update(batch)
batches.append(batch)
return batches
def _non_ascii_email_condition(session, column):
dialect_name = session.get_bind().dialect.name
if dialect_name == "sqlite":
return func.length(func.hex(column)) != 2 * func.length(column)
if dialect_name == "postgresql":
return func.octet_length(column) != func.char_length(column)
raise RuntimeError(
f"Account conflict diagnostics do not support database dialect: {dialect_name}."
)
def _expand_non_ascii_email_candidates(
session,
candidates: _AccountConflictCandidates,
) -> None:
account_rows = session.execute(
select(GlobalAccountModel.id, GlobalAccountModel.email).where(
_non_ascii_email_condition(session, GlobalAccountModel.email)
)
).all()
user_rows = session.execute(
select(TenantUserModel.id, TenantUserModel.email).where(
_non_ascii_email_condition(session, TenantUserModel.email)
)
).all()
legacy_rows = session.execute(
select(TenantUserCredentialModel.id, TenantUserCredentialModel.email).where(
TenantUserCredentialModel.deleted_at.is_(None),
_non_ascii_email_condition(session, TenantUserCredentialModel.email),
)
).all()
account_groups: dict[str, list[str]] = {}
user_groups: dict[str, list[str]] = {}
legacy_groups: dict[str, list[str]] = {}
for account_id, email in account_rows:
account_groups.setdefault(_diagnostic_normalize_email_value(email), []).append(
account_id
)
for user_id, email in user_rows:
user_groups.setdefault(_diagnostic_normalize_email_value(email), []).append(user_id)
for credential_id, email in legacy_rows:
legacy_groups.setdefault(_diagnostic_normalize_email_value(email), []).append(
credential_id
)
for normalized_email, account_ids in account_groups.items():
if len(account_ids) > 1:
candidates.add_email(normalized_email)
for normalized_email, credential_ids in legacy_groups.items():
if len(credential_ids) > 1:
candidates.add_email(normalized_email)
candidate_emails = set(candidates.normalized_emails)
for normalized_email in candidate_emails:
for account_id in account_groups.get(normalized_email, []):
candidates.add_account_id(account_id)
for user_id in user_groups.get(normalized_email, []):
candidates.add_user_id(user_id)
for credential_id in legacy_groups.get(normalized_email, []):
candidates.add_legacy_credential_id(credential_id)
def _discover_account_conflict_candidates(session) -> _AccountConflictCandidates:
candidates = _AccountConflictCandidates()
dialect_name = session.get_bind().dialect.name
account_email = _diagnostic_email_expression(
GlobalAccountModel.email,
dialect_name=dialect_name,
)
user_email = _diagnostic_email_expression(
TenantUserModel.email,
dialect_name=dialect_name,
)
legacy_email = _diagnostic_email_expression(
TenantUserCredentialModel.email,
dialect_name=dialect_name,
)
duplicate_account_rows = session.execute(
select(account_email)
.group_by(account_email)
.having(func.count(GlobalAccountModel.id) > 1)
).all()
for (normalized_email,) in duplicate_account_rows:
candidates.add_email(normalized_email)
normalized_account_emails = (
select(account_email.label("normalized_email"))
.group_by(account_email)
.subquery()
)
missing_account_rows = session.execute(
select(user_email, TenantUserModel.id)
.select_from(TenantUserModel)
.outerjoin(
normalized_account_emails,
normalized_account_emails.c.normalized_email == user_email,
)
.where(
TenantUserModel.deleted_at.is_(None),
TenantUserModel.status != "disabled",
normalized_account_emails.c.normalized_email.is_(None),
)
).all()
for normalized_email, user_id in missing_account_rows:
candidates.add_email(normalized_email)
candidates.add_user_id(user_id)
matching_membership_exists = (
select(WorkspaceMembershipModel.id)
.where(WorkspaceMembershipModel.tenant_user_id == TenantUserModel.id)
.correlate(TenantUserModel)
.exists()
)
missing_membership_rows = session.execute(
select(user_email, TenantUserModel.id).where(
TenantUserModel.deleted_at.is_(None),
~matching_membership_exists,
)
).all()
for normalized_email, user_id in missing_membership_rows:
candidates.add_email(normalized_email)
candidates.add_user_id(user_id)
owner_flag_drift = or_(
and_(
WorkspaceMembershipModel.is_owner.is_(True),
or_(
TenantModel.owner_user_id.is_(None),
TenantModel.owner_user_id != TenantUserModel.id,
),
),
and_(
WorkspaceMembershipModel.is_owner.is_(False),
TenantModel.owner_user_id == TenantUserModel.id,
),
)
membership_anomaly_rows = session.execute(
select(
WorkspaceMembershipModel.id,
WorkspaceMembershipModel.account_id,
WorkspaceMembershipModel.tenant_user_id,
user_email,
account_email,
)
.select_from(WorkspaceMembershipModel)
.outerjoin(
GlobalAccountModel,
GlobalAccountModel.id == WorkspaceMembershipModel.account_id,
)
.outerjoin(
TenantUserModel,
TenantUserModel.id == WorkspaceMembershipModel.tenant_user_id,
)
.outerjoin(TenantModel, TenantModel.id == WorkspaceMembershipModel.tenant_id)
.where(
or_(
GlobalAccountModel.id.is_(None),
TenantUserModel.id.is_(None),
TenantUserModel.deleted_at.is_not(None),
TenantModel.id.is_(None),
TenantModel.deleted_at.is_not(None),
account_email != user_email,
WorkspaceMembershipModel.tenant_id != TenantUserModel.tenant_id,
WorkspaceMembershipModel.role != TenantUserModel.role,
WorkspaceMembershipModel.status != TenantUserModel.status,
owner_flag_drift,
and_(
GlobalAccountModel.status != "active",
WorkspaceMembershipModel.status == "active",
),
)
)
).all()
for membership_id, account_id, user_id, normalized_user_email, normalized_account_email in membership_anomaly_rows:
candidates.add_membership_id(membership_id)
candidates.add_account_id(account_id)
candidates.add_user_id(user_id)
candidates.add_email(normalized_user_email)
candidates.add_email(normalized_account_email)
duplicate_user_memberships = (
select(WorkspaceMembershipModel.tenant_user_id.label("tenant_user_id"))
.group_by(WorkspaceMembershipModel.tenant_user_id)
.having(func.count(WorkspaceMembershipModel.id) > 1)
.subquery()
)
duplicate_user_rows = session.execute(
select(
WorkspaceMembershipModel.id,
WorkspaceMembershipModel.account_id,
WorkspaceMembershipModel.tenant_user_id,
user_email,
)
.select_from(WorkspaceMembershipModel)
.join(
duplicate_user_memberships,
duplicate_user_memberships.c.tenant_user_id
== WorkspaceMembershipModel.tenant_user_id,
)
.outerjoin(
TenantUserModel,
TenantUserModel.id == WorkspaceMembershipModel.tenant_user_id,
)
).all()
for membership_id, account_id, user_id, normalized_email in duplicate_user_rows:
candidates.add_membership_id(membership_id)
candidates.add_account_id(account_id)
candidates.add_user_id(user_id)
candidates.add_email(normalized_email)
duplicate_account_tenants = (
select(
WorkspaceMembershipModel.account_id.label("account_id"),
WorkspaceMembershipModel.tenant_id.label("tenant_id"),
)
.group_by(WorkspaceMembershipModel.account_id, WorkspaceMembershipModel.tenant_id)
.having(func.count(WorkspaceMembershipModel.id) > 1)
.subquery()
)
duplicate_account_tenant_rows = session.execute(
select(
WorkspaceMembershipModel.id,
WorkspaceMembershipModel.account_id,
WorkspaceMembershipModel.tenant_user_id,
user_email,
)
.select_from(WorkspaceMembershipModel)
.join(
duplicate_account_tenants,
and_(
duplicate_account_tenants.c.account_id
== WorkspaceMembershipModel.account_id,
duplicate_account_tenants.c.tenant_id
== WorkspaceMembershipModel.tenant_id,
),
)
.outerjoin(
TenantUserModel,
TenantUserModel.id == WorkspaceMembershipModel.tenant_user_id,
)
).all()
for membership_id, account_id, user_id, normalized_email in duplicate_account_tenant_rows:
candidates.add_membership_id(membership_id)
candidates.add_account_id(account_id)
candidates.add_user_id(user_id)
candidates.add_email(normalized_email)
global_credential_rows = session.execute(
select(
GlobalAccountCredentialModel.id,
GlobalAccountCredentialModel.account_id,
account_email,
)
.select_from(GlobalAccountCredentialModel)
.outerjoin(
GlobalAccountModel,
GlobalAccountModel.id == GlobalAccountCredentialModel.account_id,
)
.where(
or_(
GlobalAccountModel.id.is_(None),
GlobalAccountCredentialModel.password_scheme != HASH_SCHEME,
)
)
).all()
for credential_id, account_id, normalized_email in global_credential_rows:
candidates.add_global_credential_id(credential_id)
candidates.add_account_id(account_id)
candidates.add_email(normalized_email)
legacy_credential_rows = session.execute(
select(
TenantUserCredentialModel.id,
TenantUserCredentialModel.user_id,
user_email,
legacy_email,
)
.select_from(TenantUserCredentialModel)
.outerjoin(
TenantUserModel,
TenantUserModel.id == TenantUserCredentialModel.user_id,
)
.where(
TenantUserCredentialModel.deleted_at.is_(None),
or_(
TenantUserModel.id.is_(None),
TenantUserModel.deleted_at.is_not(None),
TenantUserCredentialModel.tenant_id != TenantUserModel.tenant_id,
legacy_email != user_email,
TenantUserCredentialModel.password_scheme != HASH_SCHEME,
),
)
).all()
for credential_id, user_id, normalized_user_email, normalized_credential_email in legacy_credential_rows:
candidates.add_legacy_credential_id(credential_id)
candidates.add_user_id(user_id)
candidates.add_email(normalized_user_email)
candidates.add_email(normalized_credential_email)
legacy_credential_exists = (
select(TenantUserCredentialModel.id)
.where(
TenantUserCredentialModel.user_id == TenantUserModel.id,
TenantUserCredentialModel.deleted_at.is_(None),
)
.correlate(TenantUserModel)
.exists()
)
global_credential_exists = (
select(GlobalAccountCredentialModel.id)
.select_from(WorkspaceMembershipModel)
.join(
GlobalAccountCredentialModel,
GlobalAccountCredentialModel.account_id
== WorkspaceMembershipModel.account_id,
)
.where(
WorkspaceMembershipModel.tenant_user_id == TenantUserModel.id,
GlobalAccountCredentialModel.status == "active",
)
.correlate(TenantUserModel)
.exists()
)
missing_credential_rows = session.execute(
select(user_email, TenantUserModel.id).where(
TenantUserModel.deleted_at.is_(None),
TenantUserModel.status == "active",
~legacy_credential_exists,
~global_credential_exists,
)
).all()
for normalized_email, user_id in missing_credential_rows:
candidates.add_email(normalized_email)
candidates.add_user_id(user_id)
effective_legacy_email = func.coalesce(user_email, legacy_email)
legacy_email_counts = (
select(
effective_legacy_email.label("normalized_email"),
func.count(TenantUserCredentialModel.id).label("credential_count"),
)
.select_from(TenantUserCredentialModel)
.outerjoin(
TenantUserModel,
TenantUserModel.id == TenantUserCredentialModel.user_id,
)
.where(TenantUserCredentialModel.deleted_at.is_(None))
.group_by(effective_legacy_email)
.having(func.count(TenantUserCredentialModel.id) > 1)
.subquery()
)
active_global_credential_exists = (
select(GlobalAccountCredentialModel.id)
.select_from(GlobalAccountCredentialModel)
.join(
GlobalAccountModel,
GlobalAccountModel.id == GlobalAccountCredentialModel.account_id,
)
.where(
GlobalAccountCredentialModel.status == "active",
account_email == legacy_email_counts.c.normalized_email,
)
.correlate(legacy_email_counts)
.exists()
)
legacy_multi_rows = session.execute(
select(legacy_email_counts.c.normalized_email).where(
~active_global_credential_exists
)
).all()
for (normalized_email,) in legacy_multi_rows:
candidates.add_email(normalized_email)
_expand_non_ascii_email_candidates(session, candidates)
return candidates
def _load_account_conflict_candidate_details(
session,
candidates: _AccountConflictCandidates,
) -> list[AccountConflictDetail]:
dialect_name = session.get_bind().dialect.name
account_models: dict[str, GlobalAccountModel] = {}
user_models: dict[str, TenantUserModel] = {}
membership_models: dict[str, WorkspaceMembershipModel] = {}
global_credential_models: dict[str, GlobalAccountCredentialModel] = {}
legacy_credential_models: dict[str, TenantUserCredentialModel] = {}
for credential_id_batch in _candidate_batches(
candidates.global_credential_ids
):
rows = session.execute(
select(GlobalAccountCredentialModel).where(
GlobalAccountCredentialModel.id.in_(sorted(credential_id_batch))
)
).scalars().all()
global_credential_models.update((item.id, item) for item in rows)
for item in rows:
candidates.add_account_id(item.account_id)
for credential_id_batch in _candidate_batches(
candidates.legacy_credential_ids
):
rows = session.execute(
select(TenantUserCredentialModel).where(
TenantUserCredentialModel.id.in_(sorted(credential_id_batch)),
TenantUserCredentialModel.deleted_at.is_(None),
)
).scalars().all()
legacy_credential_models.update((item.id, item) for item in rows)
for item in rows:
candidates.add_user_id(item.user_id)
candidates.normalized_emails.update(
_diagnostic_normalize_email_value(item.email) for item in rows
)
requested_account_ids = set(candidates.account_ids)
requested_user_ids = set(candidates.user_ids)
requested_membership_ids = set(candidates.membership_ids)
requested_emails = set(candidates.normalized_emails)
queried_account_ids: set[str] = set()
queried_user_ids: set[str] = set()
queried_membership_ids: set[str] = set()
queried_emails: set[str] = set()
expanded_membership_accounts: set[str] = set()
expanded_membership_users: set[str] = set()
while True:
email_batch = _candidate_batch(requested_emails - queried_emails)
account_id_batch = _candidate_batch(
requested_account_ids - queried_account_ids
)
user_id_batch = _candidate_batch(requested_user_ids - queried_user_ids)
queried_emails.update(email_batch)
queried_account_ids.update(account_id_batch)
queried_user_ids.update(user_id_batch)
account_filters = []
if email_batch:
account_filters.append(
_diagnostic_email_expression(
GlobalAccountModel.email,
dialect_name=dialect_name,
).in_(
sorted(email_batch)
)
)
if account_id_batch:
account_filters.append(GlobalAccountModel.id.in_(sorted(account_id_batch)))
if account_filters:
rows = session.execute(
select(GlobalAccountModel).where(or_(*account_filters))
).scalars().all()
for item in rows:
account_models[item.id] = item
requested_emails.add(_diagnostic_normalize_email_value(item.email))
user_filters = []
if email_batch:
user_filters.append(
_diagnostic_email_expression(
TenantUserModel.email,
dialect_name=dialect_name,
).in_(
sorted(email_batch)
)
)
if user_id_batch:
user_filters.append(TenantUserModel.id.in_(sorted(user_id_batch)))
if user_filters:
rows = session.execute(
select(TenantUserModel).where(or_(*user_filters))
).scalars().all()
for item in rows:
user_models[item.id] = item
requested_emails.add(_diagnostic_normalize_email_value(item.email))
membership_id_batch = _candidate_batch(
requested_membership_ids - queried_membership_ids
)
membership_account_batch = _candidate_batch(
set(account_models) - expanded_membership_accounts
)
membership_user_batch = _candidate_batch(
set(user_models) - expanded_membership_users
)
queried_membership_ids.update(membership_id_batch)
expanded_membership_accounts.update(membership_account_batch)
expanded_membership_users.update(membership_user_batch)
membership_filters = []
if membership_id_batch:
membership_filters.append(
WorkspaceMembershipModel.id.in_(sorted(membership_id_batch))
)
if membership_account_batch:
membership_filters.append(
WorkspaceMembershipModel.account_id.in_(
sorted(membership_account_batch)
)
)
if membership_user_batch:
membership_filters.append(
WorkspaceMembershipModel.tenant_user_id.in_(
sorted(membership_user_batch)
)
)
if membership_filters:
rows = session.execute(
select(WorkspaceMembershipModel).where(or_(*membership_filters))
).scalars().all()
for item in rows:
membership_models[item.id] = item
requested_account_ids.add(item.account_id)
requested_user_ids.add(item.tenant_user_id)
pending_graph_work = any(
(
requested_emails - queried_emails,
requested_account_ids - queried_account_ids,
requested_user_ids - queried_user_ids,
requested_membership_ids - queried_membership_ids,
set(account_models) - expanded_membership_accounts,
set(user_models) - expanded_membership_users,
)
)
if not pending_graph_work:
break
for account_id_batch in _candidate_batches(account_models):
rows = session.execute(
select(GlobalAccountCredentialModel).where(
GlobalAccountCredentialModel.account_id.in_(sorted(account_id_batch))
)
).scalars().all()
global_credential_models.update((item.id, item) for item in rows)
for user_id_batch in _candidate_batches(user_models):
rows = session.execute(
select(TenantUserCredentialModel).where(
TenantUserCredentialModel.deleted_at.is_(None),
TenantUserCredentialModel.user_id.in_(sorted(user_id_batch)),
)
).scalars().all()
legacy_credential_models.update((item.id, item) for item in rows)
for email_batch in _candidate_batches(requested_emails):
rows = session.execute(
select(TenantUserCredentialModel).where(
TenantUserCredentialModel.deleted_at.is_(None),
_diagnostic_email_expression(
TenantUserCredentialModel.email,
dialect_name=dialect_name,
).in_(
sorted(email_batch)
),
)
).scalars().all()
legacy_credential_models.update((item.id, item) for item in rows)
tenant_ids = {
*[item.tenant_id for item in user_models.values()],
*[item.tenant_id for item in membership_models.values()],
}
tenant_models = []
for tenant_id_batch in _candidate_batches(tenant_ids):
tenant_models.extend(
session.execute(
select(TenantModel).where(TenantModel.id.in_(sorted(tenant_id_batch)))
).scalars().all()
)
return build_account_conflict_details(
accounts=[
_global_account_from_model(item) for item in account_models.values()
],
users=[
AccountDiagnosticUserRecord(
id=item.id,
tenant_id=item.tenant_id,
email=item.email,
display_name=item.display_name,
role=normalize_role(item.role),
status=item.status,
deleted=item.deleted_at is not None,
updated_at=item.updated_at,
)
for item in user_models.values()
],
memberships=[
_workspace_membership_from_model(item)
for item in membership_models.values()
],
tenants=[
AccountDiagnosticTenantRecord(
id=item.id,
owner_user_id=item.owner_user_id,
deleted=item.deleted_at is not None,
updated_at=item.updated_at,
)
for item in tenant_models
],
credentials=[
*[
AccountDiagnosticCredentialRecord(
id=item.id,
scope="global",
account_id=item.account_id,
tenant_id=None,
user_id=None,
email=None,
status=item.status,
scheme=item.password_scheme,
password_updated_at=item.password_updated_at,
password_reset_required=False,
updated_at=item.updated_at,
)
for item in global_credential_models.values()
],
*[
AccountDiagnosticCredentialRecord(
id=item.id,
scope="legacy",
account_id=None,
tenant_id=item.tenant_id,
user_id=item.user_id,
email=item.email,
status="active",
scheme=item.password_scheme,
password_updated_at=item.password_updated_at,
password_reset_required=item.password_reset_required,
updated_at=item.updated_at,
)
for item in legacy_credential_models.values()
],
],
)
def _diagnostic_normalize_email_value(value: str) -> str:
return value.strip().lower()
def _tenant_from_model(model: TenantModel, *, user_count: int) -> TenantInfo:
return TenantInfo(
id=model.id,
@@ -1,9 +1,10 @@
import json
from collections import Counter
from datetime import UTC, datetime
from pathlib import Path
from fastapi.testclient import TestClient
from sqlalchemy import delete, select
from sqlalchemy import delete, event, select
from yuqei_contract_api.anti_abuse import contract_endpoint_risk_policies, match_policy
from yuqei_contract_api.config import ContractApiSettings
@@ -11,6 +12,9 @@ from yuqei_contract_api.main import create_app
from yuqei_contract_api.persistence.models import (
GlobalAccountCredentialModel,
GlobalAccountModel,
TenantModel,
TenantUserCredentialModel,
TenantUserModel,
WorkspaceMembershipModel,
)
@@ -199,6 +203,63 @@ def test_missing_membership_is_stable_and_filterable(tmp_path: Path) -> None:
assert "missing_membership" in first.json()["items"][0]["conflict_codes"]
def test_sql_candidates_preserve_python_unicode_email_normalization(
tmp_path: Path,
) -> None:
app = create_app(make_settings(tmp_path))
repository = app.state.contract_repository.identity
now = datetime.now(UTC)
with repository._session_factory.begin() as session:
session.execute(
GlobalAccountModel.__table__.insert(),
[
{
"id": "identity-account-unicode-upper",
"email": "\u00dcser@example.com",
"display_name": "Unicode Upper",
"status": "active",
"email_verified_at": now,
"created_at": now,
"updated_at": now,
},
{
"id": "identity-account-unicode-lower",
"email": "\u00fcser@example.com",
"display_name": "Unicode Lower",
"status": "active",
"email_verified_at": now,
"created_at": now,
"updated_at": now,
},
{
"id": "identity-account-nbsp",
"email": "\u00a0spacing@example.com\u00a0",
"display_name": "NBSP Spacing",
"status": "active",
"email_verified_at": now,
"created_at": now,
"updated_at": now,
},
{
"id": "identity-account-tab",
"email": "\tSPACING@EXAMPLE.COM\t",
"display_name": "Tab Spacing",
"status": "active",
"email_verified_at": now,
"created_at": now,
"updated_at": now,
},
],
)
page = repository.list_account_conflicts(page=1, page_size=20)
by_email = {item.normalized_email: item for item in page.items}
assert "duplicate_global_accounts" in by_email["\u00fcser@example.com"].conflict_codes
assert "duplicate_global_accounts" in by_email["spacing@example.com"].conflict_codes
def test_account_conflicts_use_sensitive_s3_access_policy() -> None:
policy = match_policy(
"GET",
@@ -210,3 +271,469 @@ def test_account_conflicts_use_sensitive_s3_access_policy() -> None:
assert policy.route_key == "identity.account_conflicts"
assert policy.data_class == "S3"
assert policy.sensitive is True
def test_sql_conflict_candidates_bound_orm_loading_to_conflict_graph(
tmp_path: Path,
) -> None:
app = create_app(make_settings(tmp_path))
repository = app.state.contract_repository.identity
now = datetime.now(UTC)
healthy_count = 300
with repository._session_factory.begin() as session:
source_credential = session.execute(
select(GlobalAccountCredentialModel).where(
GlobalAccountCredentialModel.account_id.is_not(None)
)
).scalars().first()
assert source_credential is not None
tenant_rows = []
user_rows = []
account_rows = []
membership_rows = []
credential_rows = []
for index in range(healthy_count):
suffix = f"{index:04d}"
tenant_id = f"tenant-scale-{suffix}"
user_id = f"user-scale-{suffix}"
account_id = f"identity-account-scale-{suffix}"
email = f"scale-{suffix}@example.com"
tenant_rows.append(
{
"id": tenant_id,
"name": f"Scale Workspace {suffix}",
"workspace_type": "organization",
"owner_user_id": user_id,
"legal_name": f"Scale Workspace {suffix}",
"status": "active",
"plan": "trial",
"region": "cn",
"settings": {},
"created_by": "scale-test",
"updated_by": "scale-test",
"deleted_at": None,
"created_at": now,
"updated_at": now,
}
)
user_rows.append(
{
"id": user_id,
"tenant_id": tenant_id,
"email": email,
"display_name": f"Scale User {suffix}",
"role": "business",
"status": "active",
"title": None,
"department": None,
"phone": None,
"platform_permissions": [],
"settings": {},
"invited_at": now,
"last_login_at": None,
"created_by": "scale-test",
"updated_by": "scale-test",
"deleted_at": None,
"created_at": now,
"updated_at": now,
}
)
account_rows.append(
{
"id": account_id,
"email": email,
"display_name": f"Scale User {suffix}",
"status": "active",
"email_verified_at": now,
"created_at": now,
"updated_at": now,
}
)
membership_rows.append(
{
"id": f"workspace-membership-scale-{suffix}",
"account_id": account_id,
"tenant_id": tenant_id,
"tenant_user_id": user_id,
"role": "business",
"status": "active",
"is_owner": True,
"joined_at": now,
"created_at": now,
"updated_at": now,
}
)
credential_rows.append(
{
"id": f"identity-credential-scale-{suffix}",
"account_id": account_id,
"password_hash": source_credential.password_hash,
"password_scheme": source_credential.password_scheme,
"password_updated_at": now,
"status": "active",
"created_at": now,
"updated_at": now,
}
)
session.execute(TenantModel.__table__.insert(), tenant_rows)
session.execute(TenantUserModel.__table__.insert(), user_rows)
session.execute(GlobalAccountModel.__table__.insert(), account_rows)
session.execute(WorkspaceMembershipModel.__table__.insert(), membership_rows)
session.execute(
GlobalAccountCredentialModel.__table__.insert(), credential_rows
)
healthy_page = repository.list_account_conflicts(page=1, page_size=20)
assert healthy_page.total == 0
with repository._session_factory.begin() as session:
final_membership = session.get(
WorkspaceMembershipModel,
f"workspace-membership-scale-{healthy_count - 1:04d}",
)
assert final_membership is not None
final_membership.role = "observer"
loaded_models: Counter[str] = Counter()
select_statements: list[str] = []
def capture_loaded(_session, instance) -> None:
loaded_models[type(instance).__name__] += 1
def capture_statement(
_connection,
_cursor,
statement,
_parameters,
_context,
_executemany,
) -> None:
if statement.lstrip().upper().startswith("SELECT"):
select_statements.append(statement)
session_class = repository._session_factory.class_
event.listen(session_class, "loaded_as_persistent", capture_loaded)
event.listen(repository._session_factory.kw["bind"], "before_cursor_execute", capture_statement)
try:
page = repository.list_account_conflicts(page=1, page_size=20)
finally:
event.remove(session_class, "loaded_as_persistent", capture_loaded)
event.remove(
repository._session_factory.kw["bind"],
"before_cursor_execute",
capture_statement,
)
assert page.total == 1
assert page.warning_total == 1
assert page.critical_total == 0
assert page.items[0].normalized_email == "scale-0299@example.com"
assert page.items[0].conflict_codes == ["membership_state_drift"]
assert len(select_statements) <= 25
assert sum(loaded_models.values()) <= 8
assert loaded_models["GlobalAccountModel"] <= 1
assert loaded_models["TenantUserModel"] <= 1
assert loaded_models["WorkspaceMembershipModel"] <= 1
assert loaded_models["GlobalAccountCredentialModel"] <= 1
assert loaded_models["TenantModel"] <= 1
def test_orphan_identity_rows_are_detected_without_secret_material(
tmp_path: Path,
) -> None:
app = create_app(make_settings(tmp_path))
client = TestClient(app)
repository = app.state.contract_repository.identity
now = datetime.now(UTC)
with repository._session_factory.begin() as session:
source_credential = session.execute(
select(GlobalAccountCredentialModel)
).scalars().first()
assert source_credential is not None
session.execute(
WorkspaceMembershipModel.__table__.insert(),
{
"id": "workspace-membership-orphan",
"account_id": "identity-account-missing",
"tenant_id": "tenant-missing",
"tenant_user_id": "user-missing",
"role": "business",
"status": "active",
"is_owner": False,
"joined_at": now,
"created_at": now,
"updated_at": now,
},
)
session.execute(
GlobalAccountCredentialModel.__table__.insert(),
{
"id": "identity-credential-orphan-global",
"account_id": "identity-account-missing-global-credential",
"password_hash": source_credential.password_hash,
"password_scheme": source_credential.password_scheme,
"password_updated_at": now,
"status": "active",
"created_at": now,
"updated_at": now,
},
)
session.execute(
TenantUserCredentialModel.__table__.insert(),
{
"id": "identity-credential-orphan-legacy",
"tenant_id": "tenant-missing-legacy-credential",
"user_id": "user-missing-legacy-credential",
"email": "orphan-legacy@example.com",
"password_hash": source_credential.password_hash,
"password_scheme": source_credential.password_scheme,
"password_updated_at": now,
"password_reset_required": False,
"email_verified_at": now,
"created_by": "conflict-test",
"updated_by": "conflict-test",
"deleted_at": None,
"created_at": now,
"updated_at": now,
},
)
response = client.get(
"/api/v1/identity/account-conflicts?page_size=100",
headers=platform_headers(),
)
assert response.status_code == 200
payload = response.json()
conflict_codes = {
code
for item in payload["items"]
for code in item["conflict_codes"]
}
assert "orphan_membership" in conflict_codes
assert "credential_identity_mismatch" in conflict_codes
serialized = json.dumps(payload, ensure_ascii=False)
assert source_credential.password_hash not in serialized
for forbidden_key in (
"password_hash",
"credential_hash",
"token_hash",
"code_hash",
"dev_code",
):
assert forbidden_key not in serialized
def test_multiple_legacy_credentials_without_global_credential_are_detected(
tmp_path: Path,
) -> None:
app = create_app(make_settings(tmp_path))
repository = app.state.contract_repository.identity
now = datetime.now(UTC)
shared_email = "legacy-multi@example.com"
with repository._session_factory.begin() as session:
source_credential = session.execute(
select(GlobalAccountCredentialModel)
).scalars().first()
assert source_credential is not None
session.execute(
GlobalAccountModel.__table__.insert(),
{
"id": "identity-account-legacy-multi",
"email": shared_email,
"display_name": "Legacy Multi User",
"status": "active",
"email_verified_at": now,
"created_at": now,
"updated_at": now,
},
)
for index in range(2):
tenant_id = f"tenant-legacy-multi-{index}"
user_id = f"user-legacy-multi-{index}"
session.execute(
TenantModel.__table__.insert(),
{
"id": tenant_id,
"name": f"Legacy Multi Workspace {index}",
"workspace_type": "organization",
"owner_user_id": user_id,
"legal_name": f"Legacy Multi Workspace {index}",
"status": "active",
"plan": "trial",
"region": "cn",
"settings": {},
"created_by": "conflict-test",
"updated_by": "conflict-test",
"deleted_at": None,
"created_at": now,
"updated_at": now,
},
)
session.execute(
TenantUserModel.__table__.insert(),
{
"id": user_id,
"tenant_id": tenant_id,
"email": shared_email,
"display_name": f"Legacy Multi User {index}",
"role": "business",
"status": "active",
"title": None,
"department": None,
"phone": None,
"platform_permissions": [],
"settings": {},
"invited_at": now,
"last_login_at": None,
"created_by": "conflict-test",
"updated_by": "conflict-test",
"deleted_at": None,
"created_at": now,
"updated_at": now,
},
)
session.execute(
WorkspaceMembershipModel.__table__.insert(),
{
"id": f"workspace-membership-legacy-multi-{index}",
"account_id": "identity-account-legacy-multi",
"tenant_id": tenant_id,
"tenant_user_id": user_id,
"role": "business",
"status": "active",
"is_owner": True,
"joined_at": now,
"created_at": now,
"updated_at": now,
},
)
session.execute(
TenantUserCredentialModel.__table__.insert(),
{
"id": f"identity-credential-legacy-multi-{index}",
"tenant_id": tenant_id,
"user_id": user_id,
"email": shared_email,
"password_hash": source_credential.password_hash,
"password_scheme": source_credential.password_scheme,
"password_updated_at": now,
"password_reset_required": False,
"email_verified_at": now,
"created_by": "conflict-test",
"updated_by": "conflict-test",
"deleted_at": None,
"created_at": now,
"updated_at": now,
},
)
page = repository.list_account_conflicts(page=1, page_size=20)
assert page.total == 1
assert page.warning_total == 1
assert page.critical_total == 0
assert page.items[0].normalized_email == shared_email
assert page.items[0].conflict_codes == [
"legacy_multi_credential_unified_missing"
]
def test_conflict_candidate_graph_expansion_terminates_and_is_stable(
tmp_path: Path,
) -> None:
app = create_app(make_settings(tmp_path))
repository = app.state.contract_repository.identity
now = datetime.now(UTC)
with repository._session_factory.begin() as session:
session.execute(
GlobalAccountModel.__table__.insert(),
{
"id": "identity-account-mismatched-membership",
"email": "mismatched-membership@example.com",
"display_name": "Mismatched Membership",
"status": "active",
"email_verified_at": now,
"created_at": now,
"updated_at": now,
},
)
membership = session.execute(
select(WorkspaceMembershipModel).where(
WorkspaceMembershipModel.tenant_user_id == "user-current-admin"
)
).scalar_one()
membership.account_id = "identity-account-mismatched-membership"
first = repository.list_account_conflicts(page=1, page_size=20)
second = repository.list_account_conflicts(page=1, page_size=20)
assert first.total == 1
assert "membership_identity_mismatch" in first.items[0].conflict_codes
assert first.model_dump(mode="json") == second.model_dump(mode="json")
def test_high_conflict_cardinality_keeps_select_bindings_bounded(
tmp_path: Path,
) -> None:
app = create_app(make_settings(tmp_path))
repository = app.state.contract_repository.identity
now = datetime.now(UTC)
conflict_count = 1_001
with repository._session_factory.begin() as session:
session.execute(
TenantUserModel.__table__.insert(),
[
{
"id": f"user-high-conflict-{index:04d}",
"tenant_id": "default",
"email": f"high-conflict-{index:04d}@example.com",
"display_name": f"High Conflict {index:04d}",
"role": "business",
"status": "active",
"title": None,
"department": None,
"phone": None,
"platform_permissions": [],
"settings": {},
"invited_at": now,
"last_login_at": None,
"created_by": "conflict-test",
"updated_by": "conflict-test",
"deleted_at": None,
"created_at": now,
"updated_at": now,
}
for index in range(conflict_count)
],
)
select_parameter_counts: list[int] = []
def capture_statement(
_connection,
_cursor,
statement,
parameters,
_context,
_executemany,
) -> None:
if statement.lstrip().upper().startswith("SELECT"):
select_parameter_counts.append(len(parameters))
engine = repository._session_factory.kw["bind"]
event.listen(engine, "before_cursor_execute", capture_statement)
try:
page = repository.list_account_conflicts(page=1, page_size=20)
finally:
event.remove(engine, "before_cursor_execute", capture_statement)
assert page.total == conflict_count
assert select_parameter_counts
assert max(select_parameter_counts) <= 900
@@ -0,0 +1,338 @@
import smtplib
import socketserver
import ssl
import threading
from datetime import UTC, datetime, timedelta
from email import policy
from email.parser import BytesParser
from pathlib import Path
import pytest
from yuqei_contract_api.config import ContractApiSettings, check_runtime_config
from yuqei_contract_api.identity import EmailChallengeItem
from yuqei_contract_api.identity_email_delivery import (
IdentityEmailDeliveryError,
build_identity_email_delivery,
)
def test_local_fake_smtp_accepts_a_real_smtplib_protocol_exchange() -> None:
server = _LocalSmtpServer(("127.0.0.1", 0), _LocalSmtpHandler)
server.commands = []
server.messages = []
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
delivery = build_identity_email_delivery(
_smtp_settings(
identity_smtp_host="127.0.0.1",
identity_smtp_port=server.server_address[1],
)
)
delivery.send_challenge(_challenge())
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
assert len(server.messages) == 1
commands = [command.upper() for command in server.commands]
assert any(command.startswith("EHLO ") for command in commands)
assert any(command.startswith("MAIL FROM:") for command in commands)
assert any(command.startswith("RCPT TO:") for command in commands)
assert "DATA" in commands
parsed = BytesParser(policy=policy.default).parsebytes(server.messages[0])
assert parsed["From"].addresses[0].addr_spec == "mailer@example.com"
assert parsed["To"].addresses[0].addr_spec == "owner@example.com"
assert parsed["Auto-Submitted"] == "auto-generated"
assert parsed["X-Auto-Response-Suppress"] == "All"
assert parsed["Date"]
assert parsed["Message-ID"]
assert "123456" in parsed.get_body(preferencelist=("plain",)).get_content()
def test_starttls_authentication_sequence_uses_password_file(tmp_path: Path, monkeypatch) -> None:
events: list[str] = []
password_file = tmp_path / "smtp-password"
password_file.write_text("file-password\n", encoding="utf-8")
class RecordingSmtp(_RecordingSmtp):
def ehlo(self) -> None:
events.append("ehlo")
def starttls(self, *, context) -> None:
assert isinstance(context, ssl.SSLContext)
events.append("starttls")
def login(self, username: str, password: str) -> None:
assert username == "mailer@example.com"
assert password == "file-password"
events.append("login")
def send_message(self, message) -> None:
events.append("send")
monkeypatch.setattr("yuqei_contract_api.identity_email_delivery.smtplib.SMTP", RecordingSmtp)
delivery = build_identity_email_delivery(
_smtp_settings(
identity_smtp_username="mailer@example.com",
identity_smtp_password_file=str(password_file),
identity_smtp_use_starttls=True,
)
)
assert "file-password" not in repr(delivery)
delivery.send_challenge(_challenge())
assert events == ["ehlo", "starttls", "ehlo", "login", "send"]
def test_implicit_ssl_uses_smtp_ssl_without_starttls(monkeypatch) -> None:
events: list[str] = []
class RecordingSmtpSsl(_RecordingSmtp):
def __init__(self, host: str, port: int, *, timeout: float, context) -> None:
super().__init__(host, port, timeout=timeout)
assert isinstance(context, ssl.SSLContext)
events.append("smtp_ssl")
def send_message(self, message) -> None:
events.append("send")
def fail_plain_smtp(*args, **kwargs):
raise AssertionError("Plain SMTP must not be created in implicit SSL mode.")
monkeypatch.setattr("yuqei_contract_api.identity_email_delivery.smtplib.SMTP_SSL", RecordingSmtpSsl)
monkeypatch.setattr("yuqei_contract_api.identity_email_delivery.smtplib.SMTP", fail_plain_smtp)
delivery = build_identity_email_delivery(
_smtp_settings(identity_smtp_use_ssl=True)
)
delivery.send_challenge(_challenge())
assert events == ["smtp_ssl", "send"]
def test_smtp_failures_and_invalid_headers_are_redacted(monkeypatch) -> None:
marker = "provider-private-detail"
class FailingSmtp(_RecordingSmtp):
def send_message(self, message) -> None:
raise smtplib.SMTPDataError(554, marker.encode())
monkeypatch.setattr("yuqei_contract_api.identity_email_delivery.smtplib.SMTP", FailingSmtp)
delivery = build_identity_email_delivery(_smtp_settings())
with pytest.raises(IdentityEmailDeliveryError) as failure:
delivery.send_challenge(_challenge())
assert str(failure.value) == "SMTP delivery failed."
assert marker not in str(failure.value)
injected = _challenge().model_copy(
update={"email": "owner@example.com\r\nBcc: hidden@example.com"}
)
with pytest.raises(IdentityEmailDeliveryError, match="email address is invalid"):
delivery.send_challenge(injected)
def test_smtp_unicode_failures_use_the_standard_delivery_error(monkeypatch) -> None:
class UnicodeFailingSmtp(_RecordingSmtp):
def send_message(self, message) -> None:
raise UnicodeEncodeError("ascii", "非", 0, 1, "SMTP transport encoding failed")
monkeypatch.setattr(
"yuqei_contract_api.identity_email_delivery.smtplib.SMTP",
UnicodeFailingSmtp,
)
delivery = build_identity_email_delivery(_smtp_settings())
with pytest.raises(IdentityEmailDeliveryError) as failure:
delivery.send_challenge(_challenge())
assert str(failure.value) == "SMTP delivery failed."
assert isinstance(failure.value.__cause__, UnicodeError)
def test_smtp_password_sources_and_production_tls_are_preflighted(tmp_path: Path) -> None:
password_file = tmp_path / "smtp-password"
password_file.write_text("file-password\n", encoding="utf-8")
empty_password_file = tmp_path / "empty-password"
empty_password_file.write_text("\n", encoding="utf-8")
valid = _production_smtp_settings(
identity_smtp_username="mailer@example.com",
identity_smtp_password_file=str(password_file),
)
assert _identity_delivery_check(valid).status == "ok"
plaintext = valid.model_copy(
update={"identity_smtp_use_starttls": False, "identity_smtp_use_ssl": False}
)
assert "smtp_tls_mode" in _identity_delivery_check(plaintext).details["invalid_fields"]
dual_source = _production_smtp_settings(
identity_smtp_username="mailer@example.com",
identity_smtp_password="environment-password",
identity_smtp_password_file=str(password_file),
)
assert "smtp_password_source" in _identity_delivery_check(dual_source).details["invalid_fields"]
with pytest.raises(IdentityEmailDeliveryError, match="exactly one configured source"):
build_identity_email_delivery(dual_source)
no_username = valid.model_copy(update={"identity_smtp_username": ""})
assert "smtp_username" in _identity_delivery_check(no_username).details["invalid_fields"]
empty_file = valid.model_copy(
update={"identity_smtp_password_file": str(empty_password_file)}
)
assert "smtp_password_file" in _identity_delivery_check(empty_file).details["invalid_fields"]
with pytest.raises(IdentityEmailDeliveryError, match="password file is empty"):
build_identity_email_delivery(empty_file)
def test_production_preflight_rejects_header_injection() -> None:
settings = _production_smtp_settings(
identity_smtp_from_name="Contract Platform\r\nBcc: hidden@example.com"
)
check = _identity_delivery_check(settings)
assert check.status == "error"
assert "smtp_from_name" in check.details["invalid_fields"]
with pytest.raises(IdentityEmailDeliveryError, match="header configuration is invalid"):
build_identity_email_delivery(settings)
@pytest.mark.parametrize(
"public_url",
["http://contracts.example.com", "https://localhost:3000", "https://127.0.0.1"],
)
def test_production_smtp_runtime_rejects_insecure_public_urls(public_url: str) -> None:
settings = _production_smtp_settings(
identity_public_web_base_url=public_url,
)
assert "public_web_base_url" in _identity_delivery_check(settings).details["invalid_fields"]
with pytest.raises(IdentityEmailDeliveryError, match="public web URL is invalid"):
build_identity_email_delivery(settings)
def _smtp_settings(**overrides) -> ContractApiSettings:
values = {
"environment": "test",
"identity_email_delivery_mode": "smtp",
"identity_smtp_host": "smtp.example.com",
"identity_smtp_port": 2525,
"identity_smtp_from_email": "mailer@example.com",
"identity_smtp_use_starttls": False,
"identity_smtp_use_ssl": False,
"identity_smtp_timeout_seconds": 2,
"identity_public_web_base_url": "http://contracts.local",
}
values.update(overrides)
return ContractApiSettings(**values)
def _production_smtp_settings(**overrides) -> ContractApiSettings:
values = {
"environment": "production",
"identity_email_delivery_mode": "smtp",
"identity_smtp_host": "smtp.example.com",
"identity_smtp_port": 587,
"identity_smtp_from_email": "mailer@example.com",
"identity_smtp_use_starttls": True,
"identity_smtp_use_ssl": False,
"identity_public_web_base_url": "https://contracts.example.com",
}
values.update(overrides)
return ContractApiSettings(**values)
def _identity_delivery_check(settings: ContractApiSettings):
return next(
item for item in check_runtime_config(settings).checks
if item.name == "identity_email_delivery"
)
def _challenge() -> EmailChallengeItem:
now = datetime.now(UTC)
return EmailChallengeItem(
id="email-code-protocol-test",
tenant_id="tenant-email",
user_id="user-email",
email="owner@example.com",
purpose="email_verification",
status="pending",
expires_at=now + timedelta(minutes=10),
sent_at=now,
verified_at=None,
dev_code="123456",
created_at=now,
updated_at=now,
)
class _RecordingSmtp:
def __init__(self, host: str, port: int, *, timeout: float) -> None:
self.host = host
self.port = port
self.timeout = timeout
def __enter__(self):
return self
def __exit__(self, exc_type, exc, traceback) -> None:
return None
def ehlo(self) -> None:
return None
def starttls(self, *, context) -> None:
return None
def login(self, username: str, password: str) -> None:
return None
def send_message(self, message) -> None:
return None
class _LocalSmtpServer(socketserver.ThreadingTCPServer):
allow_reuse_address = True
daemon_threads = True
commands: list[str]
messages: list[bytes]
class _LocalSmtpHandler(socketserver.StreamRequestHandler):
def handle(self) -> None:
self._reply("220 local-smtp ESMTP ready")
while line := self.rfile.readline(65_537):
command = line.decode("ascii", errors="replace").rstrip("\r\n")
self.server.commands.append(command) # type: ignore[attr-defined]
upper = command.upper()
if upper.startswith("EHLO"):
self.wfile.write(b"250-local-smtp\r\n250 SIZE 1048576\r\n")
self.wfile.flush()
elif upper.startswith(("MAIL FROM:", "RCPT TO:")):
self._reply("250 accepted")
elif upper == "DATA":
self._reply("354 end with <CRLF>.<CRLF>")
payload: list[bytes] = []
while data_line := self.rfile.readline(65_537):
if data_line in {b".\r\n", b".\n"}:
break
payload.append(data_line[1:] if data_line.startswith(b"..") else data_line)
self.server.messages.append(b"".join(payload)) # type: ignore[attr-defined]
self._reply("250 queued")
elif upper == "QUIT":
self._reply("221 bye")
return
else:
self._reply("250 ok")
def _reply(self, value: str) -> None:
self.wfile.write(f"{value}\r\n".encode("ascii"))
self.wfile.flush()
+5 -1
View File
@@ -77,14 +77,18 @@ CONTRACT_API_IDENTITY_EMAIL_DELIVERY_MODE=disabled
CONTRACT_API_IDENTITY_SELF_SERVICE_ENABLED=false
CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED=true
# SMTP settings are retained for a future switch to CONTRACT_API_IDENTITY_EMAIL_DELIVERY_MODE=smtp.
# Add docker-compose.production.smtp.yml only when SMTP is enabled. It mounts the host file below read-only.
CONTRACT_API_IDENTITY_SMTP_HOST=smtp.example.com
CONTRACT_API_IDENTITY_SMTP_PORT=587
CONTRACT_API_IDENTITY_SMTP_USERNAME=mailer@example.com
CONTRACT_API_IDENTITY_SMTP_PASSWORD=CHANGE_ME_LOAD_FROM_PROTECTED_ENV
CONTRACT_API_IDENTITY_SMTP_PASSWORD=
CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE=/run/secrets/contract_smtp_password
CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE_HOST=/etc/yuqei/secrets/contract-smtp-password
CONTRACT_API_IDENTITY_SMTP_FROM_EMAIL=mailer@example.com
CONTRACT_API_IDENTITY_SMTP_FROM_NAME=AI 合同平台
CONTRACT_API_IDENTITY_SMTP_USE_STARTTLS=true
CONTRACT_API_IDENTITY_SMTP_USE_SSL=false
# STARTTLS and implicit SSL are mutually exclusive; production rejects plaintext SMTP.
CONTRACT_API_IDENTITY_SMTP_TIMEOUT_SECONDS=10
CONTRACT_API_IDENTITY_PUBLIC_WEB_BASE_URL=https://contracts.example.com
CONTRACT_API_IDENTITY_RATE_LIMIT_SECRET=CHANGE_ME_USE_A_RANDOM_32_BYTE_SECRET
@@ -0,0 +1,12 @@
services:
contract-api:
environment:
CONTRACT_API_IDENTITY_SMTP_PASSWORD: ""
CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE: /run/secrets/contract_smtp_password
secrets:
- source: contract_smtp_password
target: contract_smtp_password
secrets:
contract_smtp_password:
file: ${CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE_HOST:?CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE_HOST must point to a protected SMTP password file}
@@ -23,6 +23,7 @@ services:
contract-api:
image: ${CONTRACT_API_IMAGE:?CONTRACT_API_IMAGE must reference an immutable production image}
pull_policy: always
# SMTP remains opt-in through docker-compose.production.smtp.yml so disabled mode has no secret-file dependency.
volumes:
- contract-files:/data/contract-files
+18 -1
View File
@@ -6,6 +6,7 @@ The production backend runs on `192.168.3.90`; Contract Web is deployed separate
- `../compose/docker-compose.yml`: shared service definitions used by local and smoke environments.
- `../compose/docker-compose.production.yml`: backend-only production image, volume, TLS edge, and backup-helper overrides.
- `../compose/docker-compose.production.smtp.yml`: optional SMTP password secret mount; include only when email delivery mode is `smtp`.
- `../compose/.env.production.example`: reviewed variable inventory. The real file belongs at `/etc/yuqei/production.env`, mode `0600`, outside the repository.
- `preflight-production.py`: read-only production configuration gate.
@@ -14,7 +15,7 @@ The production backend runs on `192.168.3.90`; Contract Web is deployed separate
1. Create an immutable RC commit and publish the three application images by digest. Pin PostgreSQL/pgvector, Redis, MinIO, mc, Nginx, and the backup helper by digest as well.
2. Create a new production PostgreSQL/volume set. Do not reuse or rename the smoke database or volumes.
3. Mount an off-host backup filesystem at `BACKUP_HOST_DIR`.
4. Place `tls.crt`, `tls.key`, and the AI provider key in protected host directories.
4. Place `tls.crt`, `tls.key`, and the AI provider key in protected host directories. When enabling SMTP, also create the file named by `CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE_HOST`, mode `0600`.
5. Copy `.env.production.example` to `/etc/yuqei/production.env`, replace every placeholder, and keep mode `0600`.
6. Run the read-only gate:
@@ -44,6 +45,22 @@ docker compose \
These commands do not start containers and do not switch traffic.
## Optional SMTP secret
The default production example keeps identity email delivery `disabled`; it does not require an SMTP password file or the SMTP compose overlay. To enable SMTP, set the SMTP mode and related transport fields in the protected production env file, set manual invitations to `false`, then append the opt-in override to every production Compose command:
```bash
docker compose \
--project-name yuqei-v2-production \
--env-file /etc/yuqei/production.env \
-f yuqei-infra/compose/docker-compose.yml \
-f yuqei-infra/compose/docker-compose.production.yml \
-f yuqei-infra/compose/docker-compose.production.smtp.yml \
config --quiet
```
The override mounts `CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE_HOST` as `/run/secrets/contract_smtp_password` and clears the direct password environment variable. Do not include this override in disabled-mode deployments. The preflight gate checks that an authenticated SMTP configuration uses an absolute, non-empty, owner-only host secret file and an encrypted transport mode.
## Provider secret and model config
The provider key file is mounted at `/run/secrets/ai_provider_api_key`. The enabled default model config must use:
+172 -4
View File
@@ -10,6 +10,7 @@ import re
import stat
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from email.headerregistry import Address
from pathlib import Path
from urllib.parse import urlparse
@@ -87,9 +88,6 @@ def validate_environment(values: dict[str, str], *, template_mode: bool = False)
"CONTRACT_API_SEED_DEMO_DATA": "false",
"CONTRACT_REVIEW_WORKER_SEED_DEMO_DATA": "false",
"CONTRACT_API_REQUIRE_WEB_PROXY_AUTH": "true",
"CONTRACT_API_IDENTITY_EMAIL_DELIVERY_MODE": "disabled",
"CONTRACT_API_IDENTITY_SELF_SERVICE_ENABLED": "false",
"CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED": "true",
"CONTRACT_WEB_SESSION_SECURE": "true",
"AI_PLATFORM_ENVIRONMENT": "production",
"AI_PLATFORM_DATABASE_AUTO_CREATE": "false",
@@ -106,6 +104,7 @@ def validate_environment(values: dict[str, str], *, template_mode: bool = False)
if values.get(key, "").strip().lower() != expected
]
checks.append(_result("production_modes", not invalid_exact, "Production modes are strict." if not invalid_exact else f"Invalid production values: {', '.join(invalid_exact)}."))
checks.append(_validate_identity_email_delivery(values))
placeholders = [
key
@@ -186,7 +185,13 @@ def validate_environment(values: dict[str, str], *, template_mode: bool = False)
def validate_files(values: dict[str, str], *, template_mode: bool = False) -> list[Check]:
if template_mode:
return [Check("protected_files", "warning", "Template mode skipped TLS, provider key, and backup mount checks.")]
return [
Check(
"protected_files",
"warning",
"Template mode skipped TLS, provider key, optional SMTP secret, and backup mount checks.",
)
]
checks: list[Check] = []
tls_dir = Path(values.get("BACKEND_TLS_CERT_DIR", ""))
tls_files = (tls_dir / "tls.crt", tls_dir / "tls.key")
@@ -200,6 +205,41 @@ def validate_files(values: dict[str, str], *, template_mode: bool = False) -> li
secure_mode = mode & 0o077 == 0
checks.append(_result("provider_key_permissions", secure_mode, "Provider key file is owner-only." if secure_mode else f"Provider key file mode {oct(mode)} exposes group or other permissions."))
email_mode = values.get("CONTRACT_API_IDENTITY_EMAIL_DELIVERY_MODE", "").strip().lower()
smtp_username = values.get("CONTRACT_API_IDENTITY_SMTP_USERNAME", "").strip()
if email_mode == "smtp" and smtp_username:
smtp_password_file = Path(values.get("CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE_HOST", ""))
smtp_password_ok = smtp_password_file.is_file() and 0 < smtp_password_file.stat().st_size <= 16 * 1024
checks.append(
_result(
"smtp_password_file",
smtp_password_ok,
"SMTP password file is present."
if smtp_password_ok
else "SMTP password file is missing, empty, or unexpectedly large.",
)
)
if smtp_password_ok and os.name != "nt":
mode = stat.S_IMODE(smtp_password_file.stat().st_mode)
secure_mode = mode & 0o077 == 0
checks.append(
_result(
"smtp_password_permissions",
secure_mode,
"SMTP password file is owner-only."
if secure_mode
else f"SMTP password file mode {oct(mode)} exposes group or other permissions.",
)
)
else:
checks.append(
_result(
"smtp_password_file",
True,
"SMTP password file is not required for the selected identity email mode.",
)
)
backup_root = Path(values.get("BACKUP_HOST_DIR", ""))
require_mount = values.get("BACKUP_REQUIRE_SEPARATE_MOUNT", "true").lower() == "true"
backup_mount_ok = backup_root.is_dir() and (not require_mount or os.path.ismount(backup_root))
@@ -207,6 +247,134 @@ def validate_files(values: dict[str, str], *, template_mode: bool = False) -> li
return checks
def _validate_identity_email_delivery(values: dict[str, str]) -> Check:
mode = values.get("CONTRACT_API_IDENTITY_EMAIL_DELIVERY_MODE", "").strip().lower()
self_service = values.get("CONTRACT_API_IDENTITY_SELF_SERVICE_ENABLED", "").strip().lower()
manual_invitation = values.get("CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED", "").strip().lower()
if mode == "disabled":
valid = self_service == "false" and manual_invitation == "true"
return _result(
"identity_email_delivery",
valid,
"Administrator-managed disabled identity email mode is configured."
if valid
else "Disabled identity email mode requires self-service=false and manual invitations=true.",
)
if mode != "smtp":
return _result(
"identity_email_delivery",
False,
"Production identity email delivery must use disabled or smtp mode.",
)
raw_username = values.get("CONTRACT_API_IDENTITY_SMTP_USERNAME", "")
username = raw_username.strip()
password_file = values.get("CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE", "").strip()
password_file_host = values.get("CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE_HOST", "").strip()
direct_password = values.get("CONTRACT_API_IDENTITY_SMTP_PASSWORD", "")
raw_starttls = values.get("CONTRACT_API_IDENTITY_SMTP_USE_STARTTLS", "").strip().lower()
raw_use_ssl = values.get("CONTRACT_API_IDENTITY_SMTP_USE_SSL", "").strip().lower()
starttls = raw_starttls == "true"
use_ssl = raw_use_ssl == "true"
raw_public_url = values.get("CONTRACT_API_IDENTITY_PUBLIC_WEB_BASE_URL", "")
public_url = urlparse(raw_public_url)
raw_smtp_host = values.get("CONTRACT_API_IDENTITY_SMTP_HOST", "")
smtp_port = _parse_int(values.get("CONTRACT_API_IDENTITY_SMTP_PORT", ""))
smtp_timeout = _parse_float(
values.get("CONTRACT_API_IDENTITY_SMTP_TIMEOUT_SECONDS", "")
)
invalid: list[str] = []
if self_service not in {"true", "false"}:
invalid.append("CONTRACT_API_IDENTITY_SELF_SERVICE_ENABLED")
if manual_invitation != "false":
invalid.append("CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED")
if (
not raw_smtp_host
or raw_smtp_host != raw_smtp_host.strip()
or any(character.isspace() for character in raw_smtp_host)
or _contains_control_character(raw_smtp_host)
):
invalid.append("CONTRACT_API_IDENTITY_SMTP_HOST")
if smtp_port is None or not 1 <= smtp_port <= 65535:
invalid.append("CONTRACT_API_IDENTITY_SMTP_PORT")
if smtp_timeout is None or not 0 < smtp_timeout <= 120:
invalid.append("CONTRACT_API_IDENTITY_SMTP_TIMEOUT_SECONDS")
if not _is_valid_email_address(
values.get("CONTRACT_API_IDENTITY_SMTP_FROM_EMAIL", "")
):
invalid.append("CONTRACT_API_IDENTITY_SMTP_FROM_EMAIL")
if _contains_control_character(
values.get("CONTRACT_API_IDENTITY_SMTP_FROM_NAME", "")
):
invalid.append("CONTRACT_API_IDENTITY_SMTP_FROM_NAME")
if raw_username != username or _contains_control_character(raw_username):
invalid.append("CONTRACT_API_IDENTITY_SMTP_USERNAME")
if raw_starttls not in {"true", "false"}:
invalid.append("CONTRACT_API_IDENTITY_SMTP_USE_STARTTLS")
if raw_use_ssl not in {"true", "false"}:
invalid.append("CONTRACT_API_IDENTITY_SMTP_USE_SSL")
if starttls == use_ssl:
invalid.append("CONTRACT_API_IDENTITY_SMTP_TLS_MODE")
if direct_password:
invalid.append("CONTRACT_API_IDENTITY_SMTP_PASSWORD")
if username:
if password_file != "/run/secrets/contract_smtp_password":
invalid.append("CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE")
if not password_file_host or not Path(password_file_host).is_absolute():
invalid.append("CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE_HOST")
elif password_file or password_file_host:
invalid.append("CONTRACT_API_IDENTITY_SMTP_USERNAME")
if (
raw_public_url != raw_public_url.strip()
or _contains_control_character(raw_public_url)
or public_url.scheme != "https"
or not public_url.netloc
or public_url.hostname in LOOPBACK_VALUES
):
invalid.append("CONTRACT_API_IDENTITY_PUBLIC_WEB_BASE_URL")
valid = not invalid
return _result(
"identity_email_delivery",
valid,
"SMTP identity email mode uses an encrypted transport and protected password file."
if valid
else f"Invalid SMTP identity email keys: {', '.join(sorted(set(invalid)))}.",
)
def _parse_int(value: str) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
def _parse_float(value: str) -> float | None:
try:
return float(value)
except (TypeError, ValueError):
return None
def _is_valid_email_address(value: str) -> bool:
if (
not value
or value != value.strip()
or any(character.isspace() for character in value)
or _contains_control_character(value)
):
return False
try:
address = Address(addr_spec=value)
except (TypeError, ValueError):
return False
return bool(address.username and address.domain)
def _contains_control_character(value: str) -> bool:
return any(ord(character) < 32 or ord(character) == 127 for character in value)
def _validate_database_urls(values: dict[str, str]) -> list[Check]:
pairs = (
("CONTRACT_API_DATABASE_URL", "CONTRACT_POSTGRES_DB"),
@@ -73,6 +73,116 @@ def test_production_preflight_accepts_strict_configuration() -> None:
assert [check.key for check in checks if check.status == "fail"] == []
def test_disabled_identity_email_does_not_require_smtp_secret_file() -> None:
values = production_values()
values.pop("CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE_HOST", None)
environment_checks = {check.key: check for check in preflight.validate_environment(values)}
file_checks = {check.key: check for check in preflight.validate_files(values)}
assert environment_checks["identity_email_delivery"].status == "pass"
assert file_checks["smtp_password_file"].status == "pass"
def test_smtp_identity_email_requires_and_validates_opt_in_secret_file(tmp_path: Path) -> None:
password_file = tmp_path / "contract-smtp-password"
password_file.write_text("test-smtp-password\n", encoding="utf-8")
password_file.chmod(0o600)
values = production_values()
values.update(
{
"CONTRACT_API_IDENTITY_EMAIL_DELIVERY_MODE": "smtp",
"CONTRACT_API_IDENTITY_SELF_SERVICE_ENABLED": "true",
"CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED": "false",
"CONTRACT_API_IDENTITY_SMTP_HOST": "smtp.mail.acme.cn",
"CONTRACT_API_IDENTITY_SMTP_USERNAME": "mailer@acme.cn",
"CONTRACT_API_IDENTITY_SMTP_PASSWORD": "",
"CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE": "/run/secrets/contract_smtp_password",
"CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE_HOST": str(password_file.resolve()),
"CONTRACT_API_IDENTITY_SMTP_FROM_EMAIL": "mailer@acme.cn",
"CONTRACT_API_IDENTITY_SMTP_USE_STARTTLS": "true",
"CONTRACT_API_IDENTITY_SMTP_USE_SSL": "false",
"CONTRACT_API_IDENTITY_PUBLIC_WEB_BASE_URL": "https://contracts.acme.cn",
}
)
environment_checks = {check.key: check for check in preflight.validate_environment(values)}
file_checks = {check.key: check for check in preflight.validate_files(values)}
assert environment_checks["identity_email_delivery"].status == "pass"
assert file_checks["smtp_password_file"].status == "pass"
if "smtp_password_permissions" in file_checks:
assert file_checks["smtp_password_permissions"].status == "pass"
unencrypted = dict(values)
unencrypted["CONTRACT_API_IDENTITY_SMTP_USE_STARTTLS"] = "false"
unencrypted["CONTRACT_API_IDENTITY_SMTP_USE_SSL"] = "false"
unencrypted_checks = {
check.key: check for check in preflight.validate_environment(unencrypted)
}
assert unencrypted_checks["identity_email_delivery"].status == "fail"
password_file.unlink()
missing_file_checks = {check.key: check for check in preflight.validate_files(values)}
assert missing_file_checks["smtp_password_file"].status == "fail"
@pytest.mark.parametrize(
("key", "value"),
[
("CONTRACT_API_IDENTITY_SMTP_PORT", "0"),
("CONTRACT_API_IDENTITY_SMTP_PORT", "not-a-port"),
("CONTRACT_API_IDENTITY_SMTP_TIMEOUT_SECONDS", "0"),
("CONTRACT_API_IDENTITY_SMTP_TIMEOUT_SECONDS", "121"),
("CONTRACT_API_IDENTITY_SMTP_FROM_EMAIL", "not-an-email"),
("CONTRACT_API_IDENTITY_SMTP_HOST", " smtp.mail.acme.cn"),
("CONTRACT_API_IDENTITY_SMTP_FROM_NAME", "AI Contract\r\nBcc: hidden@example.com"),
("CONTRACT_API_IDENTITY_SMTP_USE_STARTTLS", "sometimes"),
],
)
def test_smtp_preflight_rejects_runtime_invalid_values(
tmp_path: Path,
key: str,
value: str,
) -> None:
password_file = tmp_path / "contract-smtp-password"
password_file.write_text("test-smtp-password\n", encoding="utf-8")
values = production_values()
values.update(
{
"CONTRACT_API_IDENTITY_EMAIL_DELIVERY_MODE": "smtp",
"CONTRACT_API_IDENTITY_SELF_SERVICE_ENABLED": "true",
"CONTRACT_API_IDENTITY_MANUAL_INVITATION_ENABLED": "false",
"CONTRACT_API_IDENTITY_SMTP_HOST": "smtp.mail.acme.cn",
"CONTRACT_API_IDENTITY_SMTP_USERNAME": "mailer@acme.cn",
"CONTRACT_API_IDENTITY_SMTP_PASSWORD": "",
"CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE": "/run/secrets/contract_smtp_password",
"CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE_HOST": str(password_file.resolve()),
"CONTRACT_API_IDENTITY_SMTP_FROM_EMAIL": "mailer@acme.cn",
"CONTRACT_API_IDENTITY_SMTP_USE_STARTTLS": "true",
"CONTRACT_API_IDENTITY_SMTP_USE_SSL": "false",
"CONTRACT_API_IDENTITY_SMTP_TIMEOUT_SECONDS": "10",
"CONTRACT_API_IDENTITY_PUBLIC_WEB_BASE_URL": "https://contracts.acme.cn",
key: value,
}
)
checks = {check.key: check for check in preflight.validate_environment(values)}
assert checks["identity_email_delivery"].status == "fail"
def test_smtp_production_override_mounts_only_the_password_file_secret() -> None:
override = (
REPO_ROOT / "yuqei-infra/compose/docker-compose.production.smtp.yml"
).read_text(encoding="utf-8")
assert "CONTRACT_API_IDENTITY_SMTP_PASSWORD: \"\"" in override
assert "CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE: /run/secrets/contract_smtp_password" in override
assert "target: contract_smtp_password" in override
assert "file: ${CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE_HOST:?" in override
def test_production_preflight_rejects_smoke_database_public_bind_and_fallback() -> None:
values = production_values()
values["CONTRACT_POSTGRES_DB"] = "yuqei_contract_smoke"