Skip to content

feat: environment variables for TLS #7296

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Apr 28, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
272 changes: 269 additions & 3 deletions src/phoenix/config.py
Original file line number Diff line number Diff line change
@@ -2,7 +2,7 @@
import os
import re
import tempfile
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import timedelta
from enum import Enum
from importlib.metadata import version
@@ -240,6 +240,243 @@
ENV_PHOENIX_GQL_EXTENSION_PATHS = "PHOENIX_GQL_EXTENSION_PATHS"
ENV_PHOENIX_GRPC_INTERCEPTOR_PATHS = "PHOENIX_GRPC_INTERCEPTOR_PATHS"

ENV_PHOENIX_TLS_ENABLED = "PHOENIX_TLS_ENABLED"
"""
Whether to enable TLS for Phoenix HTTP and gRPC servers.
"""
ENV_PHOENIX_TLS_CERT_FILE = "PHOENIX_TLS_CERT_FILE"
"""
Path to the TLS certificate file for HTTPS connections.
When set, Phoenix will use HTTPS instead of HTTP for all connections.
"""
ENV_PHOENIX_TLS_KEY_FILE = "PHOENIX_TLS_KEY_FILE"
"""
Path to the TLS private key file for HTTPS connections.
Required when PHOENIX_TLS_CERT_FILE is set.
"""
ENV_PHOENIX_TLS_KEY_FILE_PASSWORD = "PHOENIX_TLS_KEY_FILE_PASSWORD"
"""
Password for the TLS private key file if it's encrypted.
Only needed if the private key file requires a password.
"""
ENV_PHOENIX_TLS_CA_FILE = "PHOENIX_TLS_CA_FILE"
"""
Path to the Certificate Authority (CA) file for client certificate verification.
Used when PHOENIX_TLS_VERIFY_CLIENT is set to true.
"""
ENV_PHOENIX_TLS_VERIFY_CLIENT = "PHOENIX_TLS_VERIFY_CLIENT"
"""
Whether to verify client certificates for mutual TLS (mTLS) authentication.
When set to true, clients must provide valid certificates signed by the CA specified in
PHOENIX_TLS_CA_FILE.
"""


@dataclass(frozen=True)
class TLSConfig:
"""Configuration for TLS (Transport Layer Security) connections.

This class manages TLS certificates and private keys for secure connections.
It handles reading certificate and key files, and decrypting private keys
if they are password-protected.

Attributes:
cert_file: Path to the TLS certificate file
key_file: Path to the TLS private key file
key_file_password: Optional password for decrypting the private key
_cert_data: Cached certificate data (internal use)
_key_data: Cached decrypted key data (internal use)
_decrypted_key_data: Cached decrypted key data (internal use)
"""

cert_file: Path
key_file: Path
key_file_password: Optional[str]
_cert_data: bytes = field(default=b"", init=False, repr=False)
_key_data: bytes = field(default=b"", init=False, repr=False)
_decrypted_key_data: Optional[bytes] = field(default=None, init=False, repr=False)

@property
def cert_data(self) -> bytes:
"""Get the certificate data, reading from file if not cached.

Returns:
bytes: The certificate data in PEM format
"""
if not self._cert_data:
with open(self.cert_file, "rb") as f:
object.__setattr__(self, "_cert_data", f.read())
return self._cert_data

@property
def key_data(self) -> bytes:
"""Get the decrypted key data, reading from file if not cached.

This property reads the private key file and decrypts it if a password
is provided. The decrypted key is cached for subsequent accesses.

Returns:
bytes: The decrypted private key data in PEM format

Raises:
ValueError: If the cryptography library is not installed or if
decryption fails
"""
if not self._key_data:
self._read_and_cache_key_data()
return self._key_data

def _read_and_cache_key_data(self) -> None:
"""Read and decrypt the private key file, then cache the result.

This method reads the private key file, decrypts it if a password
is provided, and stores the decrypted key in the _key_data attribute.

Raises:
ValueError: If the cryptography library is not installed or if
decryption fails
"""
try:
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.serialization import (
Encoding,
NoEncryption,
PrivateFormat,
load_pem_private_key,
)
except ImportError:
raise ValueError(
"The cryptography library is needed to read private keys for "
"TLS configuration. Please install it with: pip install cryptography"
)

# First read the key file
with open(self.key_file, "rb") as f:
key_data = f.read()

try:
# Convert password to bytes if it exists
password_bytes = self.key_file_password.encode() if self.key_file_password else None

# Load the key (decrypting if password is provided)
private_key = load_pem_private_key(
key_data,
password=password_bytes,
backend=default_backend(),
)

# Convert to PEM format without encryption
decrypted_pem = private_key.private_bytes(
encoding=Encoding.PEM,
format=PrivateFormat.PKCS8,
encryption_algorithm=NoEncryption(),
)
except Exception as e:
raise ValueError(f"Failed to decrypt private key: {e}")
object.__setattr__(self, "_key_data", decrypted_pem)


@dataclass(frozen=True)
class TLSConfigVerifyClient(TLSConfig):
"""TLS configuration with client verification enabled."""

ca_file: Path
_ca_data: bytes = field(default=b"", init=False, repr=False)

@property
def ca_data(self) -> bytes:
"""Get the CA certificate data, reading from file if not cached."""
if not self._ca_data:
with open(self.ca_file, "rb") as f:
object.__setattr__(self, "_ca_data", f.read())
return self._ca_data


def get_env_tls_enabled() -> bool:
"""
Gets the value of the PHOENIX_TLS_ENABLED environment variable.

Returns:
bool: True if TLS is enabled, False otherwise. Defaults to False if the environment
variable is not set.
""" # noqa: E501
return _bool_val(ENV_PHOENIX_TLS_ENABLED, False)


def get_env_tls_verify_client() -> bool:
"""
Gets the value of the PHOENIX_TLS_VERIFY_CLIENT environment variable.

Returns:
bool: True if client certificate verification is enabled, False otherwise. Defaults to False
if the environment variable is not set.
""" # noqa: E501
return _bool_val(ENV_PHOENIX_TLS_VERIFY_CLIENT, False)


def get_env_tls_config() -> Optional[TLSConfig]:
"""
Retrieves and validates TLS configuration from environment variables.

Returns:
Optional[TLSConfig]: A configuration object containing TLS settings, or None if TLS is disabled.
If client verification is enabled, returns TLSConfigVerifyClient instead.

The function reads the following environment variables:
- PHOENIX_TLS_ENABLED: Whether TLS is enabled (defaults to False)
- PHOENIX_TLS_CERT_FILE: Path to the TLS certificate file
- PHOENIX_TLS_KEY_FILE: Path to the TLS private key file
- PHOENIX_TLS_KEY_FILE_PASSWORD: Password for the TLS private key file
- PHOENIX_TLS_CA_FILE: Path to the Certificate Authority file (required for client verification)
- PHOENIX_TLS_VERIFY_CLIENT: Whether to verify client certificates

Raises:
ValueError: If required files are missing or don't exist when TLS is enabled
""" # noqa: E501
# Check if TLS is enabled
if not get_env_tls_enabled():
return None

# Get certificate file path if specified
if not (cert_file_str := getenv(ENV_PHOENIX_TLS_CERT_FILE)):
raise ValueError("PHOENIX_TLS_CERT_FILE must be set when PHOENIX_TLS_ENABLED is true")
cert_file = Path(cert_file_str)

# Get private key file path if specified
if not (key_file_str := getenv(ENV_PHOENIX_TLS_KEY_FILE)):
raise ValueError("PHOENIX_TLS_KEY_FILE must be set when PHOENIX_TLS_ENABLED is true")
key_file = Path(key_file_str)

# Get private key password if specified
key_file_password = getenv(ENV_PHOENIX_TLS_KEY_FILE_PASSWORD)

# Validate certificate and key files
_validate_file_exists_and_is_readable(cert_file, "certificate")
_validate_file_exists_and_is_readable(key_file, "key")

# If client verification is enabled, validate CA file and return TLSConfigVerifyClient
if get_env_tls_verify_client():
if not (ca_file_str := getenv(ENV_PHOENIX_TLS_CA_FILE)):
raise ValueError(
"PHOENIX_TLS_CA_FILE must be set when PHOENIX_TLS_VERIFY_CLIENT is true"
)

ca_file = Path(ca_file_str)
_validate_file_exists_and_is_readable(ca_file, "CA")

return TLSConfigVerifyClient(
cert_file=cert_file,
key_file=key_file,
key_file_password=key_file_password,
ca_file=ca_file,
)

return TLSConfig(
cert_file=cert_file,
key_file=key_file,
key_file_password=key_file_password,
)


def server_instrumentation_is_enabled() -> bool:
return bool(
@@ -956,15 +1193,17 @@ def get_env_root_url() -> URL:
host = get_env_host()
if host == "0.0.0.0":
host = "127.0.0.1"
return URL(urljoin(f"http://{host}:{get_env_port()}", get_env_host_root_path()))
scheme = "https" if get_env_tls_enabled() else "http"
return URL(urljoin(f"{scheme}://{host}:{get_env_port()}", get_env_host_root_path()))


def get_base_url() -> str:
"""Deprecated: Use get_env_root_url() instead, but note the difference in behavior."""
host = get_env_host()
if host == "0.0.0.0":
host = "127.0.0.1"
base_url = get_env_collector_endpoint() or f"http://{host}:{get_env_port()}"
scheme = "https" if get_env_tls_enabled() else "http"
base_url = get_env_collector_endpoint() or f"{scheme}://{host}:{get_env_port()}"
return base_url if base_url.endswith("/") else base_url + "/"


@@ -1159,3 +1398,30 @@ def verify_server_environment_variables() -> None:
request is authenticated as the system user with the user_id set to this
SYSTEM_USER_ID value (only if this variable is not None).
"""


def _validate_file_exists_and_is_readable(
file_path: Path,
description: str,
check_non_empty: bool = True,
) -> None:
"""
Validate that a file exists, is readable, and optionally has non-zero size.

Args:
file_path: Path to the file to validate
description: Description of the file for error messages (e.g., "certificate", "key", "CA")
check_non_empty: Whether to check if the file has non-zero size. Defaults to True.

Raises:
ValueError: If the path is not a file, isn't readable, or has zero size (if check_non_empty is True)
""" # noqa: E501
if not file_path.is_file():
raise ValueError(f"{description} path is not a file: {file_path}")
if check_non_empty and file_path.stat().st_size == 0:
raise ValueError(f"{description} file is empty: {file_path}")
try:
with open(file_path, "rb") as f:
f.read(1) # Read just one byte to verify readability
except Exception as e:
raise ValueError(f"{description} file is not readable: {e}")
21 changes: 19 additions & 2 deletions src/phoenix/server/grpc_server.py
Original file line number Diff line number Diff line change
@@ -14,7 +14,11 @@
from typing_extensions import TypeAlias

from phoenix.auth import CanReadToken
from phoenix.config import get_env_grpc_port
from phoenix.config import (
TLSConfigVerifyClient,
get_env_grpc_port,
get_env_tls_config,
)
from phoenix.server.bearer_auth import ApiKeyInterceptor
from phoenix.trace.otel import decode_otlp_span
from phoenix.trace.schemas import Span
@@ -86,7 +90,20 @@ async def __aenter__(self) -> None:
options=(("grpc.so_reuseport", 0),),
interceptors=interceptors,
)
server.add_insecure_port(f"[::]:{get_env_grpc_port()}")
if tls_config := get_env_tls_config():
private_key_certificate_chain_pairs = [(tls_config.key_data, tls_config.cert_data)]
server_credentials = (
grpc.ssl_server_credentials(
private_key_certificate_chain_pairs,
root_certificates=tls_config.ca_data,
require_client_auth=True,
)
if isinstance(tls_config, TLSConfigVerifyClient)
else grpc.ssl_server_credentials(private_key_certificate_chain_pairs)
)
server.add_secure_port(f"[::]:{get_env_grpc_port()}", server_credentials)
else:
server.add_insecure_port(f"[::]:{get_env_grpc_port()}")
add_TraceServiceServicer_to_server(Servicer(self._callback), server) # type: ignore[no-untyped-call,unused-ignore]
await server.start()
self._server = server
Loading
Oops, something went wrong.
Loading
Oops, something went wrong.