669 lines
35 KiB
Python
669 lines
35 KiB
Python
#!/usr/bin/env python3
|
|
"""Preflight and execute the P1-07.6 two-region evidence-trust UAT.
|
|
|
|
The runner intentionally refuses placeholders, mutable images, shared volumes,
|
|
one-person approvals, and execution without a real evidence package. It never
|
|
persists bearer tokens in the generated report.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
IMAGE_REFERENCE_RE = re.compile(r"^[^\s@]+@sha256:[0-9a-f]{64}$")
|
|
UAT_SERVICE_IMAGE_ENV = {
|
|
"postgres": "POSTGRES_IMAGE",
|
|
"postgres-init-databases": "POSTGRES_CLIENT_IMAGE",
|
|
"redis": "REDIS_IMAGE",
|
|
"ai-redis": "REDIS_IMAGE",
|
|
"minio": "MINIO_IMAGE",
|
|
"contract-api-migrate": "CONTRACT_API_IMAGE",
|
|
"contract-api": "CONTRACT_API_IMAGE",
|
|
"contract-review-worker": "CONTRACT_API_IMAGE",
|
|
"contract-playbook-governance-worker": "CONTRACT_API_IMAGE",
|
|
"ai-platform-api-migrate": "AI_PLATFORM_API_IMAGE",
|
|
"ai-platform-prompt-assets-import": "AI_PLATFORM_API_IMAGE",
|
|
"ai-platform-api": "AI_PLATFORM_API_IMAGE",
|
|
"worker": "AI_WORKER_IMAGE",
|
|
"contract-web": "CONTRACT_WEB_IMAGE",
|
|
"kms-gateway-aliyun": "KMS_GATEWAY_IMAGE",
|
|
"kms-gateway-tencent": "KMS_GATEWAY_IMAGE",
|
|
"evidence-witness-aliyun": "EVIDENCE_WITNESS_GATEWAY_IMAGE",
|
|
"evidence-witness-tencent": "EVIDENCE_WITNESS_GATEWAY_IMAGE",
|
|
}
|
|
|
|
|
|
POLICY_UPDATE_FIELDS = (
|
|
"timezone",
|
|
"business_calendar_enabled",
|
|
"business_days",
|
|
"workday_start_minute",
|
|
"workday_end_minute",
|
|
"holiday_dates",
|
|
"first_response_minutes",
|
|
"scan_enabled",
|
|
"scan_interval_minutes",
|
|
"scan_lookback_days",
|
|
"external_notification_enabled",
|
|
"notification_channel_id",
|
|
"scan_slo_target_rate",
|
|
"notification_slo_target_rate",
|
|
"slo_alerts_enabled",
|
|
"slo_warning_burn_rate",
|
|
"slo_critical_burn_rate",
|
|
"slo_minimum_sample_size",
|
|
"slo_fast_window_minutes",
|
|
"slo_mid_window_minutes",
|
|
"slo_fast_burn_rate",
|
|
"slo_slow_burn_rate",
|
|
"draft_evidence_valid_days",
|
|
"draft_evidence_reminder_days",
|
|
"draft_evidence_reminders_enabled",
|
|
"audit_verification_enabled",
|
|
"audit_verification_per_minute",
|
|
"audit_verification_per_day",
|
|
"audit_verification_per_export_per_day",
|
|
"audit_invalid_attempt_threshold",
|
|
"audit_lockout_minutes",
|
|
"evidence_probe_enabled",
|
|
"evidence_probe_interval_minutes",
|
|
"evidence_probe_slo_target_rate",
|
|
"transparency_anchor_enabled",
|
|
"transparency_anchor_interval_minutes",
|
|
"transparency_minimum_witnesses",
|
|
)
|
|
|
|
REQUIRED_VALUES = (
|
|
"COMPOSE_PROJECT_NAME",
|
|
"POSTGRES_VOLUME_NAME",
|
|
"MINIO_VOLUME_NAME",
|
|
"CONTRACT_FILES_VOLUME_NAME",
|
|
"MINIO_ROOT_USER",
|
|
"MINIO_ROOT_PASSWORD",
|
|
"POSTGRES_IMAGE",
|
|
"POSTGRES_CLIENT_IMAGE",
|
|
"REDIS_IMAGE",
|
|
"MINIO_IMAGE",
|
|
"CONTRACT_API_IMAGE",
|
|
"CONTRACT_WEB_IMAGE",
|
|
"AI_PLATFORM_API_IMAGE",
|
|
"AI_WORKER_IMAGE",
|
|
"POSTGRES_BIND_ADDRESS",
|
|
"REDIS_BIND_ADDRESS",
|
|
"MINIO_API_BIND_ADDRESS",
|
|
"MINIO_CONSOLE_BIND_ADDRESS",
|
|
"CONTRACT_API_BIND_ADDRESS",
|
|
"AI_PLATFORM_API_BIND_ADDRESS",
|
|
"CONTRACT_WEB_BIND_ADDRESS",
|
|
"KMS_GATEWAY_IMAGE",
|
|
"EVIDENCE_WITNESS_GATEWAY_IMAGE",
|
|
"UAT_ALIYUN_KMS_REGION",
|
|
"UAT_ALIYUN_KMS_ENDPOINT",
|
|
"UAT_ALIYUN_ENCRYPTION_KEY_ID",
|
|
"UAT_ALIYUN_EVIDENCE_KEY_ID",
|
|
"UAT_ALIYUN_EVIDENCE_KEY_VERSION",
|
|
"UAT_ALIYUN_WITNESS_KEY_ID",
|
|
"UAT_ALIYUN_WITNESS_KEY_VERSION",
|
|
"UAT_ALIYUN_WITNESS_BUCKET",
|
|
"UAT_ALIYUN_WITNESS_REGION",
|
|
"UAT_ALIYUN_WITNESS_ENDPOINT",
|
|
"UAT_TENCENT_KMS_REGION",
|
|
"UAT_TENCENT_ENCRYPTION_KEY_ID",
|
|
"UAT_TENCENT_EVIDENCE_KEY_ID",
|
|
"UAT_TENCENT_EVIDENCE_KEY_VERSION",
|
|
"UAT_TENCENT_WITNESS_KEY_ID",
|
|
"UAT_TENCENT_WITNESS_KEY_VERSION",
|
|
"UAT_TENCENT_WITNESS_BUCKET",
|
|
"UAT_TENCENT_WITNESS_REGION",
|
|
"UAT_TENCENT_WITNESS_ENDPOINT",
|
|
)
|
|
|
|
SECRET_FILE_KEYS = (
|
|
"UAT_ALIYUN_KMS_TOKEN_FILE",
|
|
"UAT_ALIYUN_KMS_CLIENT_KEY_FILE",
|
|
"UAT_ALIYUN_KMS_CLIENT_KEY_PASSWORD_FILE",
|
|
"UAT_ALIYUN_KMS_PROVIDER_CA_FILE",
|
|
"UAT_ALIYUN_GATEWAY_TLS_CERT_FILE",
|
|
"UAT_ALIYUN_GATEWAY_TLS_KEY_FILE",
|
|
"UAT_ALIYUN_GATEWAY_CA_FILE",
|
|
"UAT_ALIYUN_OSS_CREDENTIAL_FILE",
|
|
"UAT_ALIYUN_WITNESS_TOKEN_FILE",
|
|
"UAT_ALIYUN_WITNESS_TLS_CERT_FILE",
|
|
"UAT_ALIYUN_WITNESS_TLS_KEY_FILE",
|
|
"UAT_ALIYUN_WITNESS_CA_FILE",
|
|
"UAT_ALIYUN_WITNESS_PUBLIC_KEY_FILE",
|
|
"UAT_TENCENT_KMS_TOKEN_FILE",
|
|
"UAT_TENCENT_KMS_CREDENTIAL_FILE",
|
|
"UAT_TENCENT_GATEWAY_TLS_CERT_FILE",
|
|
"UAT_TENCENT_GATEWAY_TLS_KEY_FILE",
|
|
"UAT_TENCENT_GATEWAY_CA_FILE",
|
|
"UAT_TENCENT_COS_CREDENTIAL_FILE",
|
|
"UAT_TENCENT_WITNESS_TOKEN_FILE",
|
|
"UAT_TENCENT_WITNESS_TLS_CERT_FILE",
|
|
"UAT_TENCENT_WITNESS_TLS_KEY_FILE",
|
|
"UAT_TENCENT_WITNESS_CA_FILE",
|
|
"UAT_TENCENT_WITNESS_PUBLIC_KEY_FILE",
|
|
)
|
|
|
|
|
|
class UatFailure(RuntimeError):
|
|
pass
|
|
|
|
|
|
def now_iso() -> str:
|
|
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def load_env(path: Path) -> dict[str, str]:
|
|
values: dict[str, str] = {}
|
|
for line_number, raw in enumerate(path.read_text(encoding="utf-8-sig").splitlines(), 1):
|
|
line = raw.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
if line.startswith("export "):
|
|
line = line[7:].strip()
|
|
if "=" not in line:
|
|
raise UatFailure(f"Invalid env entry at line {line_number}.")
|
|
key, value = line.split("=", 1)
|
|
key = key.strip()
|
|
value = value.strip()
|
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
|
value = value[1:-1]
|
|
values[key] = value
|
|
return values
|
|
|
|
|
|
def check(checks: list[dict[str, str]], name: str, passed: bool, detail: str) -> None:
|
|
checks.append({"name": name, "status": "passed" if passed else "failed", "detail": detail})
|
|
|
|
|
|
def preflight(env: dict[str, str], *, admin_a: str, admin_b: str, static_only: bool) -> list[dict[str, str]]:
|
|
checks: list[dict[str, str]] = []
|
|
missing = [key for key in REQUIRED_VALUES if not env.get(key, "").strip()]
|
|
check(checks, "required_configuration", not missing, "all required values are present" if not missing else "missing: " + ", ".join(missing))
|
|
|
|
placeholders = sorted(key for key, value in env.items() if "CHANGE_ME" in value or "example.com" in value)
|
|
check(checks, "no_placeholders", not placeholders, "no placeholders found" if not placeholders else "placeholder keys: " + ", ".join(placeholders))
|
|
|
|
image_keys = (
|
|
"POSTGRES_IMAGE",
|
|
"POSTGRES_CLIENT_IMAGE",
|
|
"REDIS_IMAGE",
|
|
"MINIO_IMAGE",
|
|
"CONTRACT_API_IMAGE",
|
|
"CONTRACT_WEB_IMAGE",
|
|
"AI_PLATFORM_API_IMAGE",
|
|
"AI_WORKER_IMAGE",
|
|
"KMS_GATEWAY_IMAGE",
|
|
"EVIDENCE_WITNESS_GATEWAY_IMAGE",
|
|
)
|
|
invalid_images = [key for key in image_keys if not IMAGE_REFERENCE_RE.fullmatch(env.get(key, ""))]
|
|
check(checks, "immutable_images", not invalid_images, "all UAT images use immutable SHA-256 digests" if not invalid_images else "mutable or invalid image keys: " + ", ".join(invalid_images))
|
|
|
|
project = env.get("COMPOSE_PROJECT_NAME", "").lower()
|
|
isolated_project = "uat" in project and not any(marker in project for marker in ("prod", "production"))
|
|
check(checks, "isolated_project", isolated_project, "compose project is explicitly UAT-scoped" if isolated_project else "COMPOSE_PROJECT_NAME must contain uat and must not contain prod")
|
|
|
|
volume_keys = ("POSTGRES_VOLUME_NAME", "MINIO_VOLUME_NAME", "CONTRACT_FILES_VOLUME_NAME")
|
|
bad_volumes = [key for key in volume_keys if "uat" not in env.get(key, "").lower()]
|
|
object_storage_volume = env.get("MINIO_VOLUME_NAME", "").lower()
|
|
if (
|
|
"MINIO_VOLUME_NAME" not in bad_volumes
|
|
and ("rustfs" not in object_storage_volume or "minio" in object_storage_volume)
|
|
):
|
|
bad_volumes.append("MINIO_VOLUME_NAME")
|
|
check(checks, "isolated_volumes", not bad_volumes, "all persistent volumes are UAT-scoped" if not bad_volumes else "non-UAT volume keys: " + ", ".join(bad_volumes))
|
|
|
|
default_ports = {"5432", "6379", "9000", "9001", "8100", "8101", "3000"}
|
|
configured_ports = {env.get(key, "") for key in (
|
|
"POSTGRES_HOST_PORT", "REDIS_HOST_PORT", "MINIO_API_HOST_PORT", "MINIO_CONSOLE_HOST_PORT",
|
|
"CONTRACT_API_HOST_PORT", "AI_PLATFORM_API_HOST_PORT", "CONTRACT_WEB_HOST_PORT",
|
|
)}
|
|
port_isolation = "" not in configured_ports and configured_ports.isdisjoint(default_ports) and len(configured_ports) == 7
|
|
check(checks, "isolated_ports", port_isolation, "all seven host ports are distinct from development defaults" if port_isolation else "host ports must be present, unique, and distinct from default ports")
|
|
|
|
bind_keys = (
|
|
"POSTGRES_BIND_ADDRESS", "REDIS_BIND_ADDRESS", "MINIO_API_BIND_ADDRESS",
|
|
"MINIO_CONSOLE_BIND_ADDRESS", "CONTRACT_API_BIND_ADDRESS",
|
|
"AI_PLATFORM_API_BIND_ADDRESS", "CONTRACT_WEB_BIND_ADDRESS",
|
|
)
|
|
loopback_only = all(env.get(key) in {"127.0.0.1", "::1", "localhost"} for key in bind_keys)
|
|
check(checks, "loopback_bindings", loopback_only, "all host bindings are loopback-only" if loopback_only else "all UAT host bindings must use loopback addresses")
|
|
|
|
key_sets = {
|
|
"aliyun": [env.get("UAT_ALIYUN_ENCRYPTION_KEY_ID"), env.get("UAT_ALIYUN_EVIDENCE_KEY_ID"), env.get("UAT_ALIYUN_WITNESS_KEY_ID")],
|
|
"tencent": [env.get("UAT_TENCENT_ENCRYPTION_KEY_ID"), env.get("UAT_TENCENT_EVIDENCE_KEY_ID"), env.get("UAT_TENCENT_WITNESS_KEY_ID")],
|
|
}
|
|
key_isolation = all(None not in values and "" not in values and len(set(values)) == 3 for values in key_sets.values())
|
|
key_isolation = key_isolation and env.get("UAT_ALIYUN_WITNESS_KEY_ID") != env.get("UAT_TENCENT_WITNESS_KEY_ID")
|
|
check(checks, "separate_kms_keys", key_isolation, "encryption, evidence, and witness keys are separated per provider" if key_isolation else "each provider requires three distinct keys and witness keys must differ across providers")
|
|
|
|
independent_targets = (
|
|
env.get("UAT_ALIYUN_KMS_REGION") != env.get("UAT_TENCENT_KMS_REGION")
|
|
and env.get("UAT_ALIYUN_WITNESS_BUCKET") != env.get("UAT_TENCENT_WITNESS_BUCKET")
|
|
and env.get("UAT_ALIYUN_WITNESS_ENDPOINT", "").startswith("https://")
|
|
and env.get("UAT_TENCENT_WITNESS_ENDPOINT", "").startswith("https://")
|
|
)
|
|
check(checks, "independent_regions_and_witnesses", independent_targets, "regions, buckets, and HTTPS witness endpoints are independent" if independent_targets else "two distinct regions, buckets, and HTTPS endpoints are required")
|
|
|
|
same_admin = not admin_a.strip() or not admin_b.strip() or admin_a.strip() == admin_b.strip()
|
|
check(checks, "two_platform_administrators", not same_admin, "administrator identities are distinct" if not same_admin else "admin A and admin B must be non-empty and different")
|
|
|
|
missing_files: list[str] = []
|
|
empty_files: list[str] = []
|
|
file_paths: list[str] = []
|
|
for key in SECRET_FILE_KEYS:
|
|
raw = env.get(key, "").strip()
|
|
if not raw:
|
|
missing_files.append(key)
|
|
continue
|
|
path = Path(raw).expanduser()
|
|
file_paths.append(str(path.resolve(strict=False)).lower())
|
|
if not path.is_file():
|
|
missing_files.append(key)
|
|
elif path.stat().st_size == 0:
|
|
empty_files.append(key)
|
|
duplicate_files = len(file_paths) != len(set(file_paths))
|
|
file_ok = not missing_files and not empty_files and not duplicate_files
|
|
detail = "all secret, CA, certificate, and public-key files are separate and non-empty"
|
|
if not file_ok:
|
|
parts = []
|
|
if missing_files:
|
|
parts.append("missing: " + ", ".join(missing_files))
|
|
if empty_files:
|
|
parts.append("empty: " + ", ".join(empty_files))
|
|
if duplicate_files:
|
|
parts.append("at least two file settings resolve to the same path")
|
|
detail = "; ".join(parts)
|
|
check(checks, "file_backed_secrets_and_trust", file_ok, detail)
|
|
|
|
docker_ready = static_only or shutil.which("docker") is not None
|
|
check(checks, "docker_runtime", docker_ready, "runtime check skipped" if static_only else ("docker executable is available" if docker_ready else "docker executable is not installed or not on PATH"))
|
|
return checks
|
|
|
|
|
|
class Api:
|
|
def __init__(self, base_url: str, tenant_id: str) -> None:
|
|
self.base_url = base_url.rstrip("/")
|
|
self.tenant_id = tenant_id
|
|
|
|
def request(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
*,
|
|
admin: str | None = None,
|
|
payload: dict[str, Any] | None = None,
|
|
bearer: str | None = None,
|
|
expected: tuple[int, ...] = (200,),
|
|
timeout: int = 90,
|
|
) -> tuple[int, Any]:
|
|
headers = {"Accept": "application/json", "User-Agent": "yuqei-p1-07-6-uat/1.0"}
|
|
if admin:
|
|
headers.update({"X-Tenant-Id": self.tenant_id, "X-User-Id": admin})
|
|
if bearer:
|
|
headers["Authorization"] = f"Bearer {bearer}"
|
|
data = None
|
|
if payload is not None:
|
|
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
headers["Content-Type"] = "application/json"
|
|
request = urllib.request.Request(f"{self.base_url}{path}", data=data, headers=headers, method=method)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
status = response.status
|
|
raw = response.read()
|
|
except urllib.error.HTTPError as error:
|
|
status = error.code
|
|
raw = error.read()
|
|
except (urllib.error.URLError, TimeoutError) as error:
|
|
raise UatFailure(f"API request failed: {method} {path}: {error}") from error
|
|
try:
|
|
body = json.loads(raw) if raw else None
|
|
except json.JSONDecodeError:
|
|
body = {"unparseable_response": True}
|
|
if status not in expected:
|
|
code = None
|
|
if isinstance(body, dict):
|
|
detail = body.get("detail")
|
|
code = detail.get("code") if isinstance(detail, dict) else None
|
|
raise UatFailure(f"Unexpected API status {status} for {method} {path}" + (f" ({code})" if code else ""))
|
|
return status, body
|
|
|
|
|
|
def run_command(command: list[str], *, cwd: Path, timeout: int = 600) -> str:
|
|
completed = subprocess.run(command, cwd=cwd, text=True, capture_output=True, timeout=timeout, check=False)
|
|
if completed.returncode != 0:
|
|
message = (completed.stderr or completed.stdout or "command failed").strip().splitlines()[-1]
|
|
raise UatFailure(f"Command failed ({completed.returncode}): {message}")
|
|
return completed.stdout
|
|
|
|
|
|
def validate_rendered_compose(raw_json: str, env: dict[str, str]) -> None:
|
|
try:
|
|
payload = json.loads(raw_json)
|
|
except json.JSONDecodeError as exc:
|
|
raise UatFailure("Compose did not emit valid JSON.") from exc
|
|
services = payload.get("services") if isinstance(payload, dict) else None
|
|
volumes = payload.get("volumes") if isinstance(payload, dict) else None
|
|
if not isinstance(services, dict) or not isinstance(volumes, dict):
|
|
raise UatFailure("Rendered Compose model is incomplete.")
|
|
for service_name, env_key in UAT_SERVICE_IMAGE_ENV.items():
|
|
service = services.get(service_name)
|
|
if not isinstance(service, dict):
|
|
raise UatFailure(f"Rendered Compose service is missing: {service_name}.")
|
|
expected = env.get(env_key, "")
|
|
if not IMAGE_REFERENCE_RE.fullmatch(expected) or service.get("image") != expected:
|
|
raise UatFailure(
|
|
f"Rendered Compose service {service_name} is not bound to {env_key}."
|
|
)
|
|
if service.get("build") is not None:
|
|
raise UatFailure(
|
|
f"Rendered Compose service {service_name} retains a build definition."
|
|
)
|
|
|
|
storage = services["minio"]
|
|
mounts = storage.get("volumes")
|
|
valid_mount = (
|
|
isinstance(mounts, list)
|
|
and len(mounts) == 1
|
|
and isinstance(mounts[0], dict)
|
|
and mounts[0].get("type") == "volume"
|
|
and mounts[0].get("source") == "rustfs-data"
|
|
and mounts[0].get("target") == "/data"
|
|
)
|
|
volume = volumes.get("rustfs-data")
|
|
valid_volume = (
|
|
isinstance(volume, dict)
|
|
and volume.get("name") == env.get("MINIO_VOLUME_NAME")
|
|
and "rustfs" in str(volume.get("name", "")).lower()
|
|
and "minio" not in str(volume.get("name", "")).lower()
|
|
)
|
|
if not valid_mount or not valid_volume:
|
|
raise UatFailure("Rendered Compose does not use the isolated RustFS volume contract.")
|
|
environment = storage.get("environment")
|
|
if (
|
|
not isinstance(environment, dict)
|
|
or environment.get("RUSTFS_ACCESS_KEY") != env.get("MINIO_ROOT_USER")
|
|
or environment.get("RUSTFS_SECRET_KEY") != env.get("MINIO_ROOT_PASSWORD")
|
|
or environment.get("RUSTFS_VOLUMES") != "/data"
|
|
):
|
|
raise UatFailure("Rendered Compose RustFS credentials or data path are not bound.")
|
|
command = storage.get("command")
|
|
command_text = "\n".join(command) if isinstance(command, list) else str(command or "")
|
|
healthcheck = storage.get("healthcheck")
|
|
health_test = healthcheck.get("test") if isinstance(healthcheck, dict) else None
|
|
if (
|
|
storage.get("entrypoint") != ["/bin/sh", "-ec"]
|
|
or "set -eu" not in command_text
|
|
or "/data/.minio.sys" not in command_text
|
|
or ".yuqei-rustfs-volume-v1" not in command_text
|
|
or "exec /entrypoint.sh rustfs" not in command_text
|
|
or not isinstance(health_test, list)
|
|
or not any(
|
|
isinstance(token, str) and "/health/ready" in token
|
|
for token in health_test
|
|
)
|
|
or storage.get("user") != "10001:10001"
|
|
or storage.get("read_only") is not True
|
|
or "ALL" not in storage.get("cap_drop", [])
|
|
or "no-new-privileges:true" not in storage.get("security_opt", [])
|
|
):
|
|
raise UatFailure("Rendered Compose RustFS startup or hardening contract is invalid.")
|
|
|
|
|
|
def require(condition: bool, message: str) -> None:
|
|
if not condition:
|
|
raise UatFailure(message)
|
|
|
|
|
|
def package_payload(path: Path) -> dict[str, Any]:
|
|
value = json.loads(path.read_text(encoding="utf-8"))
|
|
if isinstance(value, dict) and isinstance(value.get("package"), dict):
|
|
return value["package"]
|
|
if not isinstance(value, dict):
|
|
raise UatFailure("Evidence package must be a JSON object.")
|
|
return value
|
|
|
|
|
|
def summarize_probes(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
return [{
|
|
"target_name": item.get("target_name"),
|
|
"region": item.get("region"),
|
|
"status": item.get("status"),
|
|
"signature_valid": item.get("signature_valid"),
|
|
"public_key_fingerprint": item.get("public_key_fingerprint"),
|
|
"error_code": item.get("error_code"),
|
|
} for item in items]
|
|
|
|
|
|
def summarize_anchors(batch: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"configured_targets": batch.get("configured_targets"),
|
|
"required_witnesses": batch.get("required_witnesses"),
|
|
"verified_witnesses": batch.get("verified_witnesses"),
|
|
"quorum_met": batch.get("quorum_met"),
|
|
"anchors": [{
|
|
"target_name": item.get("target_name"),
|
|
"region": item.get("region"),
|
|
"status": item.get("status"),
|
|
"witness_status": item.get("witness_status"),
|
|
"witness_key_fingerprint": item.get("witness_key_fingerprint"),
|
|
"error_code": item.get("error_code"),
|
|
"external_reference": item.get("external_reference"),
|
|
} for item in batch.get("anchors", [])],
|
|
}
|
|
|
|
|
|
def execute_uat(args: argparse.Namespace, env: dict[str, str], report: dict[str, Any]) -> None:
|
|
infra_root = Path(__file__).resolve().parents[1]
|
|
base_compose = infra_root / "compose" / "docker-compose.yml"
|
|
overlay_compose = infra_root / "compose" / "docker-compose.uat.evidence-trust.yml"
|
|
compose = [
|
|
"docker", "compose", "--env-file", str(args.env_file.resolve()),
|
|
"-f", str(base_compose), "-f", str(overlay_compose),
|
|
]
|
|
steps: list[dict[str, Any]] = report["steps"]
|
|
|
|
rendered = run_command(compose + ["config", "--format", "json"], cwd=infra_root)
|
|
validate_rendered_compose(rendered, env)
|
|
steps.append({"name": "compose_config", "status": "passed", "at": now_iso()})
|
|
run_command(compose + ["up", "-d", "--wait", "--wait-timeout", str(args.startup_timeout)], cwd=infra_root, timeout=args.startup_timeout + 120)
|
|
steps.append({"name": "isolated_stack_started", "status": "passed", "at": now_iso()})
|
|
|
|
api_base = args.api_base or f"http://127.0.0.1:{env['CONTRACT_API_HOST_PORT']}"
|
|
api = Api(api_base, args.tenant_id)
|
|
deadline = time.monotonic() + args.startup_timeout
|
|
while True:
|
|
try:
|
|
api.request("GET", "/health", expected=(200,), timeout=5)
|
|
break
|
|
except UatFailure:
|
|
if time.monotonic() >= deadline:
|
|
raise UatFailure("Contract API did not become healthy before the UAT timeout.")
|
|
time.sleep(3)
|
|
|
|
prefix = "/api/v1/admin/pilot-operations"
|
|
policies: dict[str, dict[str, Any]] = {}
|
|
for label, admin in (("admin_a", args.admin_a), ("admin_b", args.admin_b)):
|
|
_status, policy = api.request("GET", f"{prefix}/policy", admin=admin)
|
|
require(isinstance(policy, dict), f"{label} did not receive a policy response.")
|
|
policies[label] = policy
|
|
require(policies["admin_a"].get("owner_tenant_id") == policies["admin_b"].get("owner_tenant_id"), "The two administrators are not operating the same platform tenant.")
|
|
steps.append({"name": "two_platform_admins_verified", "status": "passed", "at": now_iso(), "evidence": {"admin_a": args.admin_a, "admin_b": args.admin_b, "tenant_id": args.tenant_id}})
|
|
|
|
missing_policy = [field for field in POLICY_UPDATE_FIELDS if field not in policies["admin_a"]]
|
|
require(not missing_policy, "Policy response is missing update fields: " + ", ".join(missing_policy))
|
|
policy_update = {field: policies["admin_a"][field] for field in POLICY_UPDATE_FIELDS}
|
|
policy_update.update({
|
|
"evidence_probe_enabled": True,
|
|
"transparency_anchor_enabled": True,
|
|
"transparency_minimum_witnesses": 2,
|
|
"slo_alerts_enabled": True,
|
|
"slo_minimum_sample_size": 1,
|
|
"evidence_probe_slo_target_rate": 0.99,
|
|
})
|
|
_status, updated_policy = api.request("PUT", f"{prefix}/policy", admin=args.admin_a, payload=policy_update)
|
|
require(updated_policy.get("transparency_minimum_witnesses") == 2, "Two-witness quorum policy was not applied.")
|
|
steps.append({"name": "two_witness_policy_enabled", "status": "passed", "at": now_iso(), "evidence": {"policy_version": updated_policy.get("version"), "minimum_witnesses": 2, "slo_minimum_sample_size": 1}})
|
|
|
|
_status, probes = api.request("POST", f"{prefix}/evidence-probes/regions/run", admin=args.admin_a)
|
|
require(isinstance(probes, list) and len(probes) == 2, "Baseline must return exactly two regional probes.")
|
|
require(all(item.get("status") == "succeeded" and item.get("signature_valid") is True for item in probes), "Both baseline regional KMS probes must succeed.")
|
|
require(len({item.get("region") for item in probes}) == 2 and len({item.get("public_key_fingerprint") for item in probes}) == 2, "Regional probes must have distinct regions and key fingerprints.")
|
|
steps.append({"name": "two_region_kms_baseline", "status": "passed", "at": now_iso(), "evidence": summarize_probes(probes)})
|
|
|
|
_status, anchors = api.request("POST", f"{prefix}/evidence-transparency/anchors/run", admin=args.admin_a)
|
|
require(anchors.get("configured_targets") == 2 and anchors.get("verified_witnesses") == 2 and anchors.get("quorum_met") is True, "Both immutable witnesses must verify and meet quorum.")
|
|
steps.append({"name": "two_immutable_witness_baseline", "status": "passed", "at": now_iso(), "evidence": summarize_anchors(anchors)})
|
|
|
|
evidence = package_payload(args.evidence_package)
|
|
changes_path = f"{prefix}/evidence-audit/change-requests"
|
|
issue_payload = {
|
|
"action": "issue",
|
|
"name": f"P1-07.6 isolated UAT {datetime.now(UTC).strftime('%Y%m%d%H%M%S')}",
|
|
"scopes": ["evidence:verify", "trust-bundle:read", "transparency:read"],
|
|
"per_minute_quota": 60,
|
|
"per_day_quota": 1000,
|
|
"per_export_per_day_quota": 100,
|
|
"reason": "P1-07.6 isolated dual-control UAT credential issuance",
|
|
}
|
|
_status, issue = api.request("POST", changes_path, admin=args.admin_a, payload=issue_payload)
|
|
issue_id = issue.get("id")
|
|
require(isinstance(issue_id, str), "Credential issue request ID is missing.")
|
|
same_status, same_body = api.request("POST", f"{changes_path}/{issue_id}/review", admin=args.admin_a, payload={"decision": "approve", "note": "self approval must fail"}, expected=(409,))
|
|
same_code = same_body.get("detail", {}).get("code") if isinstance(same_body, dict) and isinstance(same_body.get("detail"), dict) else None
|
|
require(same_status == 409 and same_code == "audit_client_change_separation_of_duties", "Self-approval was not rejected by separation-of-duties control.")
|
|
_status, issued = api.request("POST", f"{changes_path}/{issue_id}/review", admin=args.admin_b, payload={"decision": "approve", "note": "second administrator approved UAT issuance"})
|
|
token_v1 = issued.get("bearer_token")
|
|
client = issued.get("client") or {}
|
|
client_id = client.get("id")
|
|
require(isinstance(token_v1, str) and isinstance(client_id, str) and client.get("credential_version") == 1, "Issue approval did not return a version-1 credential.")
|
|
api.request("POST", "/api/v1/public/reliable-decision/verify", payload={"package": evidence}, bearer=token_v1)
|
|
|
|
rotate_payload = {"action": "rotate", "audit_client_id": client_id, "reason": "P1-07.6 isolated UAT credential rotation"}
|
|
_status, rotate = api.request("POST", changes_path, admin=args.admin_a, payload=rotate_payload)
|
|
_status, rotated = api.request("POST", f"{changes_path}/{rotate['id']}/review", admin=args.admin_b, payload={"decision": "approve", "note": "second administrator approved UAT rotation"})
|
|
token_v2 = rotated.get("bearer_token")
|
|
require(isinstance(token_v2, str) and rotated.get("client", {}).get("credential_version") == 2, "Rotation approval did not return a version-2 credential.")
|
|
api.request("POST", "/api/v1/public/reliable-decision/verify", payload={"package": evidence}, bearer=token_v1, expected=(401,))
|
|
api.request("POST", "/api/v1/public/reliable-decision/verify", payload={"package": evidence}, bearer=token_v2)
|
|
|
|
revoke_payload = {"action": "revoke", "audit_client_id": client_id, "reason": "P1-07.6 isolated UAT credential revocation"}
|
|
_status, revoke = api.request("POST", changes_path, admin=args.admin_a, payload=revoke_payload)
|
|
_status, revoked = api.request("POST", f"{changes_path}/{revoke['id']}/review", admin=args.admin_b, payload={"decision": "approve", "note": "second administrator approved UAT revocation"})
|
|
require(revoked.get("client", {}).get("status") == "revoked", "Credential was not revoked.")
|
|
api.request("POST", "/api/v1/public/reliable-decision/verify", payload={"package": evidence}, bearer=token_v2, expected=(401,))
|
|
steps.append({"name": "credential_dual_control_lifecycle", "status": "passed", "at": now_iso(), "evidence": {"issue_request_id": issue_id, "rotate_request_id": rotate.get("id"), "revoke_request_id": revoke.get("id"), "audit_client_id": client_id, "versions_tested": [1, 2], "self_approval_rejected": True, "old_and_revoked_tokens_rejected": True}})
|
|
|
|
outage_started = False
|
|
try:
|
|
run_command(compose + ["stop", "kms-gateway-tencent"], cwd=infra_root)
|
|
outage_started = True
|
|
steps.append({"name": "tencent_kms_outage_injected", "status": "passed", "at": now_iso(), "evidence": {"service": "kms-gateway-tencent"}})
|
|
|
|
_status, failed_probes = api.request("POST", f"{prefix}/evidence-probes/regions/run", admin=args.admin_a)
|
|
require(isinstance(failed_probes, list) and len(failed_probes) == 2, "Outage probe run did not return two targets.")
|
|
failed = [item for item in failed_probes if item.get("status") == "failed"]
|
|
succeeded = [item for item in failed_probes if item.get("status") == "succeeded"]
|
|
require(len(failed) == 1 and len(succeeded) == 1 and "tencent" in str(failed[0].get("target_name", "")).lower(), "Outage must isolate Tencent while Alibaba remains healthy.")
|
|
steps.append({"name": "regional_failure_isolation", "status": "passed", "at": now_iso(), "evidence": summarize_probes(failed_probes)})
|
|
|
|
_status, failed_anchors = api.request("POST", f"{prefix}/evidence-transparency/anchors/run", admin=args.admin_a)
|
|
require(failed_anchors.get("verified_witnesses") == 1 and failed_anchors.get("quorum_met") is False, "Witness outage must fail the two-witness quorum.")
|
|
steps.append({"name": "witness_quorum_failure_detected", "status": "passed", "at": now_iso(), "evidence": summarize_anchors(failed_anchors)})
|
|
|
|
api.request("POST", f"{prefix}/run-scheduled-scan", admin=args.admin_a)
|
|
_status, insights = api.request("GET", f"{prefix}/insights?period_days=30", admin=args.admin_a)
|
|
alerts = [item for item in insights.get("slo_alerts", []) if item.get("metric_key") == "evidence_verification_probe" and item.get("status") == "open"]
|
|
require(alerts and alerts[0].get("handoff_case_id"), "Probe failure did not create an open SLO alert with a handoff case.")
|
|
alert = alerts[0]
|
|
case_id = alert["handoff_case_id"]
|
|
steps.append({"name": "slo_alert_and_handoff_created", "status": "passed", "at": now_iso(), "evidence": {"alert_id": alert.get("id"), "severity": alert.get("severity"), "burn_rate": alert.get("burn_rate"), "handoff_case_id": case_id}})
|
|
finally:
|
|
if outage_started:
|
|
run_command(compose + ["up", "-d", "--wait", "--wait-timeout", str(args.startup_timeout), "kms-gateway-tencent", "evidence-witness-tencent"], cwd=infra_root, timeout=args.startup_timeout + 120)
|
|
|
|
_status, recovered_probes = api.request("POST", f"{prefix}/evidence-probes/regions/run", admin=args.admin_b)
|
|
require(all(item.get("status") == "succeeded" and item.get("signature_valid") is True for item in recovered_probes), "Both KMS probes must recover after service restoration.")
|
|
_status, recovered_anchors = api.request("POST", f"{prefix}/evidence-transparency/anchors/run", admin=args.admin_b)
|
|
require(recovered_anchors.get("verified_witnesses") == 2 and recovered_anchors.get("quorum_met") is True, "Both witnesses must recover and restore quorum.")
|
|
steps.append({"name": "kms_and_witness_recovered", "status": "passed", "at": now_iso(), "evidence": {"probes": summarize_probes(recovered_probes), "anchors": summarize_anchors(recovered_anchors)}})
|
|
|
|
api.request("POST", f"{prefix}/cases/{case_id}/actions", admin=args.admin_a, payload={"action": "acknowledge", "note": "UAT alert acknowledged by first administrator"})
|
|
api.request("POST", f"{prefix}/cases/{case_id}/actions", admin=args.admin_b, payload={"action": "assign", "assignee": args.admin_b, "note": "UAT recovery assigned to second administrator"})
|
|
_status, resolved_case = api.request("POST", f"{prefix}/cases/{case_id}/actions", admin=args.admin_b, payload={"action": "resolve", "note": "Regional KMS and immutable witness probes recovered; manual takeover closed."})
|
|
require(resolved_case.get("status") == "resolved" and resolved_case.get("assigned_to") == args.admin_b, "Manual handoff case was not resolved by the second administrator.")
|
|
_status, events = api.request("GET", f"{prefix}/cases/{case_id}/events", admin=args.admin_a)
|
|
actions = [item.get("action") for item in events.get("items", [])]
|
|
require(all(action in actions for action in ("acknowledge", "assign", "resolve")), "Manual handoff audit trail is incomplete.")
|
|
steps.append({"name": "manual_takeover_closed_loop", "status": "passed", "at": now_iso(), "evidence": {"case_id": case_id, "status": "resolved", "assigned_to": args.admin_b, "actions": actions}})
|
|
|
|
|
|
def write_report(path: Path, report: dict[str, Any]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Run the isolated P1-07.6 evidence trust UAT")
|
|
parser.add_argument("--env-file", type=Path, required=True)
|
|
parser.add_argument("--admin-a", required=True)
|
|
parser.add_argument("--admin-b", required=True)
|
|
parser.add_argument("--tenant-id", default="default")
|
|
parser.add_argument("--api-base")
|
|
parser.add_argument("--evidence-package", type=Path)
|
|
parser.add_argument("--execute", action="store_true", help="start the isolated stack and run the destructive outage drill")
|
|
parser.add_argument("--static-only", action="store_true", help="skip checking for a local Docker executable during preflight")
|
|
parser.add_argument("--startup-timeout", type=int, default=300)
|
|
parser.add_argument("--report", type=Path, default=Path("p1-07-6-isolated-uat-report.json"))
|
|
args = parser.parse_args()
|
|
|
|
report: dict[str, Any] = {
|
|
"format_version": "yuqei-p1-07-6-isolated-uat/v1",
|
|
"started_at": now_iso(),
|
|
"mode": "execute" if args.execute else "preflight",
|
|
"environment_file": str(args.env_file.resolve(strict=False)),
|
|
"tenant_id": args.tenant_id,
|
|
"administrators": {"admin_a": args.admin_a, "admin_b": args.admin_b},
|
|
"preflight": [],
|
|
"steps": [],
|
|
"status": "running",
|
|
}
|
|
try:
|
|
require(args.env_file.is_file(), "The UAT env file does not exist.")
|
|
env = load_env(args.env_file)
|
|
report["preflight"] = preflight(env, admin_a=args.admin_a, admin_b=args.admin_b, static_only=args.static_only)
|
|
failed = [item for item in report["preflight"] if item["status"] != "passed"]
|
|
require(not failed, "UAT preflight failed: " + ", ".join(item["name"] for item in failed))
|
|
if args.execute:
|
|
require(not args.static_only, "--static-only cannot be combined with --execute.")
|
|
require(args.evidence_package is not None and args.evidence_package.is_file(), "--execute requires a real --evidence-package JSON file.")
|
|
execute_uat(args, env, report)
|
|
report["status"] = "passed"
|
|
return_code = 0
|
|
except (UatFailure, OSError, ValueError, json.JSONDecodeError, subprocess.TimeoutExpired) as error:
|
|
report["status"] = "failed"
|
|
report["failure"] = {"message": str(error), "at": now_iso()}
|
|
return_code = 1
|
|
finally:
|
|
report["completed_at"] = now_iso()
|
|
write_report(args.report, report)
|
|
print(json.dumps({"status": report["status"], "report": str(args.report.resolve()), "failed_checks": [item["name"] for item in report["preflight"] if item["status"] == "failed"]}, ensure_ascii=False))
|
|
return return_code
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|