diff --git a/project-docs/V2-public-policy-production-promotion-runbook-2026-08-17.md b/project-docs/V2-public-policy-production-promotion-runbook-2026-08-17.md new file mode 100644 index 00000000..3a9ea52f --- /dev/null +++ b/project-docs/V2-public-policy-production-promotion-runbook-2026-08-17.md @@ -0,0 +1,186 @@ +# V2 公开政策生产 Promotion 运维手册 + +日期:2026-08-17 + +## 边界 + +本手册只描述已验签公开政策的受控状态切换。它不替代法务审批、运营审批、容量审批或公网切流审批。候选 keyring、手工修改数据库、HTTP 写接口、政策私钥和共享预发布凭据都不能用于生产激活。 + +`CONTRACT_API_PUBLIC_POLICY_EXECUTOR_ENABLED` 必须保持 `false`。生产读取、注册协议和既有用户重新同意由 Contract API 提供;真正的 promotion 和 rollback 只通过本机受控 CLI 执行,并且不暴露 HTTP 路由。 + +## 前置条件 + +1. Contract API 数据库已经迁移到 `20260812_0046`,`public_policy_state` 存在且状态指针可验证。 +2. 四份政策候选文件、`manifest.json`、具名法务审批文件和 trusted keyring 已由安全/运维安装到生产主机。 +3. 材料目录和文件均为绝对路径,文件所有者可读,未包含 symlink,目录总量不超过 2 MiB。 +4. `CONTRACT_API_PUBLIC_POLICY_RELEASE_DIGEST` 等于当前 Contract API 不可变镜像 digest。 +5. 生产只读 preflight 通过。 +6. 已取得单独的运营激活授权文件。该文件只用于计算 SHA-256,内容不得进入仓库、聊天、日志或 CI artifact。 +7. 已记录 expected generation、current set ID、trace ID、idempotency key 和授权文件 hash。 + +推荐主机布局: + +```text +/etc/yuqei/public-policy/2026-08-12/ + manifest.json + user-terms.md + privacy-policy.md + service-agreement.md + ai-use-notice.md + named-policy-legal-approval.json + policy-legal-keyring.trusted.json +``` + +生产环境模板中的绑定: + +```text +PUBLIC_POLICY_CANDIDATE_DIR_HOST=/etc/yuqei/public-policy/2026-08-12 +PUBLIC_POLICY_LEGAL_APPROVAL_FILE_HOST=/etc/yuqei/public-policy/2026-08-12/named-policy-legal-approval.json +PUBLIC_POLICY_TRUSTED_KEYRING_FILE_HOST=/etc/yuqei/public-policy/2026-08-12/policy-legal-keyring.trusted.json +CONTRACT_API_PUBLIC_POLICY_RELEASE_DIGEST=sha256: +CONTRACT_API_PUBLIC_POLICY_EXECUTOR_ENABLED=false +``` + +## 1. 只读预检 + +在生产主机执行: + +```bash +python3 yuqei-infra/deploy/preflight-production.py \ + --env-file /etc/yuqei/production.env \ + --artifact-dir /var/lib/yuqei/preflight/public-policy +``` + +必须确认以下检查为 `pass`: + +- `public_policy_deployment` +- `public_policy_credential_isolation` +- `public_policy_candidate_dir` +- `public_policy_manifest` +- `public_policy_legal_approval_file` +- `public_policy_trusted_keyring_file` +- 相关 owner-only 权限检查 + +任一失败时停止,不得通过改名、复制、手工改库或跳过 preflight 继续。 + +## 2. 查看当前状态 + +下面的 `COMPOSE_ARGS` 按生产部署手册替换为真实 project、env 和两个 Compose 文件参数: + +```bash +COMPOSE_ARGS=( + --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 +) + +docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps contract-api \ + python -m yuqei_contract_api.public_policy_promotion_cli status +``` + +输出不包含政策正文,只包含状态指针、版本、生效时间、公开路径和每个文件的 SHA-256。 + +## 3. Promotion dry-run + +```bash +docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps contract-api \ + python -m yuqei_contract_api.public_policy_promotion_cli preflight +``` + +显式 promotion dry-run 还必须提供工单上的 expected state: + +```bash +docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps contract-api \ + python -m yuqei_contract_api.public_policy_promotion_cli \ + promote \ + --expected-generation 0 \ + --expected-current-set-id none \ + --idempotency-key policy-promotion-20260812-001 \ + --trace-id ops-policy-promotion-20260812-001 +``` + +Dry-run 不写数据库,也不需要授权文件。 + +## 4. 受控 Promotion + +授权文件通过一次性只读挂载进入容器。不要把该挂载写入生产 Compose 文件: + +```bash +AUTHORIZATION_FILE=/run/yuqei/operations/policy-promotion-20260812-001.txt + +docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps \ + --volume "$AUTHORIZATION_FILE:/run/yuqei/public-policy/activation-authorization.txt:ro" \ + contract-api \ + python -m yuqei_contract_api.public_policy_promotion_cli \ + promote \ + --apply \ + --confirmation I_AUTHORIZE_THIS_EXACT_PUBLIC_POLICY_PROMOTION \ + --authorization-file /run/yuqei/public-policy/activation-authorization.txt \ + --expected-generation 0 \ + --expected-current-set-id none \ + --idempotency-key policy-promotion-20260812-001 \ + --trace-id ops-policy-promotion-20260812-001 +``` + +命令输出中的 `authorization_sha256`、generation、set ID、event ID 和 state hash 必须写入运维证据。重复执行同一命令必须使用完全相同的材料;换任何材料必须换 idempotency key 并重新审批。 + +## 5. 生效后回归 + +到达批准的 `effective_at` 后验证: + +1. `GET /api/v1/public-policies/current` 返回四份政策。 +2. 四份政策页面返回 approved 字节,页面版本和生效时间正确。 +3. 新注册用户必须绑定四个 effective 协议版本。 +4. 既有用户下次登录进入重新同意流程。 +5. 同意提交记录版本、时间和主体。 +6. 审计和 append-only 政策事件链完整。 +7. Contract Web 不出现未发布、接口故障与真实空态混淆。 +8. 生产 health、schema-check、worker、Provider readiness 和核心合同流程保持通过。 + +## 6. 受控 Rollback + +Rollback 只允许指向曾经 active 的历史 policy set,不能伪造或引用不存在的 set。先执行 dry-run: + +```bash +docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps contract-api \ + python -m yuqei_contract_api.public_policy_promotion_cli \ + rollback \ + --target-set-id \ + --expected-generation \ + --expected-current-set-id \ + --idempotency-key policy-rollback- \ + --trace-id ops-policy-rollback- +``` + +确认目标后,使用单独的 rollback 授权文件: + +```bash +ROLLBACK_AUTHORIZATION=/run/yuqei/operations/policy-rollback-.txt + +docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps \ + --volume "$ROLLBACK_AUTHORIZATION:/run/yuqei/public-policy/rollback-authorization.txt:ro" \ + contract-api \ + python -m yuqei_contract_api.public_policy_promotion_cli \ + rollback \ + --apply \ + --confirmation I_AUTHORIZE_THIS_EXACT_PUBLIC_POLICY_ROLLBACK \ + --authorization-file /run/yuqei/public-policy/rollback-authorization.txt \ + --target-set-id \ + --expected-generation \ + --expected-current-set-id \ + --idempotency-key policy-rollback- \ + --trace-id ops-policy-rollback- +``` + +Rollback 也是 append-only 事件,不删除历史政策、同意记录或审计。执行后必须重复第 5 节回归,并通知法务和运营具名复核。 + +## 7. 明确禁止 + +- 禁止把法务私钥、运营授权文件或任何 policy signing key 放入仓库、生产环境文件或 CI artifact。 +- 禁止使用 candidate keyring 直接激活。 +- 禁止把 trusted keyring 的 `policy_activation_authorized=false` 手工改为 true。 +- 禁止直接 UPDATE/DELETE `public_policy_*` 表。 +- 禁止新增 HTTP promotion、rollback 或 execute 路由。 +- 禁止在 preflight、容量、备份、法务或运营审批失败时启动 edge 或切换 DNS。 +- 禁止把 dry-run 或技术验签输出宣称为法务批准或公网切流授权。 \ No newline at end of file diff --git a/yuqei-contract-platform/services/contract-api/pyproject.toml b/yuqei-contract-platform/services/contract-api/pyproject.toml index fab96d1a..9a0cd955 100644 --- a/yuqei-contract-platform/services/contract-api/pyproject.toml +++ b/yuqei-contract-platform/services/contract-api/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ [project.scripts] yuqei-import-v1-legal-fulltext = "yuqei_contract_api.v1_legal_fulltext_import:main" yuqei-bootstrap-identity-admin = "yuqei_contract_api.identity_bootstrap:main" +yuqei-public-policy-promotion = "yuqei_contract_api.public_policy_promotion_cli:main" [build-system] requires = ["hatchling"] diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/public_policy_promotion_cli.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/public_policy_promotion_cli.py new file mode 100644 index 00000000..36d348f2 --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/public_policy_promotion_cli.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import stat +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from sqlalchemy import create_engine +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import sessionmaker + +from yuqei_contract_api.config import ContractApiSettings +from yuqei_contract_api.public_policies import ( + PublicPolicyError, + PublicPolicyRepository, + build_promotion_preflight, +) +from yuqei_contract_api.sqlalchemy_engine import build_engine_kwargs, ensure_sqlite_parent_dir + + +PROMOTION_CONFIRMATION = "I_AUTHORIZE_THIS_EXACT_PUBLIC_POLICY_PROMOTION" +ROLLBACK_CONFIRMATION = "I_AUTHORIZE_THIS_EXACT_PUBLIC_POLICY_ROLLBACK" +AUTHORIZATION_FILE_MAX_BYTES = 64 * 1024 + + +def main( + argv: list[str] | None = None, + *, + repository: PublicPolicyRepository | None = None, + settings: ContractApiSettings | None = None, + checked_at: datetime | None = None, +) -> int: + parser = build_parser() + args = parser.parse_args(argv) + resolved_settings = settings or ContractApiSettings() + if resolved_settings.public_policy_executor_enabled: + print(json.dumps({ + "status": "error", + "error": "The public policy executor flag must remain false; use this named CLI in an approved operations window.", + }, ensure_ascii=False)) + return 1 + if args.command in {"promote", "rollback"} and resolved_settings.environment == "local": + print(json.dumps({ + "status": "error", + "error": "Public policy writes require test, staging, or production environment settings.", + }, ensure_ascii=False)) + return 1 + + engine = None + try: + if repository is None: + database_url = args.database_url or resolved_settings.database_url + ensure_sqlite_parent_dir(database_url) + engine = create_engine(database_url, **build_engine_kwargs(database_url)) + session_factory = sessionmaker(bind=engine, expire_on_commit=False, future=True) + repository = PublicPolicyRepository(session_factory) + + if args.command == "status": + payload = _status_payload(repository) + elif args.command == "preflight": + payload = _preflight_payload(repository, resolved_settings, checked_at=checked_at) + elif args.command == "promote": + payload = _promote_payload( + args, + repository, + resolved_settings, + checked_at=checked_at, + ) + elif args.command == "rollback": + payload = _rollback_payload(args, repository, checked_at=checked_at) + else: # pragma: no cover - argparse rejects this branch. + raise PublicPolicyError(f"Unsupported command: {args.command}") + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + except (PublicPolicyError, ValueError, OSError) as exc: + print(json.dumps({"status": "error", "error": str(exc)}, ensure_ascii=False)) + return 1 + except SQLAlchemyError: + print(json.dumps({"status": "error", "error": "Public policy repository operation failed."}, ensure_ascii=False)) + return 1 + finally: + if engine is not None: + engine.dispose() + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Governed, non-HTTP public policy state operations." + ) + parser.add_argument("--database-url", default=None, help="Override CONTRACT_API_DATABASE_URL.") + subparsers = parser.add_subparsers(dest="command", required=True) + + subparsers.add_parser("status", help="Read the current public policy state without document bodies.") + subparsers.add_parser("preflight", help="Run the read-only verified-policy promotion preflight.") + + promote = subparsers.add_parser("promote", help="Promote one exact verified policy bundle.") + _add_write_arguments(promote, confirmation=PROMOTION_CONFIRMATION) + promote.add_argument( + "--expected-generation", + type=int, + required=True, + help="Generation observed on the approved operations ticket.", + ) + promote.add_argument( + "--expected-current-set-id", + required=True, + help="Current policy set ID from the ticket; use 'none' for the baseline state.", + ) + + rollback = subparsers.add_parser("rollback", help="Append a rollback pointer to a historical policy set.") + _add_write_arguments(rollback, confirmation=ROLLBACK_CONFIRMATION) + rollback.add_argument( + "--target-set-id", + required=True, + help="Previously active policy set to restore.", + ) + rollback.add_argument( + "--expected-generation", + type=int, + required=True, + help="Generation observed on the approved rollback ticket.", + ) + rollback.add_argument( + "--expected-current-set-id", + required=True, + help="Current policy set ID from the ticket.", + ) + return parser + + +def _add_write_arguments(parser: argparse.ArgumentParser, *, confirmation: str) -> None: + parser.add_argument("--apply", action="store_true", help="Write the state change. Omit for a read-only dry run.") + parser.add_argument( + "--confirmation", + default="", + help=f"Required when --apply is used: {confirmation}", + ) + parser.add_argument( + "--authorization-file", + type=Path, + help="Absolute owner-only file whose exact bytes authorize this operation. Its content is never printed.", + ) + parser.add_argument("--idempotency-key", required=True, help="Stable operation key from the approval record.") + parser.add_argument("--trace-id", required=True, help="Stable operations trace ID.") + parser.add_argument( + "--checked-at", + default=None, + help="Optional timezone-aware ISO-8601 time. Defaults to the current UTC time.", + ) + + +def _status_payload(repository: PublicPolicyRepository) -> dict[str, Any]: + state = repository.state() + catalog = repository.current_catalog() + return { + "command": "status", + "status": "ok", + "state": state.model_dump(mode="json"), + "current_catalog": None if catalog is None else { + "version": catalog.version, + "effective_at": catalog.effective_at, + "existing_user_reconsent": catalog.existing_user_reconsent, + "documents": [ + { + "policy_id": document.policy_id, + "version": document.version, + "public_path": document.public_path, + "sha256": document.sha256, + "size_bytes": document.size_bytes, + } + for document in catalog.documents + ], + }, + } + + +def _preflight_payload( + repository: PublicPolicyRepository, + settings: ContractApiSettings, + *, + checked_at: datetime | None, +) -> dict[str, Any]: + result = build_promotion_preflight(settings, repository, checked_at=checked_at) + return { + "command": "preflight", + **result.model_dump(mode="json"), + } + + +def _promote_payload( + args: argparse.Namespace, + repository: PublicPolicyRepository, + settings: ContractApiSettings, + *, + checked_at: datetime | None, +) -> dict[str, Any]: + _require_configured_absolute_policy_paths(settings) + now = _resolve_now(args, checked_at) + state = _require_expected_state( + repository, + expected_generation=args.expected_generation, + expected_current_set_id=args.expected_current_set_id, + ) + preflight = build_promotion_preflight(settings, repository, checked_at=now) + if not args.apply: + return { + "command": "promote", + "mode": "dry_run", + **preflight.model_dump(mode="json"), + } + _require_write_intent(args, confirmation=PROMOTION_CONFIRMATION) + authorization_sha256 = _authorization_sha256(args.authorization_file) + if preflight.current_state != state: + raise PublicPolicyError("Public policy state changed while this command was running.") + snapshot = repository.promote_verified_bundle( + preflight=preflight, + policy_dir=Path(settings.public_policy_candidate_dir), + authorization_sha256=authorization_sha256, + idempotency_key=args.idempotency_key, + trace_id=args.trace_id, + now=now, + approval_file=Path(settings.public_policy_legal_approval_file), + keyring_file=Path(settings.public_policy_trusted_keyring_file), + release_digest=settings.public_policy_release_digest, + ) + return { + "command": "promote", + "status": "promoted", + "authorization_sha256": authorization_sha256, + "trace_id": args.trace_id, + "state": snapshot.model_dump(mode="json"), + } + + +def _rollback_payload( + args: argparse.Namespace, + repository: PublicPolicyRepository, + *, + checked_at: datetime | None, +) -> dict[str, Any]: + now = _resolve_now(args, checked_at) + state = _require_expected_state( + repository, + expected_generation=args.expected_generation, + expected_current_set_id=args.expected_current_set_id, + ) + if not args.apply: + return { + "command": "rollback", + "mode": "dry_run", + "target_set_id": args.target_set_id, + "state": state.model_dump(mode="json"), + } + _require_write_intent(args, confirmation=ROLLBACK_CONFIRMATION) + authorization_sha256 = _authorization_sha256(args.authorization_file) + snapshot = repository.rollback_verified_set( + expected_state=state, + target_set_id=args.target_set_id, + authorization_sha256=authorization_sha256, + idempotency_key=args.idempotency_key, + trace_id=args.trace_id, + now=now, + ) + return { + "command": "rollback", + "status": "rolled_back", + "authorization_sha256": authorization_sha256, + "trace_id": args.trace_id, + "state": snapshot.model_dump(mode="json"), + } + + +def _require_expected_state( + repository: PublicPolicyRepository, + *, + expected_generation: int, + expected_current_set_id: str, +) -> Any: + state = repository.state() + expected_set_id = None if expected_current_set_id.lower() == "none" else expected_current_set_id + if state.generation != expected_generation or state.current_set_id != expected_set_id: + raise PublicPolicyError( + "Current public policy state does not match the approved expected state." + ) + return state + + +def _require_write_intent(args: argparse.Namespace, *, confirmation: str) -> None: + if not args.apply: + raise PublicPolicyError("A write was requested without --apply.") + if args.confirmation != confirmation: + raise PublicPolicyError(f"Confirmation does not match the exact operation: {confirmation}") + if args.authorization_file is None: + raise PublicPolicyError("An absolute owner-only authorization file is required.") + + +def _authorization_sha256(path: Path | None) -> str: + if path is None: + raise PublicPolicyError("An authorization file is required.") + try: + resolved = path.resolve(strict=True) + if not resolved.is_absolute() or not resolved.is_file(): + raise PublicPolicyError("Authorization file must be an absolute file.") + if resolved.stat().st_size <= 0 or resolved.stat().st_size > AUTHORIZATION_FILE_MAX_BYTES: + raise PublicPolicyError("Authorization file must be non-empty and no larger than 64 KiB.") + if os.name != "nt" and stat.S_IMODE(resolved.stat().st_mode) & 0o077: + raise PublicPolicyError("Authorization file must be owner-only.") + return hashlib.sha256(resolved.read_bytes()).hexdigest() + except PublicPolicyError: + raise + except OSError as exc: + raise PublicPolicyError("Authorization file is unavailable.") from exc + + +def _resolve_now(args: argparse.Namespace, fallback: datetime | None) -> datetime: + if fallback is not None: + return fallback + raw = getattr(args, "checked_at", None) + if not raw: + return datetime.now(UTC) + try: + value = datetime.fromisoformat(raw) + except ValueError as exc: + raise PublicPolicyError("checked_at must be a valid ISO-8601 timestamp.") from exc + if value.tzinfo is None: + raise PublicPolicyError("checked_at must include a timezone.") + return value.astimezone(UTC) + + +def _require_configured_absolute_policy_paths(settings: ContractApiSettings) -> None: + labels = { + "candidate directory": Path(settings.public_policy_candidate_dir), + "legal approval file": Path(settings.public_policy_legal_approval_file), + "trusted keyring file": Path(settings.public_policy_trusted_keyring_file), + } + for label, path in labels.items(): + if not path.is_absolute(): + raise PublicPolicyError(f"Configured public policy {label} must be absolute.") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/yuqei-contract-platform/services/contract-api/tests/test_public_policy_promotion.py b/yuqei-contract-platform/services/contract-api/tests/test_public_policy_promotion.py index bc0b5ff4..c256588f 100644 --- a/yuqei-contract-platform/services/contract-api/tests/test_public_policy_promotion.py +++ b/yuqei-contract-platform/services/contract-api/tests/test_public_policy_promotion.py @@ -17,6 +17,11 @@ from yuqei_contract_api.persistence.models import ( PublicPolicyStateEventModel, PublicPolicyStateModel, ) +from yuqei_contract_api.public_policy_promotion_cli import ( + PROMOTION_CONFIRMATION, + ROLLBACK_CONFIRMATION, + main as public_policy_cli_main, +) from yuqei_contract_api.public_policies import ( BASELINE_STATE_HASH, PublicPolicyError, @@ -476,3 +481,228 @@ def test_rollback_rejects_missing_and_current_targets(tmp_path: Path, monkeypatc trace_id="trace-current-target", now=datetime.now(UTC), ) + + +def _cli_settings(policy_dir: Path, approval_file: Path, keyring_file: Path) -> ContractApiSettings: + return ContractApiSettings( + environment="test", + public_policy_candidate_dir=str(policy_dir.resolve()), + public_policy_legal_approval_file=str(approval_file.resolve()), + public_policy_trusted_keyring_file=str(keyring_file.resolve()), + public_policy_release_digest=RELEASE_DIGEST, + ) + + +def test_public_policy_cli_dry_run_and_promotion_require_explicit_authorization( + tmp_path: Path, + monkeypatch, + capsys, +) -> None: + repository, _ = _repository(tmp_path) + policy_dir = tmp_path / "candidate" + policy_dir.mkdir() + effective_at = "2026-08-11T00:00:00+08:00" + approval_file, keyring_file, documents = _evidence(policy_dir, effective_at=effective_at) + _mock_verifier(monkeypatch, version="2026-08-12", effective_at=effective_at, documents=documents) + settings = _cli_settings(policy_dir, approval_file, keyring_file) + + dry_run = public_policy_cli_main( + [ + "--database-url", "sqlite+pysqlite:///:memory:", + "promote", + "--expected-generation", "0", + "--expected-current-set-id", "none", + "--idempotency-key", "policy-cli-v1", + "--trace-id", "policy-cli-trace-v1", + ], + repository=repository, + settings=settings, + checked_at=datetime(2026, 8, 13, tzinfo=UTC), + ) + dry_run_payload = json.loads(capsys.readouterr().out) + assert dry_run == 0 + assert dry_run_payload["mode"] == "dry_run" + assert dry_run_payload["status"] == "pass" + assert repository.state().generation == 0 + + authorization = tmp_path / "activation-authorization.txt" + authorization.write_bytes(b"exact operations authorization") + denied = public_policy_cli_main( + [ + "--database-url", "sqlite+pysqlite:///:memory:", + "promote", + "--apply", + "--confirmation", "NOT_THEExact_CONFIRMATION", + "--authorization-file", str(authorization), + "--expected-generation", "0", + "--expected-current-set-id", "none", + "--idempotency-key", "policy-cli-v1", + "--trace-id", "policy-cli-trace-v1", + ], + repository=repository, + settings=settings, + checked_at=datetime(2026, 8, 13, tzinfo=UTC), + ) + denied_payload = json.loads(capsys.readouterr().out) + assert denied == 1 + assert denied_payload["status"] == "error" + assert repository.state().generation == 0 + + promoted = public_policy_cli_main( + [ + "--database-url", "sqlite+pysqlite:///:memory:", + "promote", + "--apply", + "--confirmation", PROMOTION_CONFIRMATION, + "--authorization-file", str(authorization), + "--expected-generation", "0", + "--expected-current-set-id", "none", + "--idempotency-key", "policy-cli-v1", + "--trace-id", "policy-cli-trace-v1", + ], + repository=repository, + settings=settings, + checked_at=datetime(2026, 8, 13, tzinfo=UTC), + ) + promotion_payload = json.loads(capsys.readouterr().out) + assert promoted == 0 + assert promotion_payload["status"] == "promoted" + assert promotion_payload["state"]["generation"] == 1 + assert repository.state().generation == 1 + + +def test_public_policy_cli_can_roll_back_only_with_exact_expected_state( + tmp_path: Path, + monkeypatch, + capsys, +) -> None: + repository, _ = _repository(tmp_path) + policy_dir = tmp_path / "candidate" + policy_dir.mkdir() + effective_at = "2026-08-11T00:00:00+08:00" + approval_file, keyring_file, documents = _evidence(policy_dir, effective_at=effective_at) + _mock_verifier(monkeypatch, version="2026-08-12", effective_at=effective_at, documents=documents) + settings = _cli_settings(policy_dir, approval_file, keyring_file) + authorization = tmp_path / "rollback-authorization.txt" + authorization.write_bytes(b"exact rollback authorization") + + promoted = public_policy_cli_main( + [ + "--database-url", "sqlite+pysqlite:///:memory:", + "promote", + "--apply", + "--confirmation", PROMOTION_CONFIRMATION, + "--authorization-file", str(authorization), + "--expected-generation", "0", + "--expected-current-set-id", "none", + "--idempotency-key", "policy-cli-promote-v1", + "--trace-id", "policy-cli-promote-trace", + ], + repository=repository, + settings=settings, + checked_at=datetime(2026, 8, 13, tzinfo=UTC), + ) + promotion_payload = json.loads(capsys.readouterr().out) + assert promoted == 0 + target_set_id = promotion_payload["state"]["current_set_id"] + + denied = public_policy_cli_main( + [ + "--database-url", "sqlite+pysqlite:///:memory:", + "rollback", + "--apply", + "--confirmation", PROMOTION_CONFIRMATION, + "--authorization-file", str(authorization), + "--target-set-id", target_set_id, + "--expected-generation", "99", + "--expected-current-set-id", target_set_id, + "--idempotency-key", "policy-cli-rollback-v1", + "--trace-id", "policy-cli-rollback-trace", + ], + repository=repository, + settings=settings, + checked_at=datetime(2026, 8, 13, tzinfo=UTC), + ) + assert denied == 1 + assert repository.state().generation == 1 + capsys.readouterr() + + rolled_back = public_policy_cli_main( + [ + "--database-url", "sqlite+pysqlite:///:memory:", + "rollback", + "--apply", + "--confirmation", ROLLBACK_CONFIRMATION, + "--authorization-file", str(authorization), + "--target-set-id", "missing-policy-set", + "--expected-generation", "1", + "--expected-current-set-id", target_set_id, + "--idempotency-key", "policy-cli-rollback-v1", + "--trace-id", "policy-cli-rollback-trace", + ], + repository=repository, + settings=settings, + checked_at=datetime(2026, 8, 13, tzinfo=UTC), + ) + rollback_payload = json.loads(capsys.readouterr().out) + assert rolled_back == 1 + assert rollback_payload["status"] == "error" + assert repository.state().generation == 1 + + second_dir = tmp_path / "candidate-v2" + second_dir.mkdir() + second_approval, second_keyring, second_documents = _evidence( + second_dir, + version="2026-08-13", + effective_at=effective_at, + suffix="-v2", + ) + _mock_verifier( + monkeypatch, + version="2026-08-13", + effective_at=effective_at, + documents=second_documents, + ) + second_settings = _cli_settings(second_dir, second_approval, second_keyring) + second = public_policy_cli_main( + [ + "--database-url", "sqlite+pysqlite:///:memory:", + "promote", + "--apply", + "--confirmation", PROMOTION_CONFIRMATION, + "--authorization-file", str(authorization), + "--expected-generation", "1", + "--expected-current-set-id", target_set_id, + "--idempotency-key", "policy-cli-promote-v2", + "--trace-id", "policy-cli-promote-trace-v2", + ], + repository=repository, + settings=second_settings, + checked_at=datetime(2026, 8, 14, tzinfo=UTC), + ) + second_payload = json.loads(capsys.readouterr().out) + assert second == 0 + second_set_id = second_payload["state"]["current_set_id"] + + restored = public_policy_cli_main( + [ + "--database-url", "sqlite+pysqlite:///:memory:", + "rollback", + "--apply", + "--confirmation", ROLLBACK_CONFIRMATION, + "--authorization-file", str(authorization), + "--target-set-id", target_set_id, + "--expected-generation", "2", + "--expected-current-set-id", second_set_id, + "--idempotency-key", "policy-cli-rollback-v2", + "--trace-id", "policy-cli-rollback-trace-v2", + ], + repository=repository, + settings=second_settings, + checked_at=datetime(2026, 8, 14, tzinfo=UTC), + ) + restore_payload = json.loads(capsys.readouterr().out) + assert restored == 0 + assert restore_payload["status"] == "rolled_back" + assert restore_payload["state"]["generation"] == 3 + assert restore_payload["state"]["current_set_id"] == target_set_id \ No newline at end of file diff --git a/yuqei-infra/compose/.env.production.example b/yuqei-infra/compose/.env.production.example index 2c29a472..f921c46d 100644 --- a/yuqei-infra/compose/.env.production.example +++ b/yuqei-infra/compose/.env.production.example @@ -129,6 +129,14 @@ CONTRACT_API_ANTI_ABUSE_FILES_DAILY_LIMIT=50 CONTRACT_API_ANTI_ABUSE_BYTES_DAILY_LIMIT=524288000 CONTRACT_API_BATCH_DOWNLOAD_MAX_FILES=20 CONTRACT_API_BATCH_DOWNLOAD_MAX_BYTES=104857600 + +# Verified public-policy material is mounted read-only. The activation executor +# remains false; promotion is performed only through the named operations CLI. +PUBLIC_POLICY_CANDIDATE_DIR_HOST=/etc/yuqei/public-policy/2026-08-12 +PUBLIC_POLICY_LEGAL_APPROVAL_FILE_HOST=/etc/yuqei/public-policy/2026-08-12/named-policy-legal-approval.json +PUBLIC_POLICY_TRUSTED_KEYRING_FILE_HOST=/etc/yuqei/public-policy/2026-08-12/policy-legal-keyring.trusted.json +CONTRACT_API_PUBLIC_POLICY_RELEASE_DIGEST=sha256:CHANGE_ME_64_HEX_DIGEST +CONTRACT_API_PUBLIC_POLICY_EXECUTOR_ENABLED=false CONTRACT_REVIEW_WORKER_SEED_DEMO_DATA=false CONTRACT_WEB_SESSION_SECRET=CHANGE_ME_USE_A_DIFFERENT_RANDOM_32_BYTE_SECRET CONTRACT_WEB_SESSION_SECURE=true diff --git a/yuqei-infra/compose/docker-compose.production.yml b/yuqei-infra/compose/docker-compose.production.yml index eed5c2c0..a3406e9a 100644 --- a/yuqei-infra/compose/docker-compose.production.yml +++ b/yuqei-infra/compose/docker-compose.production.yml @@ -24,8 +24,17 @@ services: 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. + environment: + CONTRACT_API_PUBLIC_POLICY_CANDIDATE_DIR: /run/yuqei/public-policy/candidate + CONTRACT_API_PUBLIC_POLICY_LEGAL_APPROVAL_FILE: /run/yuqei/public-policy/named-policy-legal-approval.json + CONTRACT_API_PUBLIC_POLICY_TRUSTED_KEYRING_FILE: /run/yuqei/public-policy/policy-legal-keyring.trusted.json + CONTRACT_API_PUBLIC_POLICY_RELEASE_DIGEST: ${CONTRACT_API_PUBLIC_POLICY_RELEASE_DIGEST:?CONTRACT_API_PUBLIC_POLICY_RELEASE_DIGEST is required} + CONTRACT_API_PUBLIC_POLICY_EXECUTOR_ENABLED: ${CONTRACT_API_PUBLIC_POLICY_EXECUTOR_ENABLED:-false} volumes: - contract-files:/data/contract-files + - ${PUBLIC_POLICY_CANDIDATE_DIR_HOST:?PUBLIC_POLICY_CANDIDATE_DIR_HOST is required}:/run/yuqei/public-policy/candidate:ro + - ${PUBLIC_POLICY_LEGAL_APPROVAL_FILE_HOST:?PUBLIC_POLICY_LEGAL_APPROVAL_FILE_HOST is required}:/run/yuqei/public-policy/named-policy-legal-approval.json:ro + - ${PUBLIC_POLICY_TRUSTED_KEYRING_FILE_HOST:?PUBLIC_POLICY_TRUSTED_KEYRING_FILE_HOST is required}:/run/yuqei/public-policy/policy-legal-keyring.trusted.json:ro contract-review-worker: image: ${CONTRACT_API_IMAGE:?CONTRACT_API_IMAGE must reference an immutable production image} diff --git a/yuqei-infra/deploy/README.md b/yuqei-infra/deploy/README.md index 4a122733..95fbd85c 100644 --- a/yuqei-infra/deploy/README.md +++ b/yuqei-infra/deploy/README.md @@ -91,6 +91,17 @@ not change a keyring, toggle the kill switch, import a receipt, or invoke any id write/execute API. Make control changes through the independently approved operations procedure, then use this workflow only to observe their propagation. +## Public policy promotion + +The production Compose mounts the verified policy candidate directory, named legal approval, and trusted public keyring into Contract API read-only. Keep `CONTRACT_API_PUBLIC_POLICY_EXECUTOR_ENABLED=false`; there is no HTTP write route. + +Before activation, run the read-only production preflight and follow: + +```text +project-docs/V2-public-policy-production-promotion-runbook-2026-08-17.md +``` + +The named CLI performs `status`, `preflight`, dry-run, idempotent promotion, and historical rollback. A write requires `--apply`, the exact operation confirmation string, an owner-only authorization file, the approved expected generation/set, a trace ID, and an idempotency key. Policy private keys and the one-time authorization file must never be committed or added to Compose permanently. ## Alibaba Cloud frontend variables The frontend deployment must use server-only values: diff --git a/yuqei-infra/deploy/preflight-production.py b/yuqei-infra/deploy/preflight-production.py index 6009905c..0898bb7d 100644 --- a/yuqei-infra/deploy/preflight-production.py +++ b/yuqei-infra/deploy/preflight-production.py @@ -28,6 +28,10 @@ PLACEHOLDER_MARKERS = ("change_me", "local-only", "example.com", "your-api-key", LOOPBACK_VALUES = {"127.0.0.1", "::1", "localhost"} PROVIDER_ACCEPTANCE_RECEIPT_MAX_BYTES = 1024 * 1024 PROVIDER_ACCEPTANCE_KEYRING_MAX_BYTES = 256 * 1024 +PUBLIC_POLICY_CANDIDATE_MAX_BYTES = 2 * 1024 * 1024 +PUBLIC_POLICY_APPROVAL_MAX_BYTES = 256 * 1024 +PUBLIC_POLICY_KEYRING_MAX_BYTES = 256 * 1024 +EXPECTED_PUBLIC_POLICY_IDS = {"user_terms", "privacy_policy", "service_agreement", "ai_use_notice"} ALERT_WEBHOOK_MAX_BYTES = 16 * 1024 SMTP_CREDENTIAL_CONTAINER_PATH = "/run/secrets/contract_smtp_password" LAUNCH_SCOPE_MANIFEST_PATH = ( @@ -146,6 +150,10 @@ def validate_environment(values: dict[str, str], *, template_mode: bool = False) "AI_PLATFORM_PROVIDER_ACCEPTANCE_RECEIPT_FILE_HOST", "AI_PLATFORM_PROVIDER_ACCEPTANCE_TRUSTED_KEYS_FILE_HOST", "AI_PLATFORM_PROVIDER_ACCEPTANCE_EXPECTED_RECEIPT_SHA256", + "PUBLIC_POLICY_CANDIDATE_DIR_HOST", + "PUBLIC_POLICY_LEGAL_APPROVAL_FILE_HOST", + "PUBLIC_POLICY_TRUSTED_KEYRING_FILE_HOST", + "CONTRACT_API_PUBLIC_POLICY_RELEASE_DIGEST", "PRODUCTION_ALERT_WEBHOOK_URL_FILE", ) missing = [key for key in required if not values.get(key, "").strip()] @@ -157,6 +165,7 @@ 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_PUBLIC_POLICY_EXECUTOR_ENABLED": "false", "CONTRACT_WEB_SESSION_SECURE": "true", "AI_PLATFORM_ENVIRONMENT": "production", "AI_PLATFORM_DATABASE_AUTO_CREATE": "false", @@ -250,6 +259,7 @@ def validate_environment(values: dict[str, str], *, template_mode: bool = False) database_checks = _validate_database_urls(values) checks.extend(database_checks) checks.extend(_validate_provider_acceptance_deployment(values, template_mode=template_mode)) + checks.extend(_validate_public_policy_deployment(values, template_mode=template_mode)) public_url = values.get("BACKEND_PUBLIC_BASE_URL", "") parsed_public_url = urlparse(public_url) @@ -280,7 +290,7 @@ def validate_files(values: dict[str, str], *, template_mode: bool = False) -> li Check( "protected_files", "warning", - "Template mode skipped TLS, provider key, Provider acceptance receipt/keyring, optional SMTP secret, and backup mount checks.", + "Template mode skipped TLS, provider key, Provider acceptance receipt/keyring, public-policy evidence, optional SMTP secret, and backup mount checks.", ) ] checks: list[Check] = [] @@ -338,6 +348,8 @@ def validate_files(values: dict[str, str], *, template_mode: bool = False) -> li ) ) + checks.extend(_validate_public_policy_files(values)) + 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: @@ -412,6 +424,139 @@ def validate_files(values: dict[str, str], *, template_mode: bool = False) -> li return checks +def _validate_public_policy_files(values: dict[str, str]) -> list[Check]: + checks: list[Check] = [] + candidate_root = Path(values.get("PUBLIC_POLICY_CANDIDATE_DIR_HOST", "")) + candidate_ok = candidate_root.is_absolute() and candidate_root.is_dir() and not candidate_root.is_symlink() + total_size = 0 + symlink_found = False + if candidate_ok: + try: + for path in candidate_root.rglob("*"): + if path.is_symlink(): + symlink_found = True + elif path.is_file(): + total_size += path.stat().st_size + except OSError: + candidate_ok = False + candidate_ok = candidate_ok and not symlink_found and 0 < total_size <= PUBLIC_POLICY_CANDIDATE_MAX_BYTES + checks.append( + _result( + "public_policy_candidate_dir", + candidate_ok, + "The public-policy candidate directory is absolute, bounded, and contains no symlinks." + if candidate_ok + else "The public-policy candidate directory must be absolute, non-empty, no larger than 2 MiB, and free of symlinks.", + ) + ) + + manifest_ok = False + if candidate_ok: + manifest_path = candidate_root / "manifest.json" + try: + manifest = _read_strict_json_object(manifest_path) + documents = manifest.get("documents") + manifest_ok = ( + manifest.get("schema_version") == "yuqei-public-policy-candidate-v1" + and manifest.get("existing_user_reconsent") == "required_on_next_login" + and isinstance(documents, list) + and len(documents) == 4 + and {item.get("policy_id") for item in documents if isinstance(item, dict)} + == EXPECTED_PUBLIC_POLICY_IDS + ) + if manifest_ok: + for item in documents: + source_file = item.get("source_file") + relative = Path(str(source_file)) + resolved = (candidate_root / relative).resolve() + if ( + not isinstance(source_file, str) + or not source_file.strip() + or relative.is_absolute() + or ".." in relative.parts + or not resolved.is_relative_to(candidate_root.resolve()) + or not resolved.is_file() + or resolved.stat().st_size <= 0 + ): + manifest_ok = False + break + except (OSError, UnicodeError, ValueError, TypeError): + manifest_ok = False + checks.append( + _result( + "public_policy_manifest", + manifest_ok, + "The candidate manifest binds all four approved public-policy source files." + if manifest_ok + else "The candidate manifest must bind exactly user terms, privacy policy, service agreement, and AI notice with safe in-directory sources.", + ) + ) + + approval_path = Path(values.get("PUBLIC_POLICY_LEGAL_APPROVAL_FILE_HOST", "")) + approval_ok = _is_bounded_absolute_file(approval_path, PUBLIC_POLICY_APPROVAL_MAX_BYTES) + if approval_ok: + try: + _read_strict_json_object(approval_path) + except (OSError, UnicodeError, ValueError): + approval_ok = False + checks.append( + _result( + "public_policy_legal_approval_file", + approval_ok, + "The named public-policy legal approval file is present and JSON-valid." + if approval_ok + else "The public-policy legal approval must be an absolute JSON file no larger than 256 KiB.", + ) + ) + + keyring_path = Path(values.get("PUBLIC_POLICY_TRUSTED_KEYRING_FILE_HOST", "")) + keyring_ok = _is_bounded_absolute_file(keyring_path, PUBLIC_POLICY_KEYRING_MAX_BYTES) + if keyring_ok: + try: + keyring = _read_strict_json_object(keyring_path) + trust_basis = keyring.get("trust_basis") + keyring_ok = ( + isinstance(trust_basis, dict) + and trust_basis.get("policy_activation_authorized") is False + ) + except (OSError, UnicodeError, ValueError): + keyring_ok = False + checks.append( + _result( + "public_policy_trusted_keyring_file", + keyring_ok, + "The trusted public-policy keyring is JSON-valid and does not itself authorize activation." + if keyring_ok + else "The trusted public-policy keyring must be JSON-valid and keep policy_activation_authorized=false.", + ) + ) + + if os.name != "nt": + for key, path, enabled in ( + ("public_policy_legal_approval_permissions", approval_path, approval_ok), + ("public_policy_trusted_keyring_permissions", keyring_path, keyring_ok), + ): + if enabled: + mode = stat.S_IMODE(path.stat().st_mode) + secure_mode = mode & 0o077 == 0 + checks.append( + _result( + key, + secure_mode, + "The public-policy evidence file is owner-only." + if secure_mode + else f"Public-policy evidence file mode {oct(mode)} exposes group or other permissions.", + ) + ) + return checks + + +def _read_strict_json_object(path: Path) -> dict[str, object]: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError("JSON value is not an object") + return value + 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() @@ -651,6 +796,61 @@ def _validate_database_urls(values: dict[str, str]) -> list[Check]: return [_result("database_boundaries", not problems, "Database URLs target distinct production PostgreSQL databases on the internal service host." if not problems else f"Invalid database configuration keys: {', '.join(sorted(set(problems)))}.")] +def _validate_public_policy_deployment( + values: dict[str, str], + *, + template_mode: bool, +) -> list[Check]: + candidate_dir = values.get("PUBLIC_POLICY_CANDIDATE_DIR_HOST", "").strip() + approval_file = values.get("PUBLIC_POLICY_LEGAL_APPROVAL_FILE_HOST", "").strip() + trusted_keyring = values.get("PUBLIC_POLICY_TRUSTED_KEYRING_FILE_HOST", "").strip() + release_digest = values.get("CONTRACT_API_PUBLIC_POLICY_RELEASE_DIGEST", "").strip() + contract_image = values.get("CONTRACT_API_IMAGE", "").strip() + deployed_contract_digest = contract_image.rsplit("@", 1)[1] if "@" in contract_image else "" + + paths = (candidate_dir, approval_file, trusted_keyring) + paths_ok = ( + all(paths) + and all(_is_absolute_host_path(path) for path in paths) + and len(set(paths)) == len(paths) + ) + executor_ok = values.get("CONTRACT_API_PUBLIC_POLICY_EXECUTOR_ENABLED", "").strip().lower() == "false" + digest_ok = ( + bool(IMAGE_DIGEST_PATTERN.fullmatch(release_digest)) + and release_digest == deployed_contract_digest.lower() + ) + deployment_ok = paths_ok and executor_ok and digest_ok + + forbidden = sorted( + key + for key, value in values.items() + if value.strip() and _is_forbidden_public_policy_key(key) + ) + return [ + _result( + "public_policy_deployment", + template_mode or deployment_ok, + "Public policy evidence uses distinct absolute paths, the executor is disabled, and the release digest matches the Contract API image." + if deployment_ok + else "Public policy requires distinct absolute evidence paths, executor=false, and a release digest matching the immutable Contract API image.", + warning=template_mode and not deployment_ok, + ), + _result( + "public_policy_credential_isolation", + not forbidden, + "Production contains no public-policy private key, password, or staging credential." + if not forbidden + else f"Forbidden public-policy credentials are configured: {', '.join(forbidden)}.", + ), + ] + + +def _is_forbidden_public_policy_key(key: str) -> bool: + normalized = key.upper() + if "PUBLIC_POLICY" not in normalized: + return False + return any(token in normalized for token in ("PRIVATE_KEY", "PRIVATE_KEY_FILE", "PASSWORD", "SECRET", "STAGING")) + def _validate_provider_acceptance_deployment( values: dict[str, str], *, diff --git a/yuqei-infra/tests/test_production_release_tools.py b/yuqei-infra/tests/test_production_release_tools.py index d45e84c1..6294d03b 100644 --- a/yuqei-infra/tests/test_production_release_tools.py +++ b/yuqei-infra/tests/test_production_release_tools.py @@ -60,6 +60,7 @@ def production_values() -> dict[str, str]: "CONTRACT_API_IMAGE": f"registry.internal/contract-api@sha256:{'1' * 64}", "AI_PLATFORM_API_IMAGE": f"registry.internal/ai-platform-api@sha256:{'2' * 64}", "AI_PLATFORM_RELEASE_IMAGE_DIGEST": f"sha256:{'2' * 64}", + "CONTRACT_API_PUBLIC_POLICY_RELEASE_DIGEST": f"sha256:{'1' * 64}", "AI_WORKER_IMAGE": f"registry.internal/ai-worker@sha256:{'3' * 64}", "POSTGRES_IMAGE": f"registry.internal/pgvector@sha256:{'4' * 64}", "POSTGRES_CLIENT_IMAGE": f"registry.internal/postgres@sha256:{'5' * 64}", @@ -353,11 +354,119 @@ def test_production_compose_mounts_only_promotion_verification_material_read_onl assert "AI_PLATFORM_PROVIDER_ACCEPTANCE_EXPECTED_RECEIPT_SHA256: ${" in override assert ":/run/yuqei/provider-acceptance/receipt.json:ro" in override assert ":/run/yuqei/provider-acceptance/trusted-keys.json:ro" in override + assert "CONTRACT_API_PUBLIC_POLICY_CANDIDATE_DIR: /run/yuqei/public-policy/candidate" in override + assert "${PUBLIC_POLICY_CANDIDATE_DIR_HOST:?PUBLIC_POLICY_CANDIDATE_DIR_HOST is required}:/run/yuqei/public-policy/candidate:ro" in override + assert "${PUBLIC_POLICY_LEGAL_APPROVAL_FILE_HOST:?PUBLIC_POLICY_LEGAL_APPROVAL_FILE_HOST is required}:/run/yuqei/public-policy/named-policy-legal-approval.json:ro" in override + assert "${PUBLIC_POLICY_TRUSTED_KEYRING_FILE_HOST:?PUBLIC_POLICY_TRUSTED_KEYRING_FILE_HOST is required}:/run/yuqei/public-policy/policy-legal-keyring.trusted.json:ro" in override + assert "CONTRACT_API_PUBLIC_POLICY_EXECUTOR_ENABLED: ${CONTRACT_API_PUBLIC_POLICY_EXECUTOR_ENABLED:-false}" in override assert 'AI_PLATFORM_PROVIDER_LEGAL_ATTESTATION_SECRET: ""' in override assert "SIGNING_PRIVATE_KEY" not in override assert "STAGING_DATABASE_URL" not in override +def _write_public_policy_evidence(tmp_path: Path) -> dict[str, str]: + candidate = tmp_path / "public-policy" + candidate.mkdir() + documents = [] + for policy_id, source_file in ( + ("user_terms", "user-terms.md"), + ("privacy_policy", "privacy-policy.md"), + ("service_agreement", "service-agreement.md"), + ("ai_use_notice", "ai-use-notice.md"), + ): + (candidate / source_file).write_text(f"# {policy_id}\n", encoding="utf-8") + documents.append({"policy_id": policy_id, "source_file": source_file}) + (candidate / "manifest.json").write_text( + json.dumps( + { + "schema_version": "yuqei-public-policy-candidate-v1", + "existing_user_reconsent": "required_on_next_login", + "documents": documents, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + approval = tmp_path / "policy-approval.json" + approval.write_text('{"approval_id":"policy-approval"}', encoding="utf-8") + keyring = tmp_path / "policy-keyring.json" + keyring.write_text( + json.dumps({"trust_basis": {"policy_activation_authorized": False}}), + encoding="utf-8", + ) + return { + "PUBLIC_POLICY_CANDIDATE_DIR_HOST": str(candidate.resolve()), + "PUBLIC_POLICY_LEGAL_APPROVAL_FILE_HOST": str(approval.resolve()), + "PUBLIC_POLICY_TRUSTED_KEYRING_FILE_HOST": str(keyring.resolve()), + } + + +def test_production_preflight_rejects_invalid_public_policy_deployment() -> None: + values = production_values() + values["CONTRACT_API_PUBLIC_POLICY_RELEASE_DIGEST"] = f"sha256:{'f' * 64}" + + checks = {check.key: check for check in preflight.validate_environment(values)} + + assert checks["public_policy_deployment"].status == "fail" + + +@pytest.mark.parametrize( + ("key", "value"), + [ + ("PUBLIC_POLICY_CANDIDATE_DIR_HOST", "relative/public-policy"), + ("PUBLIC_POLICY_LEGAL_APPROVAL_FILE_HOST", "/etc/yuqei/public-policy/2026-08-12/policy-legal-keyring.trusted.json"), + ("CONTRACT_API_PUBLIC_POLICY_EXECUTOR_ENABLED", "true"), + ], +) +def test_production_preflight_rejects_unsafe_public_policy_paths_or_executor( + key: str, + value: str, +) -> None: + values = production_values() + values[key] = value + + checks = {check.key: check for check in preflight.validate_environment(values)} + + assert checks["public_policy_deployment"].status == "fail" + + +def test_production_preflight_rejects_public_policy_private_material() -> None: + values = production_values() + values["CONTRACT_API_PUBLIC_POLICY_PRIVATE_KEY_FILE"] = "/etc/yuqei/private.pem" + + checks = {check.key: check for check in preflight.validate_environment(values)} + + assert checks["public_policy_credential_isolation"].status == "fail" + + +def test_public_policy_evidence_files_are_present_and_safe(tmp_path: Path) -> None: + values = production_values() + values.update(_write_public_policy_evidence(tmp_path)) + + checks = {check.key: check for check in preflight.validate_files(values)} + + assert checks["public_policy_candidate_dir"].status == "pass" + assert checks["public_policy_manifest"].status == "pass" + assert checks["public_policy_legal_approval_file"].status == "pass" + assert checks["public_policy_trusted_keyring_file"].status == "pass" + + +def test_public_policy_preflight_fails_on_manifest_or_keyring_drift(tmp_path: Path) -> None: + values = production_values() + values.update(_write_public_policy_evidence(tmp_path)) + candidate = Path(values["PUBLIC_POLICY_CANDIDATE_DIR_HOST"]) + (candidate / "manifest.json").write_text('{"schema_version":"wrong"}', encoding="utf-8") + keyring = Path(values["PUBLIC_POLICY_TRUSTED_KEYRING_FILE_HOST"]) + keyring.write_text( + json.dumps({"trust_basis": {"policy_activation_authorized": True}}), + encoding="utf-8", + ) + + checks = {check.key: check for check in preflight.validate_files(values)} + + assert checks["public_policy_manifest"].status == "fail" + assert checks["public_policy_trusted_keyring_file"].status == "fail" + def test_shared_compose_wires_contract_anti_abuse_controls() -> None: compose = (REPO_ROOT / "yuqei-infra/compose/docker-compose.yml").read_text( encoding="utf-8"