fix(rc06): route participant over private TLS
This commit is contained in:
@@ -27,6 +27,7 @@ class ContractApiSettings(BaseSettings):
|
||||
ai_platform_service_key: str = ""
|
||||
ai_platform_rc06_publication_key: str = ""
|
||||
ai_platform_timeout_seconds: float = Field(default=30.0, ge=1.0, le=600.0)
|
||||
rc06_ai_participant_ca_file: str = ""
|
||||
rc06_publication_shared_secret: str = ""
|
||||
rc06_frozen_candidate_commit: str = ""
|
||||
rc06_release_image_digest: str = ""
|
||||
@@ -753,6 +754,7 @@ def _check_rc06_publication_facade_config(settings: ContractApiSettings) -> Runt
|
||||
("approval root public key", settings.rc06_approval_root_public_key_file),
|
||||
("apply service public key", settings.rc06_apply_service_public_key_file),
|
||||
("facade bearer token", settings.rc06_publication_bearer_token_file),
|
||||
("AI participant CA", settings.rc06_ai_participant_ca_file),
|
||||
("KMS credential", settings.reliable_evidence_signing_credential_file),
|
||||
("KMS CA", settings.reliable_evidence_signing_ca_file),
|
||||
)
|
||||
|
||||
+23
-1
@@ -10,6 +10,7 @@ import hashlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import ssl
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from threading import RLock
|
||||
from typing import Any, Protocol
|
||||
@@ -137,11 +138,29 @@ class FailClosedRc06AuthorizationVerifier:
|
||||
|
||||
|
||||
class HttpAiRc06Client:
|
||||
def __init__(self, *, base_url: str, service_key: str, timeout: float, api_prefix: str = "/api/v1") -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
service_key: str,
|
||||
timeout: float,
|
||||
api_prefix: str = "/api/v1",
|
||||
tls_ca_file: str = "",
|
||||
) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._headers = {"X-RC06-Publication-Service-Key": service_key}
|
||||
self._timeout = timeout
|
||||
self._path = f"{api_prefix}/internal/rc06-publications"
|
||||
try:
|
||||
self._tls_verify: ssl.SSLContext | bool = (
|
||||
ssl.create_default_context(cafile=tls_ca_file)
|
||||
if tls_ca_file
|
||||
else True
|
||||
)
|
||||
except (OSError, ssl.SSLError) as error:
|
||||
raise Rc06PublicationError(
|
||||
"AI RC06 participant TLS trust could not be loaded."
|
||||
) from error
|
||||
|
||||
def _request(self, method: str, path: str, *, json_payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
@@ -151,6 +170,8 @@ class HttpAiRc06Client:
|
||||
headers=self._headers,
|
||||
json=json_payload,
|
||||
timeout=self._timeout,
|
||||
verify=self._tls_verify,
|
||||
trust_env=False,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
@@ -1465,6 +1486,7 @@ def register_rc06_publication_routes(
|
||||
base_url=settings.ai_platform_base_url,
|
||||
service_key=settings.ai_platform_rc06_publication_key,
|
||||
timeout=settings.ai_platform_timeout_seconds,
|
||||
tls_ca_file=settings.rc06_ai_participant_ca_file,
|
||||
),
|
||||
frozen_candidate_commit=settings.rc06_frozen_candidate_commit,
|
||||
release_image_digest=settings.rc06_release_image_digest,
|
||||
|
||||
+1
@@ -368,6 +368,7 @@ class Rc06PublicationFacadeService:
|
||||
base_url=settings.ai_platform_base_url,
|
||||
service_key=settings.ai_platform_rc06_publication_key,
|
||||
timeout=settings.ai_platform_timeout_seconds,
|
||||
tls_ca_file=settings.rc06_ai_participant_ca_file,
|
||||
),
|
||||
authorization_verifier=authorization,
|
||||
frozen_candidate_commit=settings.rc06_frozen_candidate_commit,
|
||||
|
||||
+27
@@ -36,6 +36,9 @@ def _facade_settings(tmp_path: Path, **overrides) -> ContractApiSettings:
|
||||
"ai_platform_base_url": "https://ai-platform.internal.example.com",
|
||||
"ai_platform_service_key": "general-ai-key-000000000000000000000001",
|
||||
"ai_platform_rc06_publication_key": "rc06-ai-key-00000000000000000000000001",
|
||||
"rc06_ai_participant_ca_file": _write(
|
||||
tmp_path / "ai-participant-ca.pem", "ai-participant-ca-material"
|
||||
),
|
||||
"web_proxy_shared_secret": "web-proxy-key-000000000000000000000001",
|
||||
"rc06_publication_facade_enabled": True,
|
||||
"rc06_low_level_coordinator_http_enabled": False,
|
||||
@@ -103,6 +106,10 @@ def test_enabled_facade_startup_checks_all_independent_trust_boundaries(tmp_path
|
||||
validate_rc06_publication_startup(
|
||||
_facade_settings(tmp_path, reliable_evidence_signing_endpoint="")
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="AI participant CA path must be absolute"):
|
||||
validate_rc06_publication_startup(
|
||||
_facade_settings(tmp_path, rc06_ai_participant_ca_file="")
|
||||
)
|
||||
bearer = (tmp_path / "facade.token").read_text(encoding="utf-8")
|
||||
with pytest.raises(RuntimeError, match="independent from the KMS credential"):
|
||||
validate_rc06_publication_startup(
|
||||
@@ -121,6 +128,12 @@ def test_production_compose_and_edge_expose_only_the_hardened_facade() -> None:
|
||||
contract = model["services"]["contract-api"]
|
||||
assert "ports" in contract and contract["ports"] is None
|
||||
contract_env = contract["environment"]
|
||||
assert contract_env["CONTRACT_API_AI_PLATFORM_BASE_URL"] == (
|
||||
"https://${RC06_AI_INTERNAL_SERVER_NAME:?RC06_AI_INTERNAL_SERVER_NAME is required}:8443"
|
||||
)
|
||||
assert contract_env["CONTRACT_API_RC06_AI_PARTICIPANT_CA_FILE"] == (
|
||||
"/run/yuqei/rc06/ai-participant-ca.pem"
|
||||
)
|
||||
assert contract_env["CONTRACT_API_RC06_PUBLICATION_FACADE_ENABLED"] == "true"
|
||||
assert contract_env["CONTRACT_API_RC06_LOW_LEVEL_COORDINATOR_HTTP_ENABLED"] == "false"
|
||||
assert contract_env["CONTRACT_API_RC06_PUBLICATION_TENANT_ID"].startswith(
|
||||
@@ -142,6 +155,10 @@ def test_production_compose_and_edge_expose_only_the_hardened_facade() -> None:
|
||||
for mount in contract["volumes"]
|
||||
if "/run/yuqei/rc06/" in mount
|
||||
)
|
||||
assert any(
|
||||
mount.endswith(":/run/yuqei/rc06/ai-participant-ca.pem:ro")
|
||||
for mount in contract["volumes"]
|
||||
)
|
||||
assert "config_status" in " ".join(contract["healthcheck"]["test"])
|
||||
|
||||
ai_env = model["services"]["ai-platform-api"]["environment"]
|
||||
@@ -163,6 +180,12 @@ def test_production_compose_and_edge_expose_only_the_hardened_facade() -> None:
|
||||
assert edge["environment"]["RC06_PUBLICATION_TENANT_ID"].startswith(
|
||||
"${RC06_PUBLICATION_TENANT_ID:"
|
||||
)
|
||||
assert edge["environment"]["RC06_AI_INTERNAL_SERVER_NAME"].startswith(
|
||||
"${RC06_AI_INTERNAL_SERVER_NAME:"
|
||||
)
|
||||
assert edge["networks"]["default"]["aliases"] == [
|
||||
"${RC06_AI_INTERNAL_SERVER_NAME:?RC06_AI_INTERNAL_SERVER_NAME is required}"
|
||||
]
|
||||
nginx = (
|
||||
REPO_ROOT / "yuqei-infra/nginx/templates/default.conf.template"
|
||||
).read_text(encoding="utf-8")
|
||||
@@ -171,6 +194,9 @@ def test_production_compose_and_edge_expose_only_the_hardened_facade() -> None:
|
||||
assert "proxy_set_header X-RC06-Tenant-ID ${RC06_PUBLICATION_TENANT_ID};" in nginx
|
||||
assert "location ~ ^/contract/api/v1/internal/rc06-publications" in nginx
|
||||
assert "location ~ ^/ai/api/v1/internal/rc06-publications" in nginx
|
||||
assert "listen 8443 ssl;" in nginx
|
||||
assert "server_name ${RC06_AI_INTERNAL_SERVER_NAME};" in nginx
|
||||
assert "location ~ ^/api/v1/internal/rc06-publications(?:/|$)" in nginx
|
||||
facade_block = nginx.split(
|
||||
"location = /internal/rc06/publication-transactions/apply", 1
|
||||
)[1].split("location ~", 1)[0]
|
||||
@@ -187,6 +213,7 @@ def test_production_compose_and_edge_expose_only_the_hardened_facade() -> None:
|
||||
"RC06_KMS_CREDENTIAL_FILE",
|
||||
"RC06_KMS_CA_FILE_HOST",
|
||||
"RC06_AI_PLATFORM_PUBLICATION_SERVICE_KEY",
|
||||
"RC06_AI_INTERNAL_SERVER_NAME",
|
||||
):
|
||||
assert f"{key}=" in env_example
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ from yuqei_contract_api.persistence.models import (
|
||||
TemplatePublishedSnapshotModel,
|
||||
)
|
||||
from yuqei_contract_api.rc06_publication import (
|
||||
HttpAiRc06Client,
|
||||
Rc06Conflict,
|
||||
Rc06PrepareRequest,
|
||||
Rc06PublicationCoordinator,
|
||||
@@ -103,6 +104,47 @@ def test_postgresql_authority_clock_uses_wall_clock_not_transaction_timestamp():
|
||||
assert "current_timestamp" not in session.statement.lower()
|
||||
|
||||
|
||||
def test_http_ai_client_pins_ca_and_ignores_process_proxy_environment(monkeypatch):
|
||||
tls_context = object()
|
||||
observed = {}
|
||||
|
||||
class _Response:
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return {"state": "prepared"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
rc06_publication_module.ssl,
|
||||
"create_default_context",
|
||||
lambda *, cafile: tls_context if cafile == "/pinned/ai-ca.pem" else None,
|
||||
)
|
||||
|
||||
def request(method, url, **kwargs):
|
||||
observed.update(method=method, url=url, **kwargs)
|
||||
return _Response()
|
||||
|
||||
monkeypatch.setattr(rc06_publication_module.httpx, "request", request)
|
||||
client = HttpAiRc06Client(
|
||||
base_url="https://ai-participant.internal:8443",
|
||||
service_key="dedicated-participant-key",
|
||||
timeout=30,
|
||||
tls_ca_file="/pinned/ai-ca.pem",
|
||||
)
|
||||
|
||||
assert client.status("rc06-transaction-0001") == {"state": "prepared"}
|
||||
assert observed["url"] == (
|
||||
"https://ai-participant.internal:8443"
|
||||
"/api/v1/internal/rc06-publications/rc06-transaction-0001"
|
||||
)
|
||||
assert observed["verify"] is tls_context
|
||||
assert observed["trust_env"] is False
|
||||
assert observed["headers"] == {
|
||||
"X-RC06-Publication-Service-Key": "dedicated-participant-key"
|
||||
}
|
||||
|
||||
|
||||
def _ai_service(repository):
|
||||
return Rc06PromptPublicationService(
|
||||
repository._session_factory,
|
||||
|
||||
@@ -20,6 +20,9 @@ BACKUP_UTILITY_IMAGE=alpine@sha256:CHANGE_ME_64_HEX_DIGEST
|
||||
|
||||
BACKEND_SERVER_NAME=api.contracts.example.com
|
||||
BACKEND_PUBLIC_BASE_URL=https://api.contracts.example.com
|
||||
# Compose-private DNS alias for the edge's un-published TLS listener. Keep this
|
||||
# in the certificate SAN; using BACKEND_SERVER_NAME reuses the pinned edge cert.
|
||||
RC06_AI_INTERNAL_SERVER_NAME=api.contracts.example.com
|
||||
BACKEND_TLS_CERT_DIR=/etc/yuqei/tls
|
||||
# Pin the exact CA bundle used by preflight and the runtime edge health probe.
|
||||
BACKEND_TLS_TRUSTED_CA_FILE=/etc/ssl/certs/ca-certificates.crt
|
||||
@@ -149,7 +152,6 @@ CONTRACT_API_BATCH_DOWNLOAD_MAX_BYTES=104857600
|
||||
# regenerated from one frozen candidate; CHANGE_ME values intentionally fail
|
||||
# startup/compose interpolation. The three service credentials below are
|
||||
# independent and must not be reused by the web proxy or general AI transport.
|
||||
RC06_AI_PLATFORM_BASE_URL=https://ai-platform-api.internal.example.com
|
||||
RC06_AI_PLATFORM_PUBLICATION_SERVICE_KEY=CHANGE_ME_USE_A_DEDICATED_RANDOM_32_BYTE_SECRET
|
||||
RC06_PUBLICATION_ENVIRONMENT_ID=CHANGE_ME_STABLE_RC06_ENVIRONMENT_ID
|
||||
RC06_PUBLICATION_TENANT_ID=CHANGE_ME_FIXED_RC06_TENANT_ID
|
||||
|
||||
@@ -34,8 +34,9 @@ services:
|
||||
pull_policy: always
|
||||
# SMTP remains opt-in through docker-compose.production.smtp.yml so disabled mode has no secret-file dependency.
|
||||
environment:
|
||||
CONTRACT_API_AI_PLATFORM_BASE_URL: ${RC06_AI_PLATFORM_BASE_URL:?RC06_AI_PLATFORM_BASE_URL must be an internal HTTPS endpoint}
|
||||
CONTRACT_API_AI_PLATFORM_BASE_URL: "https://${RC06_AI_INTERNAL_SERVER_NAME:?RC06_AI_INTERNAL_SERVER_NAME is required}:8443"
|
||||
CONTRACT_API_AI_PLATFORM_RC06_PUBLICATION_KEY: ${RC06_AI_PLATFORM_PUBLICATION_SERVICE_KEY:?RC06_AI_PLATFORM_PUBLICATION_SERVICE_KEY is required}
|
||||
CONTRACT_API_RC06_AI_PARTICIPANT_CA_FILE: /run/yuqei/rc06/ai-participant-ca.pem
|
||||
CONTRACT_API_RC06_PUBLICATION_FACADE_ENABLED: "true"
|
||||
CONTRACT_API_RC06_LOW_LEVEL_COORDINATOR_HTTP_ENABLED: "false"
|
||||
CONTRACT_API_RC06_PUBLICATION_ENVIRONMENT_ID: ${RC06_PUBLICATION_ENVIRONMENT_ID:?RC06_PUBLICATION_ENVIRONMENT_ID is required}
|
||||
@@ -73,6 +74,7 @@ services:
|
||||
- ${RC06_APPROVAL_ROOT_PUBLIC_KEY_FILE_HOST:?RC06_APPROVAL_ROOT_PUBLIC_KEY_FILE_HOST is required}:/run/yuqei/rc06/approval-root-public.pem:ro
|
||||
- ${RC06_APPLY_SERVICE_PUBLIC_KEY_FILE_HOST:?RC06_APPLY_SERVICE_PUBLIC_KEY_FILE_HOST is required}:/run/yuqei/rc06/apply-service-public.pem:ro
|
||||
- ${RC06_KMS_CA_FILE_HOST:?RC06_KMS_CA_FILE_HOST is required}:/run/yuqei/rc06/kms-ca.pem:ro
|
||||
- ${BACKEND_TLS_TRUSTED_CA_FILE:?BACKEND_TLS_TRUSTED_CA_FILE is required}:/run/yuqei/rc06/ai-participant-ca.pem:ro
|
||||
secrets:
|
||||
- source: rc06_facade_bearer_token
|
||||
target: rc06_facade_bearer_token
|
||||
@@ -154,6 +156,7 @@ services:
|
||||
environment:
|
||||
BACKEND_SERVER_NAME: ${BACKEND_SERVER_NAME:?BACKEND_SERVER_NAME is required}
|
||||
RC06_PUBLICATION_TENANT_ID: ${RC06_PUBLICATION_TENANT_ID:?RC06_PUBLICATION_TENANT_ID is required}
|
||||
RC06_AI_INTERNAL_SERVER_NAME: ${RC06_AI_INTERNAL_SERVER_NAME:?RC06_AI_INTERNAL_SERVER_NAME is required}
|
||||
NGINX_ENVSUBST_OUTPUT_DIR: /etc/nginx/conf.d
|
||||
ports:
|
||||
- "${BACKEND_HTTP_BIND_ADDRESS:-0.0.0.0}:${BACKEND_HTTP_PORT:-80}:80"
|
||||
@@ -164,6 +167,10 @@ services:
|
||||
- ${BACKEND_TLS_TRUSTED_CA_FILE:?BACKEND_TLS_TRUSTED_CA_FILE is required}:/etc/nginx/trusted-ca.pem:ro
|
||||
extra_hosts:
|
||||
- "${BACKEND_SERVER_NAME:?BACKEND_SERVER_NAME is required}:127.0.0.1"
|
||||
networks:
|
||||
default:
|
||||
aliases:
|
||||
- ${RC06_AI_INTERNAL_SERVER_NAME:?RC06_AI_INTERNAL_SERVER_NAME is required}
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /var/cache/nginx
|
||||
|
||||
@@ -17,6 +17,42 @@ server {
|
||||
return 308 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# Compose-private RC06 participant transport. Port 8443 is deliberately not
|
||||
# published by the production override. Only the exact AI participant prefix
|
||||
# is proxied; the public 443 server continues to return 404 for this route.
|
||||
server {
|
||||
listen 8443 ssl;
|
||||
http2 on;
|
||||
server_name ${RC06_AI_INTERNAL_SERVER_NAME};
|
||||
|
||||
ssl_certificate /etc/nginx/tls/tls.crt;
|
||||
ssl_certificate_key /etc/nginx/tls/tls.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_tickets off;
|
||||
|
||||
server_tokens off;
|
||||
client_max_body_size 16m;
|
||||
|
||||
location ~ ^/api/v1/internal/rc06-publications(?:/|$) {
|
||||
proxy_pass http://ai_platform_upstream$request_uri;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $server_name;
|
||||
proxy_set_header X-Real-IP "";
|
||||
proxy_set_header X-Forwarded-For "";
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header Connection "";
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 660s;
|
||||
proxy_send_timeout 660s;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 404;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
|
||||
Reference in New Issue
Block a user