Skip to content

Commit 58d290f

Browse files
PYCO-93: Address mTLS PR review feedback
Changes ------- * Moves Credential._check_replaceable_with to the top of update_credential (sync + async) so the type check runs before any state changes, on every rotation. * Drops the duplicate _check_replaceable_with from _CredentialHolder.replace; both callers validate first. * Refactors update_credential to put the holder swap and old-client close in the shared path. The cert branch now only handles the SSL-context refresh and Client rebuild. * Logs a warning when the PKCS#12 temporary PEM cleanup fails, instead of swallowing the OSError. The cluster request still succeeds since the SSL context is already loaded. * Tightens the cryptography pin from >=41.0 to ~=47.0. Regenerates requirements.txt and uv.lock. * Tidies up two comments (Credential.__slots__ rationale, cert chain hook in connection.py).
1 parent 2f8181b commit 58d290f

8 files changed

Lines changed: 35 additions & 102 deletions

File tree

acouchbase_analytics/protocol/_core/client_adapter.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -200,31 +200,29 @@ def reset_client(self) -> None:
200200
del self._client
201201

202202
async def update_credential(self, new_credential: Credential) -> None:
203+
old_client = None
204+
self._credential_holder.credential._check_replaceable_with(new_credential)
203205
if new_credential._kind == 'cert':
204206
# httpx pins the SSL context to the AsyncClient at construction,
205207
# and the cert chain is part of that context. So a cert rotation
206208
# needs a fresh Client. Build it before aclosing the old one,
207209
# otherwise a concurrent send_request can see self._client gone.
208-
self._credential_holder.credential._check_replaceable_with(new_credential)
209210
old_client = getattr(self, '_client', None)
210211
old_ssl_context = self._conn_details.ssl_context
211212
old_sni_hostname = self._conn_details.sni_hostname
212213
try:
213214
self._conn_details.validate_security_options(new_credential)
214215
# If the cluster hasn't issued a request yet there's no Client
215216
# to swap; we still refreshed the SSL context above.
216-
new_client = self._build_client() if old_client is not None else None
217+
if old_client is not None:
218+
self._client = self._build_client()
217219
except Exception:
218220
self._conn_details.ssl_context = old_ssl_context
219221
self._conn_details.sni_hostname = old_sni_hostname
220222
raise
221-
if new_client is not None:
222-
self._client = new_client
223-
self._credential_holder.replace(new_credential)
224-
if old_client is not None:
225-
await old_client.aclose()
226-
else:
227-
self._credential_holder.replace(new_credential)
223+
self._credential_holder.replace(new_credential)
224+
if old_client is not None:
225+
await old_client.aclose()
228226
self.log_message('Cluster HTTP credential updated', LogLevel.INFO)
229227

230228

couchbase_analytics/common/credential.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
from __future__ import annotations
1919

20+
import logging
2021
import os
2122
import ssl
2223
import tempfile
@@ -29,6 +30,9 @@
2930
from httpx import Request
3031

3132

33+
logger = logging.getLogger(__name__)
34+
35+
3236
class Credential:
3337
"""A credential for authenticating with a Couchbase Analytics endpoint.
3438
@@ -321,7 +325,12 @@ def _load_pkcs12_into_context(self, ctx: ssl.SSLContext) -> None:
321325
try:
322326
os.unlink(tmp_path)
323327
except OSError:
324-
pass
328+
logger.warning(
329+
'Failed to remove temporary PKCS#12 PEM file %s; '
330+
'decrypted private key material may remain on disk.',
331+
tmp_path,
332+
exc_info=True,
333+
)
325334

326335
def _check_endpoint_compatible(self, is_secure: bool) -> None:
327336
if self._kind == 'jwt' and not is_secure:
@@ -365,6 +374,6 @@ def apply_to_request(self, request: Request) -> None:
365374
self._credential._apply_to_request(request)
366375

367376
def replace(self, new_credential: Credential) -> None:
368-
# GIL-atomic store; concurrent rotators are last-writer-wins.
369-
self._credential._check_replaceable_with(new_credential)
377+
# Caller must have validated replaceability via _check_replaceable_with;
378+
# this is a GIL-atomic store, last-writer-wins on concurrent rotators.
370379
self._credential = new_credential

couchbase_analytics/protocol/_core/client_adapter.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -199,31 +199,29 @@ def reset_client(self) -> None:
199199
del self._client
200200

201201
def update_credential(self, new_credential: Credential) -> None:
202+
old_client = None
203+
self._credential_holder.credential._check_replaceable_with(new_credential)
202204
if new_credential._kind == 'cert':
203205
# httpx pins the SSL context to the Client at construction, and
204206
# the cert chain is part of that context. So a cert rotation
205207
# needs a fresh Client. Build it before closing the old one,
206208
# otherwise a concurrent send_request can see self._client gone.
207-
self._credential_holder.credential._check_replaceable_with(new_credential)
208209
old_client = getattr(self, '_client', None)
209210
old_ssl_context = self._conn_details.ssl_context
210211
old_sni_hostname = self._conn_details.sni_hostname
211212
try:
212213
self._conn_details.validate_security_options(new_credential)
213214
# If the cluster hasn't issued a request yet there's no Client
214215
# to swap; we still refreshed the SSL context above.
215-
new_client = self._build_client() if old_client is not None else None
216+
if old_client is not None:
217+
self._client = self._build_client()
216218
except Exception:
217219
self._conn_details.ssl_context = old_ssl_context
218220
self._conn_details.sni_hostname = old_sni_hostname
219221
raise
220-
if new_client is not None:
221-
self._client = new_client
222-
self._credential_holder.replace(new_credential)
223-
if old_client is not None:
224-
old_client.close()
225-
else:
226-
self._credential_holder.replace(new_credential)
222+
self._credential_holder.replace(new_credential)
223+
if old_client is not None:
224+
old_client.close()
227225
self.log_message('Cluster HTTP credential updated', LogLevel.INFO)
228226

229227

couchbase_analytics/protocol/connection.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,8 +255,8 @@ def validate_security_options(self, credential: Credential) -> None: # noqa: C9
255255
self.ssl_context.verify_mode = ssl.CERT_REQUIRED
256256

257257
# Cert credentials attach their chain here; no-op for password/JWT.
258-
# Runs after verify_mode is set so OpenSSL has a fully configured
259-
# context when load_cert_chain fires.
258+
# Runs after verify_mode is set so the context is finished before
259+
# load_cert_chain reads it.
260260
credential._apply_to_ssl_context(self.ssl_context)
261261

262262
@classmethod

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ name = "couchbase-analytics"
1010
version = "1.0.0.dev1"
1111
dependencies = [
1212
"anyio~=4.9.0",
13-
"cryptography>=41.0",
13+
"cryptography~=47.0",
1414
"httpx~=0.28.1",
1515
"ijson~=3.4.0",
1616
"sniffio~=1.3.1",

requirements.in

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@ anyio~=4.9.0
22
sniffio~=1.3.1
33
httpx~=0.28.1
44
ijson~=3.4.0
5-
cryptography>=41.0
5+
cryptography~=47.0
66
# Typing support
77
typing-extensions~=4.11; python_version<"3.11.0"

requirements.txt

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@ certifi==2025.6.15
1010
# httpx
1111
cffi==2.0.0 ; platform_python_implementation != 'PyPy'
1212
# via cryptography
13-
cryptography==47.0.0 ; python_full_version <= '3.9'
14-
# via -r requirements.in
15-
cryptography==48.0.0 ; python_full_version > '3.9'
13+
cryptography==47.0.0
1614
# via -r requirements.in
1715
exceptiongroup==1.3.0 ; python_full_version < '3.11'
1816
# via anyio

0 commit comments

Comments
 (0)