Skip to content

fix(postgres): forward SSL/TLS config to psycopg - #60

Merged
vistart merged 19 commits into
release/v1.0.0.dev17from
fix/type-values-namespace
Sep 29, 2026
Merged

vistart merged 19 commits into
release/v1.0.0.dev17from
fix/type-values-namespace

Conversation

@vistart

@vistart vistart commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Description

Fixes # (issue)

PostgresConnectionConfig declares the libpq-named PostgresSSLMixin
(sslmode/sslcert/sslkey/sslrootcert/sslcrl/sslcompression) and does not
inherit the generic SSLMixin, so it has no ssl_ca/ssl_cert/ssl_key/ssl_mode
attributes at all.

Both connect() implementations nevertheless guarded on those four names with hasattr():

if hasattr(self.config, "ssl_ca"):
    ssl_params["sslcert"] = self.config.ssl_ca
if hasattr(self.config, "ssl_cert"):
    ssl_params["sslcert"] = self.config.ssl_cert
if hasattr(self.config, "ssl_key"):
    ssl_params["sslkey"] = self.config.ssl_key
if hasattr(self.config, "ssl_mode"):
    ssl_params["sslmode"] = self.config.ssl_mode

Every check was therefore False and no TLS setting ever reached psycopg.connect().

The mapping was also wrong on its own terms: ssl_ca was assigned to sslcert rather
than sslrootcert, and sslcert was assigned twice.

Security impact

Certificate verification was silently disabled. With no sslmode passed at all,
libpq falls back to its own default of prefer: TLS is still negotiated, but the
server certificate is not verified.

So a connection configured with sslmode="verify-full" or sslmode="verify-ca"
behaved exactly like sslmode="prefer" — encryption without authentication, leaving
it open to an active man-in-the-middle. The traffic was not plaintext on a
TLS-capable server, but it was not verified either, and nothing in the application
indicated the difference. Certificates supplied via sslrootcert/sslcert/sslkey
were ignored outright, so a broken or wrong CA path never surfaced as an error.

Measured on a PostgreSQL 19beta4 server with SSL enabled, before the fix:

sslmode not passed at all   (True, 'TLSv1.3', 'TLS_AES_256_GCM_SHA384')   # prefer
sslmode='require'           (True, 'TLSv1.3', 'TLS_AES_256_GCM_SHA384')
sslrootcert=/nonexistent    connects without error                      # never read

Anyone who relied on sslmode to protect credentials or data in transit should
treat the affected versions as not providing certificate verification and
re-verify their deployments after upgrading.

Reproduction Steps

Configure a connection that requires verified TLS, then inspect the session:

from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig
from rhosocial.activerecord.backend.impl.postgres.backend import PostgresBackend

config = PostgresConnectionConfig(
    host="...", port=5432, database="test_db", username="...", password="...",
    sslmode="verify-full", sslrootcert="/nonexistent/ca.crt",
)
print(config.to_connection_string())
# postgres://...?sslmode=verify-full&sslrootcert=/nonexistent/ca.crt   <- looks right

backend = PostgresBackend(connection_config=config)
backend.connect()   # succeeds, and should not have
with backend._connection.cursor() as cur:
    cur.execute("select ssl, version from pg_stat_ssl where pid = pg_backend_pid()")
    print(cur.fetchone())

Expected: connection refused — /nonexistent/ca.crt cannot be loaded, so
verify-full must fail.
Actual: the session connects with TLS, ignoring both verify-full and the
unreadable CA path. Passing the same config straight to psycopg.connect() does fail
with root certificate file "/nonexistent/ca.crt" does not exist, which confirms the
connection-string fields themselves are fine and only the driver call was wrong.

The CLI's --ssl verify-full maps to config.sslmode and is lost at the same point.

Type of change

  • fix: A bug fix
  • refactor: A code change that neither fixes a bug nor adds a feature (collecting the libpq keywords in one place, shared by the sync and async connect paths)
  • test: Adding tests for the fix

Breaking Change

  • Yes
  • No

Applicable rhosocial-activerecord Version Range:
>=1.0.0,<2.0.0

Related Repositories/Packages

This is one of five coordinated fixes. The same class of defect — SSL parameters
collected on the config but never handed to the driver — was found and fixed
alongside in python-activerecord-mariadb, python-activerecord-mysql,
python-activerecord-oracle and python-activerecord-sqlserver. No shared
python-activerecord change is required; each backend translates the generic
SSLMixin names to its own driver's keywords.

Testing

  • I have added new tests to cover my changes.
  • I have updated existing tests to reflect my changes.
  • All existing tests pass.
  • This change has been tested on PostgreSQL 19beta4, Python 3.14.

Test Plan:

New tests/rhosocial/activerecord_postgres_test/feature/backend/test_ssl_connection_params.py
asserts the libpq keywords reach psycopg.connect() on both the sync and the async
path, that the generic SSL names are absent from the config, and that no stray ssl*
key is emitted when nothing is configured. The sync test fails against the pre-fix code.

Verified against a live PostgreSQL 19beta4 server, after the fix:

config pg_stat_ssl
sslmode=disable (False, None, None)
sslmode=require (True, 'TLSv1.3', 'TLS_AES_256_GCM_SHA384')
verify-full + missing CA rejected at connect time
verify-full + bogus client cert rejected at connect time

sslmode=disable and sslmode=require now produce distinguishable sessions, which is
the observable proof that the setting reaches the driver at all.

Full tests/rhosocial/activerecord_postgres_test/feature/backend/ suite passes
(excluding the pre-existing test_old_types_package_is_gone failure, which fails
identically on the unmodified branch). Lint and mypy show no new findings.

Checklist

  • My code follows the project's code style guidelines.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have made corresponding changes to the documentation (Docstrings, README.md, etc.).
  • My changes generate no new warnings.
  • I have added a Changelog fragment (changelog.d/<issue_number>.<type>.md). — will be added as changelog.d/<pr_number>.fixed.md once CI passes.
  • I have verified that my changes do not introduce SQL injection vulnerabilities.
  • I have checked for potential performance regressions.

Additional Notes

This branch also carries accumulated work that predates this fix: the
impl.postgres.backend package reorganisation, the type_values / expression
namespace split, lazy backend exports, and the README/.claude documentation
rewrite (16 commits ahead of release/v1.0.0.dev17 in total).

Worth flagging separately: backend/sync.py is a byte-identical duplicate of
backend/backend.py (only the header comment differs) and nothing imports it.
Both are patched here so the dead copy cannot be resurrected with the bug intact,
but collapsing the duplication would be a worthwhile follow-up.

…paces

Every BaseExpression subclass must live under the backend's expression
namespace, and the name `types` should mean exactly one thing per layer.
Two problems are fixed here.

First, PostgresEnumType is an expression (it renders a type reference for
casts and column definitions, and its own docstring says it is not for DDL),
but it was defined in types/enum.py alongside a manager. Move it to
expression/enum_.py, matching the enum_.py naming already used by the
core expression types package. EnumTypeManager is a DDL facade, so it
moves to a top-level enum_type_manager.py, alongside transaction.py and
its TransactionManager.

Second, types/ held 44 domain value types (PostgresVector, PostgresMoney,
TsQueryNode, ...) that have nothing to do with expressions. Renaming the
package to type_values/ frees `types` to mean DataType expressions, so the
boundary becomes checkable. The full impl.postgres.<Name> API is
unchanged; only the impl.postgres.types.* path moves, with no
compatibility shim.

Imports are rewritten by resolving each relative import against the
filesystem, so the 17 sibling `from .types import` sites in expression/,
protocols/, mixins/ and explain/ - which resolve to their own DataType,
Protocol, mixin and DTO modules - are deliberately left untouched.

Adds a guardrail test that imports every backend module and asserts that
no BaseExpression subclass is defined outside the expression namespace.
The guardrail collected every transitive BaseExpression subclass in the
process, which includes the core expressions in
rhosocial.activerecord.backend.expression. Those are legitimate, so the
assertion flagged all of them and failed.

Constrain the check to classes actually defined inside this backend's
package: a class under impl.postgres must be under impl.postgres.expression.
Classes from the core and from other backends are out of scope.
…without a driver

Every backend __init__ imported all of its submodules eagerly, and both
.backend and .adapters import psycopg. Python executes a parent
package's __init__ before any submodule, so
`impl.postgres.expression` could not be imported without psycopg
present - even though no expression class touches it, and
type_values/range.py documents that PostgresRange works without psycopg
installed. The eager adapters import defeated that intent.

Public names are now resolved on first access through a PEP 562 module
__getattr__, with the name -> (module, attribute) map derived from the
existing import statements.

Also repairs two names that __all__ has promised since v1.0.0.dev12 but
that no import provides: gen_random_uuid and uuid_default_generator, whose
imports were dropped while the __all__ entries were left behind. Both
exist in .functions, so `from impl.postgres import *` raised
AttributeError on the release branch and now succeeds.

The exported surface is otherwise unchanged: attribute access, __all__,
dir() and `from impl.postgres import *` all behave as before, and the
previous eager imports move into an `if TYPE_CHECKING:` block so mypy and
IDEs still resolve them.

296 public names verified to resolve.
Drop the top-level impl/<backend>/__init__.py so the directory becomes a
PEP 420 namespace portion and no longer re-exports every public name. Each
name is now imported from the sub-package that defines it, so a symbol has
one public path instead of two.

The facade also made "expression is importable on its own" unenforceable:
importing impl.<backend>.expression executed the parent __init__, which
pulled in the driver. With the file gone, the sub-packages stand alone by
construction rather than by a lazy __getattr__ that each backend had to
maintain separately.

This is a breaking change for anyone importing
`from rhosocial.activerecord.backend.impl.<backend> import X`; the
replacement is `...impl.<backend>.<module> import X`.
Move the sync and async classes into a backend/ package with the fixed layout:
backend/backend.py and backend/async_backend.py. Eight backends kept them as
two modules beside backend/, and sqlite and postgres used backend/sync.py, so
the class locations differed three ways between backends.

With the layout fixed the worker tasks derive the async class from the sync
module's own package instead of searching for it, so the
_find_async_backend helper is gone.
The move into backend/ shifted every relative import by one level, and the
function-local ones were missed, so impl.bigquery could not reach its adapters
module. Resolve each target against the filesystem instead of counting dots.
impl.<backend>.backend already re-exported the sync class, so leaving the async
one out made the two classes reachable by different rules. Export both.

The eight backends whose async driver is a hard requirement import it
eagerly. SQLite and SQL Server keep a PEP 562 __getattr__, because aiosqlite
and aioodbc ship as the [async] extra and importing either eagerly would make
the async class a requirement for sync-only users.
The test checked that impl.postgres re-exported PostgresStorageParameter, which
is a property of the flat facade that has been removed. Assert it on
impl.postgres.storage_parameters instead.
The worker tasks resolve the backend class by importing backend_module and
reading the attribute off it. Pointing that at impl.postgres, now a bare
namespace, made every worker task fail with "module has no attribute
'PostgresBackend'". The package that re-exports the class is impl.postgres.backend.
The CLI resolves AsyncPostgresBackend inside backend_async_factory by importing
impl.postgres.backend.async_backend, so patching the bare impl.postgres
namespace left the real class in place. Point the patch at the defining module.
…d/base

Constructing either backend raised ModuleNotFoundError: No module named
'rhosocial.activerecord.backend.impl.adapters'. The adapter registration in
backend_base.py reached for ..adapters, but backend_base.py sits directly in
impl/postgres, so the second dot climbed to impl and adapters resolved one level
too high. The identical text in backend/base.py is correct only because that
file sits one directory deeper.

backend_base.py was a byte-for-byte copy of backend/base.py, kept alive only by
two stale modules: backend/sync.py, itself a copy of backend/backend.py
differing in nothing but that same import, and the pre-restructure top-level
async_backend.py. Deleting backend_base.py without those would trade one
breakage for another, so all three import sites now name backend/base.py.

Both classes now construct and build their dialect, verified against
PostgresConnectionConfig. Also corrects backend/base.py's file header, which
still named the deleted path.
The remaining pre-restructure copy of async_backend.py, left over from moving
both classes into the backend package. It differed from backend/async_backend.py
only in its import depths, and those depths were wrong: ..expression and
..introspection climbed to impl.expression and impl.introspection, one level
short of backend.expression and impl.postgres.introspection. They sit inside
function bodies, so nothing failed until the code path ran.

The canonical backend/async_backend.py resolves both correctly from its deeper
position. Deleting the stale copy rather than correcting it also keeps a single
definition per class; the two introspection docs now name the canonical path.

Verified both classes still construct, and that resolving every relative import
across all ten backend repositories and the core now finds no unresolvable
module, down from 19.
The convention is that the first line names the module's own path, so a stale
name sends the next reader to a file that is not there. These were left behind
by earlier renames, when table.py became ddl_table.py and show_types.py became
show/types.py.

Comment only; no behaviour changes.
The README's 15-row version table described PostgreSQL's own feature history
rather than what this backend gates on, and several thresholds were off by a
minor release (RETURNING 8.2 vs 8.4, JSONB 9.4 vs 9.4, JSON_TABLE 12 vs 13,
MERGE 15 vs 16). Replaced with a table generated from the dialect's supports_*
switches. The core dependency was documented as >=1.0.0, which cannot resolve
against the 1.0.0.devN pre-release line; pyproject had the correct value.

In the agent docs: the architecture file imported from a nonexistent
impl.postgresql package and named the class PostgreSQLBackend (it is
PostgresBackend), listed a CTEQueryMixin that does not exist in the ActiveQuery
composition, showed __init__.py for packages that are PEP 420 namespace
packages, inverted the IActiveRecord relationship to BaseActiveRecord, and
mislabelled ValidationError's base. Also corrected the core dependency and
extras listings, and the PYTHONPATH guidance, which omitted tests -- without
it the shared testsuite cannot resolve its provider registry.
psycopg 3.3.6 declares requires-python >=3.10 while 3.2.13 supports >=3.8, so a
single floor of >=3.2.13 leaves 3.10 and above on the older line. The backend is
tested against 3.8 through 3.15, and the driver gate belongs with the other
version-dependent behaviour rather than being left to resolve to whatever is
newest.

Both pyproject.toml and requirements.txt carry the same two markers, since CI
installs the backend with ".[test]" and the requirements file is used for
developer setup.

Verified the markers evaluate as intended: 3.8 and 3.9 select >=3.2.13, 3.10
through 3.15 select >=3.3.6, and both bounds match the published metadata.
PostgresConnectionConfig declares the libpq-named PostgresSSLMixin
(sslmode/sslcert/sslkey/sslrootcert/sslcrl/sslcompression) and does not
inherit the generic SSLMixin, so it has no ssl_ca/ssl_cert/ssl_key/ssl_mode
attributes at all. Both connect() implementations nevertheless guarded on
those four names with hasattr(), so every check was False and no TLS setting
ever reached psycopg.connect(). A connection configured with
sslmode="verify-full" ran in plaintext, and an unreadable sslrootcert was
silently ignored instead of failing the handshake.

Collect the configured libpq keywords once in
PostgresConnectionConfig.get_ssl_connection_params() and call it from the
sync and async connect paths, so the two cannot drift apart again. The
kwargs extraction lists in both backends now accept the libpq names.

Verified against a PostgreSQL 19beta4 server: pg_stat_ssl.ssl is now False
for sslmode=disable and True for sslmode=require, and verify-full with a
missing root CA is rejected at connect time.
@codecov

codecov Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.68627% with 124 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.06%. Comparing base (6d86c50) to head (3dda125).
⚠️ Report is 1 commits behind head on release/v1.0.0.dev17.

Files with missing lines Patch % Lines
...iverecord/backend/impl/postgres/backend/backend.py 72.33% 76 Missing and 33 partials ⚠️
...verecord/backend/impl/postgres/expression/enum_.py 79.59% 8 Missing and 2 partials ⚠️
...activerecord/backend/impl/postgres/backend/sync.py 0.00% 2 Missing ⚠️
...tiverecord/backend/impl/postgres/cli/connection.py 0.00% 1 Missing ⚠️
...ial/activerecord/backend/impl/postgres/cli/info.py 0.00% 1 Missing ⚠️
...tiverecord/backend/impl/postgres/functions/json.py 85.71% 1 Missing ⚠️
Additional details and impacted files
@@                   Coverage Diff                    @@
##           release/v1.0.0.dev17      #60      +/-   ##
========================================================
- Coverage                 76.92%   75.06%   -1.86%     
========================================================
  Files                       330      338       +8     
  Lines                     15360    15807     +447     
  Branches                   2606     2680      +74     
========================================================
+ Hits                      11816    11866      +50     
- Misses                     2580     2986     +406     
+ Partials                    964      955       -9     
Flag Coverage Δ
postgres-18 75.06% <75.68%> (-1.86%) ⬇️
python-3.14 75.06% <75.68%> (-1.86%) ⬇️
unittests 75.06% <75.68%> (-1.86%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@vistart
vistart changed the base branch from main to release/v1.0.0.dev17 September 29, 2026 08:09
@sonarqubecloud

Copy link
Copy Markdown

Replace the six divergent changelog-check variants with one implementation
that encodes the documented merge rules.

feature/*|fix/* -> release/*|maint/*|main:
  - the PR must add at least one changelog.d/<number>.<type>.md
  - every added fragment's <number> must equal the PR number
  - fragments already present from previously merged branches are ignored,
    since only files ADDED by this PR are considered
  - escape hatches ([trivial] title, skip-changelog label, docs: update
    CHANGELOG) waive only the "at least one" requirement; the
    number-must-match rule is always enforced on fragments that were added

release/* -> main:
  - CHANGELOG.md must have changed (content is not inspected)
  - changelog.d/ must hold no fragments at the merge commit
  - no escape hatches apply to this path

The number-must-match rule was the one documented requirement that none of
the previous variants implemented. Two latent bugs are also fixed: the
paths-ignore of "**.md" meant a CHANGELOG.md-only push never ran the check at
all, and three backends skipped release merges outright instead of applying
the release rules.
A feature/fix branch merges into a release or maintenance branch; only
release/* merges into main. Opening a second PR at main from the same
branch is a duplicate, and it also breaks the changelog fragment, which is
numbered for exactly one PR.

This pattern already cost a wasted PR in six repositories (a ->release PR
merged alongside a ->main PR that was then closed), and the fragment
numbering check only exposed it indirectly, by failing the leftover PR.

hotfix/* -> main stays allowed; the version control policy calls for
hotfixes to be merged back to main and the active release branch.
@sonarqubecloud

Copy link
Copy Markdown

@vistart vistart self-assigned this Sep 29, 2026
@vistart vistart added the bug Something isn't working label Sep 29, 2026
@vistart
vistart merged commit 9a74cca into release/v1.0.0.dev17 Sep 29, 2026
28 of 30 checks passed
@vistart
vistart deleted the fix/type-values-namespace branch October 1, 2026 03:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant