Skip to content

Commit 8b93c81

Browse files
fix(postgres): default SSL mode to 'prefer' for broader compatibility
PostgresBackend previously defaulted to ssl=True, which forced SSL negotiation. Servers without TLS certificates (Docker, local dev, private cloud networks) rejected the connection outright. Now defaults to ssl="prefer": tries SSL first, falls back to plaintext on rejection. Matches the standard libpq default. Accepts string modes ("prefer", "require", "disable", "verify-full") with True/False backward compat. DSN-level sslmode= always takes precedence.
1 parent 170678f commit 8b93c81

File tree

5 files changed

+126
-26
lines changed

5 files changed

+126
-26
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.5.2] - 2026-04-09
11+
12+
### Fixed
13+
- **PostgresBackend SSL: sslmode=prefer by default**. Previously defaulted to `ssl=True` which forced SSL negotiation. Servers without TLS certificates (Docker, local dev, private cloud networks) rejected the connection. Now defaults to `ssl="prefer"`: try SSL first, fall back to plaintext on rejection. Matches the standard `libpq` default behavior.
14+
15+
### Changed
16+
- `PostgresBackend.__init__()` `ssl` parameter: `bool` (default `True`) to `str` (default `"prefer"`). Accepts `"prefer"`, `"require"`, `"disable"`, `"verify-full"`, `"verify-ca"`. `True`/`False` still accepted for backward compat.
17+
- DSN-level `sslmode=` always takes precedence over the constructor `ssl` argument.
18+
- 5 new tests for SSL mode normalization and backward compatibility.
19+
1020
## [1.5.1] - 2026-04-08
1121

1222
### Fixed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "qp-vault"
7-
version = "1.5.1"
7+
version = "1.5.2"
88
description = "Governed knowledge store for autonomous organizations. Trust tiers, cryptographic audit trails, content-addressed storage, air-gap native."
99
readme = "README.md"
1010
license = "Apache-2.0"

src/qp_vault/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
Docs: https://github.com/quantumpipes/vault
2727
"""
2828

29-
__version__ = "1.5.1"
29+
__version__ = "1.5.2"
3030
__author__ = "Quantum Pipes Technologies, LLC"
3131
__license__ = "Apache-2.0"
3232

src/qp_vault/storage/postgres.py

Lines changed: 70 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -194,9 +194,20 @@ def __init__(
194194
*,
195195
embedding_dimensions: int = 768,
196196
command_timeout: float = 30.0,
197-
ssl: bool = True,
197+
ssl: str = "prefer",
198198
ssl_verify: bool = False,
199199
) -> None:
200+
"""Initialize PostgresBackend.
201+
202+
Args:
203+
dsn: PostgreSQL connection string (``postgresql://user:pass@host/db``).
204+
embedding_dimensions: Vector dimensions for pgvector columns.
205+
command_timeout: Statement timeout in seconds.
206+
ssl: SSL mode. Supports ``"prefer"`` (try SSL, fall back to plaintext),
207+
``"require"`` (SSL required), ``"disable"`` (no SSL), or ``True``/``False``
208+
for backward compat. ``sslmode=`` in the DSN always takes precedence.
209+
ssl_verify: When True, verify the server certificate against the system CA store.
210+
"""
200211
if not HAS_ASYNCPG:
201212
raise ImportError(
202213
"asyncpg is required for PostgresBackend. "
@@ -205,37 +216,72 @@ def __init__(
205216
self._dsn = dsn
206217
self._dimensions = embedding_dimensions
207218
self._command_timeout = command_timeout
208-
self._ssl = ssl
219+
# Normalize ssl param: True -> "prefer", False -> "disable"
220+
if ssl is True:
221+
self._ssl_mode = "prefer"
222+
elif ssl is False:
223+
self._ssl_mode = "disable"
224+
else:
225+
self._ssl_mode = ssl
209226
self._ssl_verify = ssl_verify
210227
self._pool: Any = None
211228

212229
async def _get_pool(self) -> Any:
213-
"""Get or create connection pool."""
214-
if self._pool is None:
215-
import ssl as _ssl
230+
"""Get or create connection pool.
231+
232+
SSL behavior follows the ``sslmode`` parameter from the DSN if present,
233+
otherwise falls back to the ``ssl`` constructor argument (default: ``prefer``).
216234
217-
ssl_context: Any = None
235+
``prefer`` mode: attempt SSL first; on failure, retry without SSL.
236+
This matches the default behavior of libpq and psycopg.
237+
"""
238+
if self._pool is None:
239+
# DSN-level sslmode takes precedence over constructor arg
218240
if "sslmode=disable" in self._dsn:
219-
ssl_context = False
220-
elif self._ssl:
221-
ssl_context = _ssl.create_default_context()
222-
if not self._ssl_verify:
223-
ssl_context.check_hostname = False
224-
ssl_context.verify_mode = _ssl.CERT_NONE
225-
226-
pool_kwargs: dict[str, Any] = {
227-
"min_size": 2,
228-
"max_size": 10,
229-
"command_timeout": self._command_timeout,
230-
}
231-
if ssl_context is not None and ssl_context is not False:
232-
pool_kwargs["ssl"] = ssl_context
233-
elif ssl_context is False:
234-
pass # Explicitly disabled via DSN
235-
236-
self._pool = await asyncpg.create_pool(self._dsn, **pool_kwargs)
241+
effective_mode = "disable"
242+
elif "sslmode=require" in self._dsn or "sslmode=verify" in self._dsn:
243+
effective_mode = "require"
244+
elif "sslmode=prefer" in self._dsn:
245+
effective_mode = "prefer"
246+
else:
247+
effective_mode = self._ssl_mode
248+
249+
self._pool = await self._create_pool(effective_mode)
237250
return self._pool
238251

252+
async def _create_pool(self, ssl_mode: str) -> Any:
253+
"""Create the connection pool with the given SSL mode."""
254+
import ssl as _ssl
255+
256+
pool_kwargs: dict[str, Any] = {
257+
"min_size": 2,
258+
"max_size": 10,
259+
"command_timeout": self._command_timeout,
260+
}
261+
262+
if ssl_mode == "disable":
263+
# No SSL
264+
pass
265+
elif ssl_mode in ("require", "verify-full", "verify-ca"):
266+
ssl_context = _ssl.create_default_context()
267+
if not self._ssl_verify:
268+
ssl_context.check_hostname = False
269+
ssl_context.verify_mode = _ssl.CERT_NONE
270+
pool_kwargs["ssl"] = ssl_context
271+
elif ssl_mode == "prefer":
272+
# Try SSL first, fall back to plaintext on rejection
273+
ssl_context = _ssl.create_default_context()
274+
ssl_context.check_hostname = False
275+
ssl_context.verify_mode = _ssl.CERT_NONE
276+
pool_kwargs["ssl"] = ssl_context
277+
try:
278+
return await asyncpg.create_pool(self._dsn, **pool_kwargs)
279+
except Exception:
280+
# SSL rejected; retry without
281+
pool_kwargs.pop("ssl", None)
282+
283+
return await asyncpg.create_pool(self._dsn, **pool_kwargs)
284+
239285
async def initialize(self) -> None:
240286
"""Create schema, tables, and indexes."""
241287
pool = await self._get_pool()

tests/test_v1_features.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,50 @@ def test_ssl_configurable(self) -> None:
398398
assert config.postgres_ssl_verify is True
399399

400400

401+
class TestPostgresBackendSSL:
402+
"""Test PostgresBackend SSL mode handling (sslmode=prefer by default)."""
403+
404+
def test_default_ssl_mode_is_prefer(self) -> None:
405+
"""PostgresBackend defaults to ssl='prefer' (try SSL, fall back)."""
406+
from qp_vault.storage.postgres import HAS_ASYNCPG, PostgresBackend
407+
if not HAS_ASYNCPG:
408+
pytest.skip("asyncpg not installed")
409+
backend = PostgresBackend("postgresql://localhost/test")
410+
assert backend._ssl_mode == "prefer"
411+
412+
def test_ssl_true_normalizes_to_prefer(self) -> None:
413+
"""ssl=True normalizes to 'prefer' for backward compat."""
414+
from qp_vault.storage.postgres import HAS_ASYNCPG, PostgresBackend
415+
if not HAS_ASYNCPG:
416+
pytest.skip("asyncpg not installed")
417+
backend = PostgresBackend("postgresql://localhost/test", ssl=True)
418+
assert backend._ssl_mode == "prefer"
419+
420+
def test_ssl_false_normalizes_to_disable(self) -> None:
421+
"""ssl=False normalizes to 'disable'."""
422+
from qp_vault.storage.postgres import HAS_ASYNCPG, PostgresBackend
423+
if not HAS_ASYNCPG:
424+
pytest.skip("asyncpg not installed")
425+
backend = PostgresBackend("postgresql://localhost/test", ssl=False)
426+
assert backend._ssl_mode == "disable"
427+
428+
def test_ssl_require_string(self) -> None:
429+
"""ssl='require' is accepted as-is."""
430+
from qp_vault.storage.postgres import HAS_ASYNCPG, PostgresBackend
431+
if not HAS_ASYNCPG:
432+
pytest.skip("asyncpg not installed")
433+
backend = PostgresBackend("postgresql://localhost/test", ssl="require")
434+
assert backend._ssl_mode == "require"
435+
436+
def test_ssl_disable_string(self) -> None:
437+
"""ssl='disable' is accepted as-is."""
438+
from qp_vault.storage.postgres import HAS_ASYNCPG, PostgresBackend
439+
if not HAS_ASYNCPG:
440+
pytest.skip("asyncpg not installed")
441+
backend = PostgresBackend("postgresql://localhost/test", ssl="disable")
442+
assert backend._ssl_mode == "disable"
443+
444+
401445
# =============================================================================
402446
# FIPS KAT
403447
# =============================================================================

0 commit comments

Comments
 (0)