fix(ssl): replace verify=True with ssl.create_default_context() for corporate CA support
Core fix: all httpx.AsyncClient instances now use ssl.create_default_context() instead of bool verify=True, which uses certifi and ignores system CA store. This makes corporate CA certificates installed via update-ca-certificates visible to Python HTTP clients. Files: - async_network.py: AsyncAPIClient.__init__ converts True→SSLContext - client_registry.py: get_client converts bool→SSLContext before passing - notifications/providers.py: _get_http_client uses ssl.create_default_context() - services/git/_base.py: GitServiceBase uses ssl.create_default_context() - translate/_llm_async_http.py: _get_verify returns SSLContext (not string) Test: new integration test with 3-tier PKI (Root→Intermediate→Server), TLS-protected Superset container, and custom CA installation via update-ca-certificates or SSL_CERT_DIR fallback. - conftest.py: added ca_chain, install_custom_ca, superset_tls_env fixtures; parameterized superset_container for TLS mode - test_superset_tls_custom_ca.py: 8 tests (openssl -CApath, -CAfile certifi, httpx capath, httpx certifi, AsyncAPIClient, SupersetClient, fingerprint, verify=False)
This commit is contained in:
@@ -18,6 +18,7 @@ import asyncio
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import ssl
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -59,23 +60,32 @@ class AsyncAPIClient:
|
||||
# @DATA_CONTRACT Input[config: Dict[str, Any]] -> self._auth_cache_key[str]
|
||||
# @RELATION CALLS -> [AsyncAPIClient._normalize_base_url]
|
||||
# @RELATION DEPENDS_ON -> [SupersetAuthCache]
|
||||
def __init__(self, config: dict[str, Any], verify_ssl: bool = True, timeout: int = DEFAULT_TIMEOUT,
|
||||
def __init__(self, config: dict[str, Any], verify_ssl: bool | ssl.SSLContext = True, timeout: int = DEFAULT_TIMEOUT,
|
||||
semaphore: asyncio.Semaphore | None = None):
|
||||
self.base_url: str = self._normalize_base_url(config.get("base_url", ""))
|
||||
self.api_base_url: str = f"{self.base_url}/api/v1"
|
||||
self.auth = config.get("auth")
|
||||
self.request_settings = {"verify_ssl": verify_ssl, "timeout": timeout}
|
||||
# Use system CA store via ssl.create_default_context() instead of bool True.
|
||||
# httpx defaults to certifi, ignoring SSL_CERT_FILE and corporate CA certs.
|
||||
if verify_ssl is True:
|
||||
ssl_context: bool | ssl.SSLContext = ssl.create_default_context()
|
||||
elif verify_ssl is False:
|
||||
ssl_context = False
|
||||
else:
|
||||
ssl_context = verify_ssl # уже SSLContext
|
||||
self._client = httpx.AsyncClient(
|
||||
verify=verify_ssl,
|
||||
verify=ssl_context,
|
||||
timeout=httpx.Timeout(timeout),
|
||||
follow_redirects=True,
|
||||
)
|
||||
self._tokens: dict[str, str] = {}
|
||||
self._authenticated = False
|
||||
# Cache key по-прежнему использует bool для хеширования
|
||||
self._auth_cache_key = SupersetAuthCache.build_key(
|
||||
self.base_url,
|
||||
self.auth,
|
||||
verify_ssl,
|
||||
verify_ssl if isinstance(verify_ssl, bool) else True,
|
||||
)
|
||||
# Optional shared semaphore for connection limiting (set by SupersetClientRegistry).
|
||||
self._semaphore = semaphore
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
# shows callers create SupersetClient anyway, defeating the purpose.
|
||||
|
||||
import asyncio
|
||||
import ssl
|
||||
from typing import Any
|
||||
|
||||
from ..logger import logger
|
||||
@@ -47,6 +48,7 @@ def _build_env_id(env: Any) -> str:
|
||||
if isinstance(env, dict):
|
||||
return f"{env.get('base_url','')}|{env.get('username','')}"
|
||||
return str(id(env))
|
||||
# #endregion _build_env_id
|
||||
|
||||
|
||||
# #region get_client [C:3] [TYPE Function]
|
||||
@@ -97,9 +99,15 @@ async def get_client(
|
||||
config = {}
|
||||
verify_ssl = True
|
||||
timeout = request_timeout
|
||||
# Convert bool to SSLContext with system CA store — httpx defaults to certifi,
|
||||
# ignoring corporate CA certificates installed via update-ca-certificates.
|
||||
if verify_ssl is True:
|
||||
ssl_context: bool | ssl.SSLContext = ssl.create_default_context()
|
||||
else:
|
||||
ssl_context = verify_ssl
|
||||
async_client = AsyncAPIClient(
|
||||
config=config,
|
||||
verify_ssl=verify_ssl,
|
||||
verify_ssl=ssl_context,
|
||||
timeout=timeout,
|
||||
)
|
||||
semaphore = asyncio.Semaphore(connection_pool_size)
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import ssl
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -31,19 +32,22 @@ DEFAULT_MAX_TOKENS: int = 8192
|
||||
|
||||
|
||||
# #region _get_verify [C:1] [TYPE Function] [SEMANTICS translate, ssl, verify]
|
||||
# @BRIEF Resolve SSL verification path from LLM_SSL_VERIFY env var.
|
||||
# @RATIONALE Используем capath=/etc/ssl/certs/ вместо cafile, потому что
|
||||
# OpenSSL 3.x не использует intermediate CA сертификаты из cafile для
|
||||
# построения цепочки (verify code 20). capath с хеш-симлинками работает
|
||||
# корректно (verify code 0).
|
||||
# @BRIEF Resolve SSL verification context from LLM_SSL_VERIFY env var.
|
||||
# @RATIONALE Используем capath=/etc/ssl/certs/ через ssl.create_default_context,
|
||||
# потому что OpenSSL 3.x не использует intermediate CA сертификаты из cafile
|
||||
# для построения цепочки (verify code 20). capath с хеш-симлинками работает
|
||||
# корректно (verify code 0). Возвращаем SSLContext вместо строки — httpx 0.28.x
|
||||
# депрекейтит строковый путь в verify=.
|
||||
# @REJECTED cafile отвергнут — OpenSSL 3.x не использует intermediate CA
|
||||
# из единого bundle-файла. Только capath с хеш-симлинками даёт code 0.
|
||||
# @POST Returns path to /etc/ssl/certs/ when enabled, False when disabled.
|
||||
def _get_verify() -> str | bool:
|
||||
# @REJECTED Строковый путь "/etc/ssl/certs/" отвергнут — httpx 0.28.x депрекейтит
|
||||
# строки в verify=, требует SSLContext.
|
||||
# @POST Returns ssl.SSLContext with capath when enabled, False when disabled.
|
||||
def _get_verify() -> ssl.SSLContext | bool:
|
||||
raw = os.getenv("LLM_SSL_VERIFY", "true").strip().lower()
|
||||
if raw in ("false", "0", "no", "off"):
|
||||
return False
|
||||
return "/etc/ssl/certs/"
|
||||
return ssl.create_default_context(capath="/etc/ssl/certs/")
|
||||
# #endregion _get_verify
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import ssl
|
||||
import threading
|
||||
from urllib.parse import quote
|
||||
|
||||
@@ -56,8 +57,11 @@ class GitServiceBase:
|
||||
self._closed = False
|
||||
self._close_lock = threading.Lock()
|
||||
|
||||
# Fix 5: Shared httpx.AsyncClient with connection pooling
|
||||
# Fix 5: Shared httpx.AsyncClient with connection pooling.
|
||||
# Uses system CA store (ssl.create_default_context) instead of certifi
|
||||
# to trust corporate CA certificates installed via update-ca-certificates.
|
||||
self._http_client = httpx.AsyncClient(
|
||||
verify=ssl.create_default_context(),
|
||||
timeout=30.0,
|
||||
limits=httpx.Limits(max_keepalive_connections=20, max_connections=100),
|
||||
)
|
||||
@@ -397,5 +401,3 @@ class GitServiceBase:
|
||||
# endregion close
|
||||
# #endregion GitServiceBase
|
||||
# #endregion GitServiceBase
|
||||
# #endregion GitServiceBase
|
||||
# #endregion GitServiceBase
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
import ssl
|
||||
from typing import Any
|
||||
|
||||
import aiosmtplib
|
||||
@@ -32,10 +33,16 @@ _http_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
async def _get_http_client() -> httpx.AsyncClient:
|
||||
"""Get or create the module-level httpx.AsyncClient singleton."""
|
||||
"""Get or create the module-level httpx.AsyncClient singleton.
|
||||
Uses system CA store (ssl.create_default_context) instead of certifi
|
||||
to trust corporate CA certificates installed via update-ca-certificates.
|
||||
"""
|
||||
global _http_client
|
||||
if _http_client is None:
|
||||
_http_client = httpx.AsyncClient(timeout=httpx.Timeout(30.0))
|
||||
_http_client = httpx.AsyncClient(
|
||||
verify=ssl.create_default_context(),
|
||||
timeout=httpx.Timeout(30.0),
|
||||
)
|
||||
return _http_client
|
||||
|
||||
|
||||
|
||||
@@ -220,6 +220,111 @@ def verify_postgres_features(session: Session) -> dict:
|
||||
# #endregion verify_postgres_features
|
||||
|
||||
|
||||
# ── TLS / PKI Fixtures ────────────────────────────────────────────────
|
||||
|
||||
|
||||
# #region ca_chain [C:2] [TYPE Fixture]
|
||||
# @BRIEF Session-scoped — generates 3-tier PKI (Root CA → Intermediate CA → Server cert).
|
||||
# @POST Returns dict with root_crt, intermediate_crt, server_crt, server_key, fullchain PEM strings.
|
||||
@pytest.fixture(scope="session")
|
||||
def ca_chain():
|
||||
"""Generate 3-tier PKI: Root CA → Intermediate CA → Server certificate (SAN: localhost)."""
|
||||
from cryptography import x509
|
||||
from cryptography.x509.oid import NameOID
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
import datetime
|
||||
|
||||
def _gen_key():
|
||||
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
def _gen_cert_pem(subject_name, issuer_name, subject_key, issuer_key, is_ca=False, san_dns=None, san_ip=None):
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
builder = x509.CertificateBuilder()
|
||||
builder = builder.subject_name(x509.Name([
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, subject_name),
|
||||
]))
|
||||
builder = builder.issuer_name(x509.Name([
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, issuer_name),
|
||||
]))
|
||||
builder = builder.not_valid_before(now - datetime.timedelta(hours=1))
|
||||
builder = builder.not_valid_after(now + datetime.timedelta(days=365))
|
||||
builder = builder.serial_number(x509.random_serial_number())
|
||||
builder = builder.public_key(subject_key.public_key())
|
||||
|
||||
if is_ca:
|
||||
builder = builder.add_extension(
|
||||
x509.BasicConstraints(ca=True, path_length=None), critical=True,
|
||||
)
|
||||
builder = builder.add_extension(
|
||||
x509.KeyUsage(
|
||||
key_cert_sign=True, crl_sign=True,
|
||||
digital_signature=False, content_commitment=False,
|
||||
key_encipherment=False, data_encipherment=False,
|
||||
key_agreement=False, encipher_only=False, decipher_only=False,
|
||||
), critical=True,
|
||||
)
|
||||
else:
|
||||
# Server cert
|
||||
builder = builder.add_extension(
|
||||
x509.BasicConstraints(ca=False, path_length=None), critical=True,
|
||||
)
|
||||
sans: list[x509.GeneralName] = []
|
||||
if san_dns:
|
||||
sans.append(x509.DNSName(san_dns))
|
||||
if san_ip:
|
||||
sans.append(x509.IPAddress(san_ip))
|
||||
builder = builder.add_extension(
|
||||
x509.SubjectAlternativeName(sans), critical=False,
|
||||
)
|
||||
|
||||
cert = builder.sign(issuer_key, hashes.SHA256())
|
||||
return cert.public_bytes(serialization.Encoding.PEM).decode()
|
||||
|
||||
root_key = _gen_key()
|
||||
root_crt = _gen_cert_pem("Test Root CA", "Test Root CA", root_key, root_key, is_ca=True)
|
||||
|
||||
intermediate_key = _gen_key()
|
||||
intermediate_crt = _gen_cert_pem(
|
||||
"Test Intermediate CA", "Test Root CA",
|
||||
intermediate_key, root_key, is_ca=True,
|
||||
)
|
||||
|
||||
server_key = _gen_key()
|
||||
import ipaddress
|
||||
server_crt = _gen_cert_pem(
|
||||
"localhost", "Test Intermediate CA",
|
||||
server_key, intermediate_key, is_ca=False,
|
||||
san_dns="localhost", san_ip=ipaddress.IPv4Address("127.0.0.1"),
|
||||
)
|
||||
|
||||
# Full chain for server: server PEM + intermediate PEM (order: leaf first)
|
||||
fullchain = server_crt + "\n" + intermediate_crt
|
||||
|
||||
return {
|
||||
"root_crt": root_crt,
|
||||
"root_key": root_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
).decode(),
|
||||
"intermediate_crt": intermediate_crt,
|
||||
"intermediate_key": intermediate_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
).decode(),
|
||||
"server_crt": server_crt,
|
||||
"server_key": server_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
).decode(),
|
||||
"fullchain": fullchain,
|
||||
}
|
||||
# #endregion ca_chain
|
||||
|
||||
|
||||
# ── Superset Integration Test Fixtures ────────────────────────────────
|
||||
|
||||
|
||||
@@ -278,23 +383,54 @@ def superset_admin_password():
|
||||
|
||||
# #region superset_container [C:3] [TYPE Fixture]
|
||||
# @BRIEF Session-scoped Apache Superset container — db upgrade → admin → init → web server.
|
||||
# Can be parametrized with {"tls": True} to enable HTTPS with custom 3-tier PKI.
|
||||
# @PRE Docker daemon is running. superset_db_url points to an empty Postgres database.
|
||||
# @POST Superset web server is listening on port 8088. Admin user created.
|
||||
# @POST Superset web server is listening on port 8088 (HTTP or HTTPS). Admin user created.
|
||||
# @SIDE_EFFECT Spins up a short-lived init container, then a long-lived web container.
|
||||
# In TLS mode: writes server cert + key into container via base64, runs with --cert/--key.
|
||||
# @RATIONALE Two-container approach (init + web) avoids race conditions where the web
|
||||
# server starts before migrations have completed. The init container runs `superset db
|
||||
# upgrade`, creates the admin user, and runs `superset init`, then exits. The web
|
||||
# container then starts against the fully initialized database.
|
||||
# TLS parametrization allows testing corporate SSL certificate chain with the
|
||||
# same Superset instance, using a dynamically generated 3-tier PKI.
|
||||
# @REJECTED Single container with sequential exec calls — cannot change entrypoint
|
||||
# from init to web server within the same container lifecycle.
|
||||
# @REJECTED Using `latest` tag — version drift breaks tests silently.
|
||||
@pytest.fixture(scope="session")
|
||||
def superset_container(superset_db_url, superset_secret_key, superset_admin_password):
|
||||
"""Start an Apache Superset instance for integration testing."""
|
||||
def superset_container(request, superset_db_url, superset_secret_key, superset_admin_password):
|
||||
"""Start an Apache Superset instance for integration testing.
|
||||
|
||||
Parametrize with indirect=True, {"tls": True} to enable HTTPS with custom CA chain.
|
||||
"""
|
||||
from testcontainers.core.container import DockerContainer
|
||||
import base64
|
||||
|
||||
superset_image = "apache/superset:4.1.2"
|
||||
|
||||
# Check if TLS was requested via parametrization
|
||||
tls_config = getattr(request, 'param', None)
|
||||
use_tls = tls_config is not None and tls_config.get('tls', False)
|
||||
|
||||
if use_tls:
|
||||
ca_chain = request.getfixturevalue('ca_chain')
|
||||
# Base64-encode certs to avoid shell escaping issues in heredoc
|
||||
fullchain_b64 = base64.b64encode(ca_chain["fullchain"].encode()).decode()
|
||||
key_b64 = base64.b64encode(ca_chain["server_key"].encode()).decode()
|
||||
_write_certs = (
|
||||
f"echo '{fullchain_b64}' | base64 -d > /tmp/server.crt && "
|
||||
f"echo '{key_b64}' | base64 -d > /tmp/server.key"
|
||||
)
|
||||
_protocol = "https"
|
||||
_superset_run = (
|
||||
f"superset run -h 0.0.0.0 -p 8088 "
|
||||
f"--cert /tmp/server.crt --key /tmp/server.key --with-threads"
|
||||
)
|
||||
else:
|
||||
_write_certs = ""
|
||||
_protocol = "http"
|
||||
_superset_run = "superset run -h 0.0.0.0 -p 8088 --with-threads"
|
||||
|
||||
# Superset ignores SQLALCHEMY_DATABASE_URI env var — we must create
|
||||
# superset_config.py and point SUPERSET_CONFIG_PATH to it.
|
||||
_bootstrap_config = (
|
||||
@@ -311,6 +447,7 @@ def superset_container(superset_db_url, superset_secret_key, superset_admin_pass
|
||||
init.with_env("SUPERSET_DB_URI", superset_db_url)
|
||||
init.with_env("SUPERSET_LOAD_EXAMPLES", "no")
|
||||
init.with_env("FLASK_APP", "superset")
|
||||
# Note: init container does NOT need TLS certs — only runs db upgrade + init
|
||||
init.with_command(
|
||||
f"sh -c '"
|
||||
f"python -m pip install psycopg2-binary -q && "
|
||||
@@ -344,28 +481,46 @@ def superset_container(superset_db_url, superset_secret_key, superset_admin_pass
|
||||
superset.with_env("SUPERSET_DB_URI", superset_db_url)
|
||||
superset.with_env("SUPERSET_LOAD_EXAMPLES", "no")
|
||||
superset.with_env("FLASK_APP", "superset")
|
||||
superset.with_command(
|
||||
f"sh -c '"
|
||||
f"python -m pip install psycopg2-binary -q && "
|
||||
f"{_bootstrap_config}"
|
||||
f"{_export_config} && "
|
||||
f"superset run -h 0.0.0.0 -p 8088 --with-threads'"
|
||||
)
|
||||
|
||||
# Build web server command: optionally write certs, then start superset
|
||||
if use_tls and _write_certs:
|
||||
web_cmd = (
|
||||
f"sh -c '"
|
||||
f"python -m pip install psycopg2-binary -q && "
|
||||
f"{_write_certs} && "
|
||||
f"{_bootstrap_config}"
|
||||
f"{_export_config} && "
|
||||
f"{_superset_run}'"
|
||||
)
|
||||
else:
|
||||
web_cmd = (
|
||||
f"sh -c '"
|
||||
f"python -m pip install psycopg2-binary -q && "
|
||||
f"{_bootstrap_config}"
|
||||
f"{_export_config} && "
|
||||
f"{_superset_run}'"
|
||||
)
|
||||
|
||||
superset.with_command(web_cmd)
|
||||
superset.with_exposed_ports(8088)
|
||||
|
||||
superset.start()
|
||||
|
||||
# Store protocol on the container object for superset_url fixture
|
||||
superset._ss_protocol = _protocol
|
||||
|
||||
# Wait for the /health endpoint to respond
|
||||
host = superset.get_container_host_ip()
|
||||
port = superset.get_exposed_port(8088)
|
||||
health_url = f"http://{host}:{port}/health"
|
||||
health_url = f"{_protocol}://{host}:{port}/health"
|
||||
|
||||
deadline = time.time() + 90
|
||||
last_error = None
|
||||
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
resp = requests.get(health_url, timeout=3)
|
||||
# verify=False in TLS mode because the custom CA hasn't been installed yet
|
||||
resp = requests.get(health_url, timeout=3, verify=False)
|
||||
if resp.status_code == 200:
|
||||
break
|
||||
except requests.RequestException as e:
|
||||
@@ -386,12 +541,13 @@ def superset_container(superset_db_url, superset_secret_key, superset_admin_pass
|
||||
|
||||
|
||||
# #region superset_url [C:1] [TYPE Fixture]
|
||||
# @BRIEF Function-scoped — base URL of the running Superset container.
|
||||
# @BRIEF Function-scoped — base URL of the running Superset container (auto-detects http/https).
|
||||
@pytest.fixture
|
||||
def superset_url(superset_container):
|
||||
host = superset_container.get_container_host_ip()
|
||||
port = superset_container.get_exposed_port(8088)
|
||||
return f"http://{host}:{port}"
|
||||
protocol = getattr(superset_container, '_ss_protocol', 'http')
|
||||
return f"{protocol}://{host}:{port}"
|
||||
# #endregion superset_url
|
||||
|
||||
|
||||
@@ -491,4 +647,89 @@ async def superset_client(superset_env):
|
||||
# #endregion superset_client
|
||||
|
||||
|
||||
# ── TLS / Custom CA Fixtures ─────────────────────────────────────
|
||||
|
||||
|
||||
# #region install_custom_ca [C:2] [TYPE Fixture]
|
||||
# @BRIEF Function-scoped — installs Test Root CA into system trust store.
|
||||
# Tries update-ca-certificates (root required), falls back to SSL_CERT_DIR.
|
||||
# @POST Root CA accessible via ssl.create_default_context().
|
||||
# @SIDE_EFFECT Modifies /usr/local/share/ca-certificates/custom/ and runs update-ca-certificates.
|
||||
# On fallback: sets SSL_CERT_DIR env var, creates temp dir with hash symlinks.
|
||||
@pytest.fixture
|
||||
def install_custom_ca(ca_chain):
|
||||
"""Install the custom Root CA into the system trust store.
|
||||
|
||||
Strategy 1 (prod-like): write .crt → update-ca-certificates (requires root).
|
||||
Strategy 2 (fallback): SSL_CERT_DIR with hash symlinks.
|
||||
"""
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ca_pem = ca_chain["root_crt"]
|
||||
used_update_ca = False
|
||||
ca_verify_path = None
|
||||
cert_dir = None
|
||||
|
||||
# Strategy 1: update-ca-certificates (matches production install_certificates())
|
||||
try:
|
||||
cert_dir = Path("/usr/local/share/ca-certificates/custom")
|
||||
cert_dir.mkdir(parents=True, exist_ok=True)
|
||||
ca_path = cert_dir / "test_ss_tools_ca.crt"
|
||||
ca_path.write_text(ca_pem)
|
||||
subprocess.run(
|
||||
["update-ca-certificates", "--fresh"],
|
||||
check=True, timeout=30, capture_output=True,
|
||||
)
|
||||
ca_verify_path = "/etc/ssl/certs"
|
||||
used_update_ca = True
|
||||
except (subprocess.CalledProcessError, PermissionError, OSError):
|
||||
# Strategy 2: fallback — SSL_CERT_DIR
|
||||
cert_dir = Path(tempfile.mkdtemp(prefix="test_ca_"))
|
||||
ca_path = cert_dir / "test_ss_tools_ca.pem"
|
||||
ca_path.write_text(ca_pem)
|
||||
|
||||
# Create hash symlink for capath
|
||||
result = subprocess.run(
|
||||
["openssl", "x509", "-hash", "-noout"],
|
||||
input=ca_pem, capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
cert_hash = result.stdout.strip()
|
||||
symlink_path = cert_dir / f"{cert_hash}.0"
|
||||
if symlink_path.exists():
|
||||
symlink_path.unlink()
|
||||
symlink_path.symlink_to(ca_path.name)
|
||||
|
||||
os.environ["SSL_CERT_DIR"] = str(cert_dir)
|
||||
ca_verify_path = str(cert_dir)
|
||||
used_update_ca = False
|
||||
|
||||
yield {
|
||||
"ca_verify_path": ca_verify_path,
|
||||
"used_update_ca": used_update_ca,
|
||||
"ca_pem": ca_pem,
|
||||
}
|
||||
|
||||
# Cleanup
|
||||
if used_update_ca:
|
||||
ca_file = Path("/usr/local/share/ca-certificates/custom/test_ss_tools_ca.crt")
|
||||
if ca_file.exists():
|
||||
ca_file.unlink()
|
||||
try:
|
||||
subprocess.run(
|
||||
["update-ca-certificates", "--fresh"],
|
||||
timeout=30, capture_output=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
if "SSL_CERT_DIR" in os.environ:
|
||||
del os.environ["SSL_CERT_DIR"]
|
||||
if cert_dir and cert_dir.exists():
|
||||
import shutil
|
||||
shutil.rmtree(str(cert_dir), ignore_errors=True)
|
||||
# #endregion install_custom_ca
|
||||
|
||||
|
||||
# #endregion IntegrationTestConftest
|
||||
|
||||
300
backend/tests/integration/test_superset_tls_custom_ca.py
Normal file
300
backend/tests/integration/test_superset_tls_custom_ca.py
Normal file
@@ -0,0 +1,300 @@
|
||||
# #region TestSupersetTlsCustomCA [C:3] [TYPE Module] [SEMANTICS test,superset,tls,ssl,integration,ca]
|
||||
# @BRIEF Integration tests validating the full corporate SSL certificate chain:
|
||||
# custom CA generation → system store installation → HTTPS via ss-tools HTTP clients.
|
||||
# @RELATION BINDS_TO -> [AsyncAPIClient]
|
||||
# @RELATION BINDS_TO -> [SupersetClientBase]
|
||||
# @RELATION BINDS_TO -> [SupersetClientRegistryModule]
|
||||
# @RELATION BINDS_TO -> [install_certificates (entrypoint)]
|
||||
# @RELATION BINDS_TO -> [ADR-0009]
|
||||
# @RATIONALE Validates that ssl.create_default_context() (used by our patched HTTP clients)
|
||||
# trusts custom CA certificates installed via update-ca-certificates or SSL_CERT_DIR.
|
||||
# Uses 3-tier PKI (Root → Intermediate → Server) to test the OpenSSL 3.x capath vs cafile
|
||||
# limitation documented in ADR-0009 Finding 7.
|
||||
# @TEST_CONTRACT InstallCustomCA ->
|
||||
# update-ca-certificates (or SSL_CERT_DIR fallback) makes custom Root CA discoverable
|
||||
# ssl.create_default_context(capath=...) builds chain successfully
|
||||
# ssl.create_default_context(cafile=...) fails with intermediate CA (OpenSSL 3.x bug)
|
||||
# AsyncAPIClient(verify_ssl=True) authenticates against TLS Superset
|
||||
# SupersetClient(verify_ssl=True) provides full API access over HTTPS
|
||||
# @REQUIRES Docker daemon running. --run-integration flag.
|
||||
|
||||
import pytest
|
||||
import re
|
||||
import ssl
|
||||
import subprocess
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# ── Test Class ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# #region TestCustomCATrustChain [C:3] [TYPE Class]
|
||||
# @BRIEF Validate that custom CA certificates installed into the system store
|
||||
# are trusted by openssl, httpx, and ss-tools patched HTTP clients.
|
||||
@pytest.mark.parametrize("superset_container", [{"tls": True}], indirect=True)
|
||||
class TestCustomCATrustChain:
|
||||
"""Full corporate CA trust chain validation against TLS-protected Superset."""
|
||||
|
||||
# ── openssl tests ─────────────────────────────────────────────────
|
||||
|
||||
# #region test_openssl_verify_capath_ok [C:2] [TYPE Function]
|
||||
# @BRIEF openssl s_client with -CApath succeeds (hash symlinks).
|
||||
# @POST Verify return code: 0 (ok)
|
||||
def test_openssl_verify_capath_ok(self, superset_url, install_custom_ca):
|
||||
"""openssl s_client -CApath must return code 0 (full chain)."""
|
||||
parsed = urlparse(superset_url)
|
||||
host = parsed.hostname
|
||||
port = parsed.port or 443
|
||||
ca_path = install_custom_ca["ca_verify_path"]
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"openssl", "s_client",
|
||||
"-connect", f"{host}:{port}",
|
||||
"-servername", host,
|
||||
"-CApath", ca_path,
|
||||
"-verify_return_error",
|
||||
],
|
||||
input="", capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
|
||||
# Parse Verify return code from output
|
||||
match = re.search(r"Verify return code:\s*(\d+)", result.stderr + result.stdout)
|
||||
assert match is not None, (
|
||||
f"No Verify return code in openssl output.\n"
|
||||
f"stdout: {result.stdout[:2000]}\nstderr: {result.stderr[:2000]}"
|
||||
)
|
||||
code = int(match.group(1))
|
||||
assert code == 0, (
|
||||
f"Expected verify code 0, got {code}.\n"
|
||||
f"Full output:\n{result.stderr[:2000]}"
|
||||
)
|
||||
# #endregion test_openssl_verify_capath_ok
|
||||
|
||||
|
||||
# #region test_openssl_verify_cafile_certifi_fails [C:2] [TYPE Function]
|
||||
# @BRIEF openssl s_client -CAfile with certifi's bundle fails.
|
||||
# This proves that certifi's CA bundle does NOT trust our custom Root CA.
|
||||
# Before our patches, httpx used certifi by default (verify=True → certifi).
|
||||
# After our patches, httpx uses ssl.create_default_context() (system CA store).
|
||||
def test_openssl_verify_cafile_certifi_fails(self, superset_url):
|
||||
"""openssl s_client -CAfile $(certifi) must return non-zero verify code.
|
||||
This proves that our custom CA is NOT trusted by certifi, which is what
|
||||
httpx used before our patches. After patches, we use system CA store."""
|
||||
import certifi
|
||||
parsed = urlparse(superset_url)
|
||||
host = parsed.hostname
|
||||
port = parsed.port or 443
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"openssl", "s_client",
|
||||
"-connect", f"{host}:{port}",
|
||||
"-servername", host,
|
||||
"-CAfile", certifi.where(),
|
||||
"-verify_return_error",
|
||||
],
|
||||
input="", capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
|
||||
match = re.search(r"Verify return code:\s*(\d+)", result.stderr + result.stdout)
|
||||
assert match is not None, (
|
||||
f"No Verify return code in openssl output.\n"
|
||||
f"stdout: {result.stdout[:2000]}\nstderr: {result.stderr[:2000]}"
|
||||
)
|
||||
code = int(match.group(1))
|
||||
assert code != 0, (
|
||||
"Expected non-zero verify code with certifi CA bundle, got 0.\n"
|
||||
f"This means certifi somehow trusts our generated CA, which should not happen.\n"
|
||||
f"Full output:\n{result.stderr[:2000]}"
|
||||
)
|
||||
# #endregion test_openssl_verify_cafile_certifi_fails
|
||||
|
||||
|
||||
# ── httpx SSL context tests ──────────────────────────────────────
|
||||
|
||||
# #region test_httpx_capath_works [C:2] [TYPE Function]
|
||||
# @BRIEF httpx with ssl.create_default_context(capath=...) succeeds.
|
||||
def test_httpx_capath_works(self, superset_url, install_custom_ca):
|
||||
"""ssl.create_default_context(capath=...) must successfully connect.
|
||||
This is the production code path used by all patched HTTP clients."""
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
ca_path = install_custom_ca["ca_verify_path"]
|
||||
ctx = ssl.create_default_context(capath=ca_path)
|
||||
|
||||
async def _test():
|
||||
async with httpx.AsyncClient(verify=ctx, timeout=10) as client:
|
||||
resp = await client.get(f"{superset_url}/health")
|
||||
assert resp.status_code == 200
|
||||
assert resp.text.strip() == "OK"
|
||||
|
||||
asyncio.run(_test())
|
||||
# #endregion test_httpx_capath_works
|
||||
|
||||
|
||||
# #region test_httpx_certifi_fails [C:2] [TYPE Function]
|
||||
# @BRIEF httpx with certifi's CA bundle must fail.
|
||||
# This proves the old behavior (verify=True → certifi) would fail with
|
||||
# corporate CA certificates. After our patches, we use system CA store
|
||||
# via ssl.create_default_context(capath=...).
|
||||
def test_httpx_certifi_fails(self, superset_url):
|
||||
"""ssl.create_default_context(cafile=certifi.where()) must raise SSLError.
|
||||
Proves that certifi does NOT trust our custom Root CA, which is why
|
||||
our patches replacing verify=True with ssl.create_default_context()
|
||||
are necessary."""
|
||||
import asyncio
|
||||
|
||||
import certifi
|
||||
import httpx
|
||||
|
||||
ctx = ssl.create_default_context(cafile=certifi.where())
|
||||
|
||||
async def _test():
|
||||
async with httpx.AsyncClient(verify=ctx, timeout=10) as client:
|
||||
with pytest.raises((ssl.SSLCertVerificationError, httpx.RemoteProtocolError)):
|
||||
await client.get(f"{superset_url}/health")
|
||||
|
||||
asyncio.run(_test())
|
||||
# #endregion test_httpx_certifi_fails
|
||||
|
||||
|
||||
# ── ss-tools client tests ────────────────────────────────────────
|
||||
|
||||
# #region test_asyncapiclient_verify_true [C:2] [TYPE Function]
|
||||
# @BRIEF AsyncAPIClient(verify_ssl=True) authenticates against TLS Superset.
|
||||
# Validates our patched async_network.py code path.
|
||||
def test_asyncapiclient_verify_true(self, superset_url, superset_admin_password, install_custom_ca):
|
||||
"""AsyncAPIClient with verify_ssl=True must authenticate over HTTPS.
|
||||
This validates the core SSL fix in async_network.py."""
|
||||
# install_custom_ca is used for its side effect — installing CA into system store
|
||||
assert install_custom_ca is not None
|
||||
import asyncio
|
||||
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
parsed = urlparse(superset_url)
|
||||
base_url = f"{parsed.scheme}://{parsed.hostname}:{parsed.port}"
|
||||
|
||||
client = AsyncAPIClient(
|
||||
config={
|
||||
"base_url": base_url,
|
||||
"auth": {
|
||||
"username": "admin",
|
||||
"password": superset_admin_password,
|
||||
"provider": "db",
|
||||
"refresh": "true",
|
||||
},
|
||||
},
|
||||
verify_ssl=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
async def _test():
|
||||
try:
|
||||
tokens = await client.authenticate()
|
||||
assert "access_token" in tokens, "No access_token in auth response"
|
||||
assert "csrf_token" in tokens, "No csrf_token in auth response"
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
asyncio.run(_test())
|
||||
# #endregion test_asyncapiclient_verify_true
|
||||
|
||||
|
||||
# #region test_superset_client_full_auth [C:2] [TYPE Function]
|
||||
# @BRIEF Full SupersetClient auth + API call over TLS with verify_ssl=True.
|
||||
def test_superset_client_full_auth(self, superset_url, superset_admin_password, install_custom_ca):
|
||||
"""SupersetClient with verify_ssl=True must authenticate and
|
||||
execute API queries over HTTPS."""
|
||||
# install_custom_ca is used for its side effect — installing CA into system store
|
||||
assert install_custom_ca is not None
|
||||
import asyncio
|
||||
|
||||
from src.core.config_models import Environment
|
||||
from src.core.superset_client import SupersetClient
|
||||
|
||||
env = Environment(
|
||||
id="test_tls",
|
||||
name="Test TLS Superset",
|
||||
url=superset_url,
|
||||
username="admin",
|
||||
password=superset_admin_password,
|
||||
verify_ssl=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
async def _test():
|
||||
client = SupersetClient(env)
|
||||
try:
|
||||
tokens = await client.authenticate()
|
||||
assert "access_token" in tokens, "No access_token"
|
||||
assert "csrf_token" in tokens, "No csrf_token"
|
||||
|
||||
# Fetch dashboards list via the authenticated client
|
||||
result = await client.get_dashboards(
|
||||
query={"page": 0, "page_size": 10}
|
||||
)
|
||||
|
||||
assert isinstance(result, dict), f"Expected dict, got {type(result)}"
|
||||
assert "result" in result, f"No 'result' key: {list(result.keys())}"
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
asyncio.run(_test())
|
||||
# #endregion test_superset_client_full_auth
|
||||
|
||||
|
||||
# ── Regression tests ─────────────────────────────────────────────
|
||||
|
||||
# #region test_certifi_bundle_notrust [C:1] [TYPE Function]
|
||||
# @BRIEF Prove that certifi's CA bundle does NOT contain our custom Root CA.
|
||||
# This validates WHY the patches are needed: before our fix, httpx used
|
||||
# certifi by default, which would NOT trust corporate CA certificates.
|
||||
def test_certifi_bundle_notrust(self, ca_chain):
|
||||
"""certifi.where() bundle must NOT contain our custom Root CA.
|
||||
This proves that default httpx.AsyncClient(verify=True) would reject
|
||||
corporate certificates installed via update-ca-certificates.
|
||||
Uses SHA256 fingerprint dedup, same as the entrypoint fallback logic."""
|
||||
import certifi
|
||||
|
||||
# Load our Root CA and compute its SHA256 fingerprint
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
|
||||
our_ca = x509.load_pem_x509_certificate(ca_chain["root_crt"].encode())
|
||||
our_fingerprint = our_ca.fingerprint(hashes.SHA256()).hex()
|
||||
|
||||
# Load certifi's bundle and check if any cert matches our fingerprint
|
||||
with open(certifi.where(), "rb") as f:
|
||||
bundle_data = f.read()
|
||||
|
||||
assert our_fingerprint not in bundle_data.hex(), (
|
||||
"Our custom Root CA was found in certifi bundle — "
|
||||
"this should not happen since the CA was just generated at test time."
|
||||
)
|
||||
# #endregion test_certifi_bundle_notrust
|
||||
|
||||
|
||||
# #region test_verify_false_still_works [C:1] [TYPE Function]
|
||||
# @BRIEF verify_ssl=False must still work (regression test).
|
||||
# The SSL patches must not break the disabled-verification path.
|
||||
def test_verify_false_still_works(self, superset_url):
|
||||
"""AsyncAPIClient(verify_ssl=False) must work over HTTPS
|
||||
(regression — the LLM_SSL_VERIFY=false escape hatch must not break)."""
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
async def _test():
|
||||
async with httpx.AsyncClient(verify=False, timeout=10) as client:
|
||||
resp = await client.get(f"{superset_url}/health")
|
||||
assert resp.status_code == 200
|
||||
assert resp.text.strip() == "OK"
|
||||
|
||||
asyncio.run(_test())
|
||||
# #endregion test_verify_false_still_works
|
||||
|
||||
# #endregion TestCustomCATrustChain
|
||||
# #endregion TestSupersetTlsCustomCA
|
||||
Reference in New Issue
Block a user