Skip to content
Draft
Show file tree
Hide file tree
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
2 changes: 2 additions & 0 deletions ddcdatabases/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
from .core.certs import SSLCertificateError
from .core.operations import DBUtils, DBUtilsAsync
from .core.persistent import PersistentConnectionConfig, close_all_persistent_connections
from importlib.metadata import version
Expand All @@ -7,6 +8,7 @@
"DBUtils",
"DBUtilsAsync",
"PersistentConnectionConfig",
"SSLCertificateError",
"close_all_persistent_connections",
]

Expand Down
6 changes: 6 additions & 0 deletions ddcdatabases/core/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations
import logging
import sqlalchemy as sa
from .certs import verify_cert_paths
from .configs import BaseOperationRetryConfig, BaseRetryConfig
from .retry import retry_operation, retry_operation_async
from collections.abc import AsyncGenerator, Generator
Expand All @@ -19,6 +20,7 @@ class BaseConnection:
__slots__ = (
"connection_url",
"engine_args",
"driver_cert_paths",
"autoflush",
"expire_on_commit",
"sync_driver",
Expand All @@ -42,9 +44,11 @@ def __init__(
connection_retry_config: BaseRetryConfig | None = None,
operation_retry_config: BaseOperationRetryConfig | None = None,
logger: Any = None,
driver_cert_paths: tuple[tuple[str | None, str], ...] = (),
) -> None:
self.connection_url = connection_url
self.engine_args = engine_args
self.driver_cert_paths = driver_cert_paths
self.autoflush = autoflush
self.expire_on_commit = expire_on_commit
self.sync_driver = sync_driver
Expand Down Expand Up @@ -111,13 +115,15 @@ async def __aexit__(

@contextmanager
def _get_engine(self) -> Generator[Engine, None, None]:
verify_cert_paths(self.driver_cert_paths)
_connection_url = URL.create(drivername=self.sync_driver, **self.connection_url)
_engine = create_engine(url=_connection_url, **self.engine_args)
yield _engine
_engine.dispose()

@asynccontextmanager
async def _get_async_engine(self) -> AsyncGenerator[AsyncEngine, None]:
verify_cert_paths(self.driver_cert_paths)
_connection_url = URL.create(drivername=self.async_driver, **self.connection_url)
_engine = create_async_engine(url=_connection_url, **self.engine_args)
yield _engine
Expand Down
56 changes: 56 additions & 0 deletions ddcdatabases/core/certs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""
Construction of the client SSL contexts used by the async drivers.
"""

import os
import ssl
from collections.abc import Iterable


class SSLCertificateError(OSError):
"""A configured certificate path could not be loaded"""


def build_client_ssl_context(
ca_cert_path: str,
client_cert_path: str | None = None,
client_key_path: str | None = None,
minimum_version: ssl.TLSVersion = ssl.TLSVersion.TLSv1_3,
) -> ssl.SSLContext:
"""Build a client SSL context, naming the file when one cannot be loaded"""

context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.minimum_version = minimum_version

try:
context.load_verify_locations(cafile=ca_cert_path)
except OSError as err:
raise SSLCertificateError(f"CA certificate unusable: {ca_cert_path} | {err}") from err

if client_cert_path and client_key_path:
try:
context.load_cert_chain(certfile=client_cert_path, keyfile=client_key_path)
except OSError as err:
raise SSLCertificateError(
f"client certificate/key unusable: {client_cert_path}, {client_key_path} | {err}"
) from err

return context


def verify_cert_paths(entries: Iterable[tuple[str | None, str]]) -> None:
"""Raise SSLCertificateError naming every configured path that is not usable"""

problems: list[str] = []
for path, label in entries:
if not path:
continue
if not os.path.exists(path):
problems.append(f"{label} missing: {path}")
continue
# a directory (an Oracle wallet) must also be traversable, not merely readable
required = os.R_OK | os.X_OK if os.path.isdir(path) else os.R_OK
if not os.access(path, required):
problems.append(f"{label} not readable: {path}")
if problems:
raise SSLCertificateError("; ".join(problems))
31 changes: 21 additions & 10 deletions ddcdatabases/core/persistent.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@
from __future__ import annotations
import asyncio
import logging
import ssl as _ssl_module
import threading
import time
import weakref
from .certs import build_client_ssl_context, verify_cert_paths
from .configs import BaseOperationRetryConfig, BaseRetryConfig, merge_config_with_settings
from .retry import retry_operation, retry_operation_async
from .settings import (
Expand Down Expand Up @@ -805,15 +805,11 @@
async_connect_args = {}
if ssl_mode and ssl_mode != "disable":
if ssl_ca_cert_path:
ssl_context = _ssl_module.SSLContext(_ssl_module.PROTOCOL_TLS_CLIENT)
ssl_context.minimum_version = _ssl_module.TLSVersion.TLSv1_3
ssl_context.load_verify_locations(cafile=ssl_ca_cert_path)
if ssl_client_cert_path and ssl_client_key_path:
ssl_context.load_cert_chain(
certfile=ssl_client_cert_path,
keyfile=ssl_client_key_path,
)
async_connect_args["ssl"] = ssl_context
async_connect_args["ssl"] = build_client_ssl_context(
ca_cert_path=ssl_ca_cert_path,
client_cert_path=ssl_client_cert_path,
client_key_path=ssl_client_key_path,
)
else:
async_connect_args["ssl"] = ssl_mode

Expand Down Expand Up @@ -847,6 +843,13 @@
# Build psycopg SSL connect_args
sync_connect_args = {}
if ssl_mode and ssl_mode != "disable":
verify_cert_paths(
(
(ssl_ca_cert_path, "CA certificate"),

Check failure on line 848 in ddcdatabases/core/persistent.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "CA certificate" 3 times.

See more on https://sonarcloud.io/project/issues?id=ddc_ddcDatabases&issues=AaCMd2G9aiCZtpFAhdqI&open=AaCMd2G9aiCZtpFAhdqI&pullRequest=44
(ssl_client_cert_path, "client certificate"),
(ssl_client_key_path, "client key"),
)
)
sync_connect_args["sslmode"] = ssl_mode
if ssl_ca_cert_path:
sync_connect_args["sslrootcert"] = ssl_ca_cert_path
Expand Down Expand Up @@ -970,6 +973,13 @@
# Build MySQL SSL connect_args (same format for both pymysql and aiomysql)
ssl_connect_args = {}
if ssl_mode and ssl_mode != "DISABLED":
verify_cert_paths(
(
(ssl_ca_cert_path, "CA certificate"),
(ssl_client_cert_path, "client certificate"),
(ssl_client_key_path, "client key"),
)
)
ssl_dict: dict[str, str] = {}
if ssl_ca_cert_path:
ssl_dict["ca"] = ssl_ca_cert_path
Expand Down Expand Up @@ -1108,6 +1118,7 @@
_query["Encrypt"] = "yes" if _settings.ssl_encrypt else "no"
_query["TrustServerCertificate"] = "yes" if _settings.ssl_trust_server_certificate else "no"
if _settings.ssl_ca_cert_path:
verify_cert_paths(((_settings.ssl_ca_cert_path, "CA certificate"),))
_query["ServerCertificate"] = _settings.ssl_ca_cert_path

with _registry_lock:
Expand Down
7 changes: 7 additions & 0 deletions ddcdatabases/mongodb.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
import sys
from .core.certs import verify_cert_paths
from .core.configs import (
CONNECTION_RETRY_FIELD_MAP,
OPERATION_RETRY_FIELD_MAP,
Expand Down Expand Up @@ -167,6 +168,12 @@ def _build_connection_url(self) -> str:
f"@{self._connection_config.host}/{self._connection_config.database}"
)
if self._tls_config.tls_enabled:
verify_cert_paths(
(
(self._tls_config.tls_ca_cert_path, "TLS CA certificate"),
(self._tls_config.tls_cert_key_path, "TLS certificate/key"),
)
)
url += "?tls=true"
if self._tls_config.tls_ca_cert_path:
url += f"&tlsCAFile={self._tls_config.tls_ca_cert_path}"
Expand Down
3 changes: 3 additions & 0 deletions ddcdatabases/mssql.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,9 @@ def __init__(
"Encrypt": "yes" if self._ssl_config.ssl_encrypt else "no",
"TrustServerCertificate": "yes" if self._ssl_config.ssl_trust_server_certificate else "no",
}
_driver_cert_paths: tuple[tuple[str | None, str], ...] = ()
if self._ssl_config.ssl_ca_cert_path:
_driver_cert_paths = ((self._ssl_config.ssl_ca_cert_path, "CA certificate"),)
_query["ServerCertificate"] = self._ssl_config.ssl_ca_cert_path

self.connection_url = {
Expand Down Expand Up @@ -148,6 +150,7 @@ def __init__(
super().__init__(
connection_url=self.connection_url,
engine_args=self.engine_args,
driver_cert_paths=_driver_cert_paths,
autoflush=self._session_config.autoflush,
expire_on_commit=self._session_config.expire_on_commit,
sync_driver=self.sync_driver,
Expand Down
7 changes: 7 additions & 0 deletions ddcdatabases/mysql.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,13 @@ def __init__(
"autocommit": self._session_config.autocommit,
"connect_timeout": self._pool_config.connection_timeout,
}
_driver_cert_paths: tuple[tuple[str | None, str], ...] = ()
if self._ssl_config.ssl_mode and self._ssl_config.ssl_mode != "DISABLED":
_driver_cert_paths = (
(self._ssl_config.ssl_ca_cert_path, "CA certificate"),
(self._ssl_config.ssl_client_cert_path, "client certificate"),
(self._ssl_config.ssl_client_key_path, "client key"),
)
ssl_dict = {}
if self._ssl_config.ssl_ca_cert_path:
ssl_dict["ca"] = self._ssl_config.ssl_ca_cert_path
Expand Down Expand Up @@ -151,6 +157,7 @@ def __init__(
super().__init__(
connection_url=self.connection_url,
engine_args=self.engine_args,
driver_cert_paths=_driver_cert_paths,
autoflush=self._session_config.autoflush,
expire_on_commit=self._session_config.expire_on_commit,
sync_driver=self.sync_driver,
Expand Down
3 changes: 3 additions & 0 deletions ddcdatabases/oracle.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ def __init__(

self.extra_engine_args = extra_engine_args or {}
_connect_args = {}
_driver_cert_paths: tuple[tuple[str | None, str], ...] = ()
if self._ssl_config.ssl_wallet_path:
_driver_cert_paths = ((self._ssl_config.ssl_wallet_path, "wallet directory"),)
_connect_args["wallet_location"] = self._ssl_config.ssl_wallet_path
self.engine_args = {
"echo": self._session_config.echo,
Expand All @@ -129,6 +131,7 @@ def __init__(
super().__init__(
connection_url=self.connection_url,
engine_args=self.engine_args,
driver_cert_paths=_driver_cert_paths,
autoflush=self._session_config.autoflush,
expire_on_commit=self._session_config.expire_on_commit,
sync_driver=self.sync_driver,
Expand Down
27 changes: 17 additions & 10 deletions ddcdatabases/postgresql.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import logging
import ssl as _ssl_module
from .core.base import BaseConnection
from .core.certs import build_client_ssl_context, verify_cert_paths
from .core.configs import (
CONNECTION_RETRY_FIELD_MAP,
OPERATION_RETRY_FIELD_MAP,
Expand Down Expand Up @@ -109,6 +109,15 @@ def __init__(
self.sync_driver = _settings.sync_driver
self.async_driver = _settings.async_driver

# psycopg opens these itself; asyncpg's are attributed by build_client_ssl_context
_driver_cert_paths: tuple[tuple[str | None, str], ...] = ()
if self._ssl_config.ssl_mode and self._ssl_config.ssl_mode != "disable":
_driver_cert_paths = (
(self._ssl_config.ssl_ca_cert_path, "CA certificate"),
(self._ssl_config.ssl_client_cert_path, "client certificate"),
(self._ssl_config.ssl_client_key_path, "client key"),
)

self.connection_url = {
"host": self._connection_config.host,
"port": self._connection_config.port,
Expand Down Expand Up @@ -137,6 +146,7 @@ def __init__(
super().__init__(
connection_url=self.connection_url,
engine_args=self.engine_args,
driver_cert_paths=_driver_cert_paths,
autoflush=self._session_config.autoflush,
expire_on_commit=self._session_config.expire_on_commit,
sync_driver=self.sync_driver,
Expand Down Expand Up @@ -201,6 +211,7 @@ def _get_base_engine_args(self, connection_url: URL, driver_connect_args: dict,

@contextmanager
def _get_engine(self) -> Generator[Engine, None, None]:
verify_cert_paths(self.driver_cert_paths)
_connection_url = URL.create(
drivername=self.sync_driver,
**self.connection_url,
Expand Down Expand Up @@ -247,15 +258,11 @@ async def _get_async_engine(self) -> AsyncGenerator[AsyncEngine, None]:
async_connect_args["server_settings"] = {"search_path": self._connection_config.schema}
if self._ssl_config.ssl_mode and self._ssl_config.ssl_mode != "disable":
if self._ssl_config.ssl_ca_cert_path:
ssl_context = _ssl_module.SSLContext(_ssl_module.PROTOCOL_TLS_CLIENT)
ssl_context.minimum_version = _ssl_module.TLSVersion.TLSv1_3
ssl_context.load_verify_locations(cafile=self._ssl_config.ssl_ca_cert_path)
if self._ssl_config.ssl_client_cert_path and self._ssl_config.ssl_client_key_path:
ssl_context.load_cert_chain(
certfile=self._ssl_config.ssl_client_cert_path,
keyfile=self._ssl_config.ssl_client_key_path,
)
async_connect_args["ssl"] = ssl_context
async_connect_args["ssl"] = build_client_ssl_context(
ca_cert_path=self._ssl_config.ssl_ca_cert_path,
client_cert_path=self._ssl_config.ssl_client_cert_path,
client_key_path=self._ssl_config.ssl_client_key_path,
)
else:
async_connect_args["ssl"] = self._ssl_config.ssl_mode

Expand Down
10 changes: 5 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ packages = ["ddcdatabases"]

[project]
name = "ddcdatabases"
version = "5.0.0"
version = "5.0.1"
description = "Simplified database ORM connections with support for multiple database engines"
urls.Repository = "https://github.com/ddc/ddcDatabases"
urls.Homepage = "https://pypi.org/project/ddcDatabases"
Expand Down Expand Up @@ -61,22 +61,22 @@ mongodb = ["motor>=3.7.1"]
oracle = ["oracledb>=4.0.2"]
mssql = ["pyodbc>=5.3.0", "aioodbc>=0.5.0"]
mysql = ["mysqlclient>=2.2.8", "aiomysql>=0.3.2"]
postgres = ["psycopg[binary]>=3.3.4", "asyncpg>=0.31.0"]
postgres = ["psycopg[binary]>=3.3.5", "asyncpg>=0.31.0"]
pgsql = ["ddcdatabases[postgres]"]
mariadb = ["ddcdatabases[mysql]"]

[dependency-groups]
dev = [
"coverage>=7.15.4",
"coverage>=7.16.0",
"poethepoet>=0.48.0",
"pytest-asyncio>=1.4.0",
"ruff>=0.16.3",
"ruff>=0.16.6",
"testcontainers[postgres,mysql,mssql,mongodb,oracle]>=4.15.0",
]

[tool.poe.tasks]
linter.shell = "uv run ruff check --fix . && uv run ruff format ."
snyk-export.shell = "uv export --no-hashes --no-annotate --format requirements-txt > requirements.txt && uvx pre-commit run --all-files || uvx pre-commit run --all-files"
snyk-export.shell = "uv export --no-hashes --no-annotate --all-extras --all-groups --format requirements-txt > requirements.txt && uvx pre-commit run --all-files || uvx pre-commit run --all-files"
snyk.sequence = ["snyk-export", { shell = "uv pip install pip && snyk test --file=requirements.txt && snyk code test; uv pip uninstall pip" }]
profile = "uv run python -m cProfile -o cprofile_unit.prof -m pytest tests/unit"
profile-integration = "uv run python -m cProfile -o cprofile_integration.prof -m pytest tests/integration"
Expand Down
Loading
Loading