Skip to content

Commit ef3a3bf

Browse files
committed
feat(sqlserver): support client certificates on the ODBC connection string
encrypt and trust_server_certificate only cover server-side encryption, so a client certificate could not be presented at all. Add client_certificate, client_key, client_key_password and host_name_in_certificate to SQLServerConnectionConfig and emit them as the ClientCertificate/ClientKey/ClientKeyPassword/HostNameInCertificate ODBC keywords from build_connection_string(), with the same four names accepted as backend kwargs. validate_config() now rejects a client_key given without a client_certificate, so the mismatch surfaces as a clear ValueError instead of an opaque driver failure at connect time.
1 parent c54e813 commit ef3a3bf

5 files changed

Lines changed: 98 additions & 3 deletions

File tree

‎changelog.d/5.fixed.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add client-certificate (mutual TLS) support to the ODBC connection string. `SQLServerConnectionConfig` could only express server-side encryption via `encrypt` and `trust_server_certificate`; there was no way to present a client certificate. The new `client_certificate`, `client_key`, `client_key_password` and `host_name_in_certificate` fields are emitted as the `ClientCertificate`, `ClientKey`, `ClientKeyPassword` and `HostNameInCertificate` ODBC keywords by `build_connection_string()`, and are accepted as backend kwargs. `validate_config()` now rejects a `client_key` supplied without a `client_certificate`, so the mismatch surfaces as a clear error instead of an opaque driver failure.

‎src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,10 @@ def __init__(self, **kwargs):
100100
sqlserver_params = [
101101
'host', 'port', 'database', 'deployment_target', 'username', 'password',
102102
'trusted_connection', 'driver', 'encrypt',
103-
'trust_server_certificate', 'timeout', 'query_timeout',
103+
'trust_server_certificate',
104+
'client_certificate', 'client_key', 'client_key_password',
105+
'host_name_in_certificate',
106+
'timeout', 'query_timeout',
104107
'autocommit', 'charset', 'pool_size', 'pool_timeout',
105108
]
106109

‎src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,10 @@ def __init__(self, **kwargs):
122122
sqlserver_params = [
123123
'host', 'port', 'database', 'deployment_target', 'username', 'password',
124124
'trusted_connection', 'driver', 'encrypt',
125-
'trust_server_certificate', 'timeout', 'query_timeout',
125+
'trust_server_certificate',
126+
'client_certificate', 'client_key', 'client_key_password',
127+
'host_name_in_certificate',
128+
'timeout', 'query_timeout',
126129
'autocommit', 'charset', 'pool_size', 'pool_timeout',
127130
]
128131

‎src/rhosocial/activerecord/backend/impl/sqlserver/config.py‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,15 @@ class SQLServerConnectionConfig(ConnectionConfig):
7272
encrypt_connection: Optional[bool] = None
7373
trust_server_certificate: bool = False
7474

75+
# Client certificate (mutual TLS). The ODBC driver reads these straight
76+
# from the filesystem, so they are PEM/PFX paths rather than material.
77+
client_certificate: Optional[str] = None
78+
client_key: Optional[str] = None
79+
client_key_password: Optional[str] = None
80+
# Overrides the name matched against the server certificate. Required when
81+
# the certificate CN is not the host name being connected to.
82+
host_name_in_certificate: Optional[str] = None
83+
7584
timeout: int = 30
7685
query_timeout: int = 0
7786

@@ -136,7 +145,16 @@ def build_connection_string(self) -> str:
136145

137146
if self.charset and self.charset.upper() != "UTF-8":
138147
parts.append(f"CharacterSet={self.charset}")
139-
148+
149+
if self.client_certificate:
150+
parts.append(f"ClientCertificate={self.client_certificate}")
151+
if self.client_key:
152+
parts.append(f"ClientKey={self.client_key}")
153+
if self.client_key_password:
154+
parts.append(f"ClientKeyPassword={self.client_key_password}")
155+
if self.host_name_in_certificate:
156+
parts.append(f"HostNameInCertificate={self.host_name_in_certificate}")
157+
140158
for key, value in self.options.items():
141159
if value is not None:
142160
parts.append(f"{key}={value}")
@@ -164,6 +182,11 @@ def validate_config(self) -> None:
164182
UserWarning,
165183
stacklevel=2
166184
)
185+
186+
if self.client_key and not self.client_certificate:
187+
raise ValueError(
188+
"client_key requires client_certificate for SQL Server connections"
189+
)
167190

168191
def __repr__(self) -> str:
169192
"""Return safe string representation (password masked)."""
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_ssl_connection_params.py
2+
"""Regression tests: SQL Server TLS settings must reach the ODBC connection string.
3+
4+
``build_connection_string()`` is the only place ``pyodbc`` gets its
5+
configuration, so anything the config exposes for TLS has to appear there.
6+
"""
7+
8+
import pytest
9+
10+
from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig
11+
12+
13+
def make_config(**kwargs):
14+
defaults = dict(host="db.example.com", database="test_db", username="sa", password="secret")
15+
defaults.update(kwargs)
16+
return SQLServerConnectionConfig(**defaults)
17+
18+
19+
def test_encryption_is_on_by_default():
20+
assert "Encrypt=yes" in make_config().build_connection_string()
21+
assert "TrustServerCertificate=no" in make_config().build_connection_string()
22+
23+
24+
def test_encrypt_can_be_disabled():
25+
conn_str = make_config(encrypt=False).build_connection_string()
26+
assert "Encrypt=no" in conn_str
27+
28+
29+
def test_trust_server_certificate_is_forwarded():
30+
conn_str = make_config(trust_server_certificate=True).build_connection_string()
31+
assert "TrustServerCertificate=yes" in conn_str
32+
33+
34+
def test_client_certificate_is_forwarded():
35+
conn_str = make_config(
36+
client_certificate="/certs/client.pem",
37+
client_key="/certs/client.key",
38+
).build_connection_string()
39+
assert "ClientCertificate=/certs/client.pem" in conn_str
40+
assert "ClientKey=/certs/client.key" in conn_str
41+
42+
43+
def test_host_name_in_certificate_is_forwarded():
44+
"""Needed when the server certificate CN is not the connect host."""
45+
conn_str = make_config(host_name_in_certificate="db.internal").build_connection_string()
46+
assert "HostNameInCertificate=db.internal" in conn_str
47+
48+
49+
def test_client_key_password_is_forwarded():
50+
conn_str = make_config(
51+
client_certificate="/certs/client.pfx",
52+
client_key_password="pfx-secret",
53+
).build_connection_string()
54+
assert "ClientKeyPassword=pfx-secret" in conn_str
55+
56+
57+
def test_no_client_tls_keys_when_unset():
58+
conn_str = make_config().build_connection_string()
59+
for key in ("ClientCertificate", "ClientKey", "ClientKeyPassword", "HostNameInCertificate"):
60+
assert key not in conn_str
61+
62+
63+
def test_client_key_without_certificate_is_rejected():
64+
with pytest.raises(ValueError, match="client_key requires client_certificate"):
65+
make_config(client_key="/certs/client.key").validate_config()

0 commit comments

Comments
 (0)