Skip to content

Commit 3a99496

Browse files
PYCBC-1714: Support mTLS Certs Refresh (without restart)
Changes ======= - Add update_credentials to sync (couchbase) and async (acouchbase) APIs - Wire through Python/C++ bindings to refresh TLS context without restart - Update tests for credential refresh path Change-Id: I39a9bf87424ca7331b5fcd4c83b290ff82c4772e Reviewed-on: https://review.couchbase.org/c/couchbase-python-client/+/236404 Tested-by: Build Bot <build@couchbase.com> Reviewed-by: Jared Casey <jared.casey@couchbase.com>
1 parent c7eca54 commit 3a99496

9 files changed

Lines changed: 275 additions & 5 deletions

File tree

acouchbase/cluster.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
from typing import (TYPE_CHECKING,
2323
Any,
2424
Awaitable,
25-
Dict)
25+
Dict,
26+
Union)
2627

2728
from acouchbase import get_event_loop
2829
from acouchbase.analytics import AnalyticsQuery, AsyncAnalyticsRequest
@@ -37,6 +38,7 @@
3738
from acouchbase.n1ql import AsyncN1QLRequest, N1QLQuery
3839
from acouchbase.search import AsyncFullTextSearchRequest, SearchQueryBuilder
3940
from acouchbase.transactions import Transactions
41+
from couchbase.auth import CertificateAuthenticator, PasswordAuthenticator
4042
from couchbase.diagnostics import ClusterState, ServiceType
4143
from couchbase.exceptions import UnAmbiguousTimeoutException
4244
from couchbase.logic.cluster import ClusterLogic
@@ -179,6 +181,19 @@ async def close(self) -> None:
179181
await self._close_ftr
180182
super()._destroy_connection()
181183

184+
def update_credentials(self, authenticator: Union[CertificateAuthenticator, PasswordAuthenticator]) -> None:
185+
"""Update the credentials used by this Cluster.
186+
187+
Args:
188+
authenticator (Union[CertificateAuthenticator, PasswordAuthenticator]): New authenticator.
189+
"""
190+
if not self.connected:
191+
raise RuntimeError("Cluster is not connected, cannot update credentials.")
192+
193+
# This is a fast, synchronous operation in the core; call directly.
194+
super()._update_credentials(auth=authenticator.as_dict())
195+
self._auth = authenticator.as_dict()
196+
182197
def bucket(self, bucket_name) -> AsyncBucket:
183198
"""Creates a Bucket instance to a specific bucket.
184199

acouchbase/tests/credentials_t.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Copyright 2016-2025. Couchbase, Inc.
2+
# All Rights Reserved.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License")
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
import uuid
17+
18+
import pytest
19+
import pytest_asyncio
20+
21+
from acouchbase.cluster import Cluster as AsyncCluster
22+
from couchbase.auth import CertificateAuthenticator, PasswordAuthenticator
23+
from couchbase.exceptions import InvalidArgumentException
24+
from couchbase.options import ClusterOptions
25+
26+
27+
class AsyncCredentialsTests:
28+
29+
@pytest_asyncio.fixture(scope="class")
30+
async def cb_env(self, couchbase_config):
31+
class Env:
32+
def __init__(self, cfg):
33+
self.cfg = cfg
34+
self.cluster = None
35+
env = Env(couchbase_config)
36+
conn_string = couchbase_config.get_connection_string()
37+
username, pw = couchbase_config.get_username_and_pw()
38+
env.cluster = await AsyncCluster.connect(conn_string, ClusterOptions(PasswordAuthenticator(username, pw)))
39+
yield env
40+
await env.cluster.close()
41+
42+
@pytest.mark.asyncio
43+
async def test_update_credentials_async(self, cb_env):
44+
cluster = cb_env.cluster
45+
46+
new_user = f"pycbc_{uuid.uuid4().hex[:8]}"
47+
new_pass = f"pw_{uuid.uuid4().hex[:8]}"
48+
cluster.update_credentials(PasswordAuthenticator(new_user, new_pass))
49+
50+
info_after = cluster._get_client_connection_info()
51+
assert info_after['credentials']['username'] == new_user
52+
assert info_after['credentials']['password'] == new_pass
53+
54+
@pytest.mark.asyncio
55+
async def test_update_to_certificate_auth_without_tls_fails_async(self, cb_env):
56+
cluster = cb_env.cluster
57+
# Attempt to switch to certificate auth without TLS should raise
58+
with pytest.raises(InvalidArgumentException):
59+
cluster.update_credentials(CertificateAuthenticator(cert_path='path/to/cert',
60+
key_path='path/to/key'))
61+
62+
@pytest.mark.asyncio
63+
async def test_update_credentials_failure_does_not_change_state_async(self, cb_env):
64+
cluster = cb_env.cluster
65+
66+
# capture original
67+
info_before = cluster._get_client_connection_info()
68+
assert 'credentials' in info_before
69+
orig_creds = info_before['credentials']
70+
71+
# attempt to switch to certificate auth on non-TLS connection; expect failure
72+
with pytest.raises(InvalidArgumentException):
73+
cluster.update_credentials(CertificateAuthenticator(cert_path='path/to/cert',
74+
key_path='path/to/key'))
75+
76+
# ensure credentials are unchanged after the failed update
77+
info_after = cluster._get_client_connection_info()
78+
assert info_after['credentials'] == orig_creds

couchbase/cluster.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@
1919
from datetime import timedelta
2020
from typing import (TYPE_CHECKING,
2121
Any,
22-
Dict)
22+
Dict,
23+
Union)
2324

2425
from couchbase.analytics import AnalyticsQuery, AnalyticsRequest
26+
from couchbase.auth import CertificateAuthenticator, PasswordAuthenticator
2527
from couchbase.bucket import Bucket
2628
from couchbase.diagnostics import ClusterState, ServiceType
2729
from couchbase.exceptions import ErrorMapper, UnAmbiguousTimeoutException
@@ -229,6 +231,17 @@ def diagnostics(self,
229231

230232
return super().diagnostics(*opts, **kwargs)
231233

234+
def update_credentials(self, authenticator: Union[CertificateAuthenticator, PasswordAuthenticator]) -> None:
235+
"""Update the credentials used by this Cluster.
236+
237+
Args:
238+
authenticator (Union[CertificateAuthenticator, PasswordAuthenticator]): New authenticator.
239+
"""
240+
if not self.connected:
241+
raise RuntimeError("Cluster not yet connected.")
242+
super()._update_credentials(auth=authenticator.as_dict())
243+
self._auth = authenticator.as_dict()
244+
232245
def wait_until_ready(self,
233246
timeout, # type: timedelta
234247
*opts, # type: WaitUntilReadyOptions

couchbase/logic/cluster.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
from couchbase import USER_AGENT_EXTRA
3030
from couchbase.auth import CertificateAuthenticator, PasswordAuthenticator
3131
from couchbase.diagnostics import ServiceType
32-
from couchbase.exceptions import InvalidArgumentException
32+
from couchbase.exceptions import ErrorMapper, InvalidArgumentException
33+
from couchbase.exceptions import exception as BaseCouchbaseException
3334
from couchbase.options import (ClusterMetricsOptions,
3435
ClusterOptions,
3536
ClusterOrphanReportingOptions,
@@ -46,7 +47,8 @@
4647
get_connection_info,
4748
management_operation,
4849
mgmt_operations,
49-
operations)
50+
operations,
51+
update_credentials)
5052
from couchbase.result import (ClusterInfoResult,
5153
DiagnosticsResult,
5254
PingResult)
@@ -471,6 +473,22 @@ def _close_cluster(self, **kwargs):
471473
self._connection, **close_kwargs
472474
)
473475

476+
def _update_credentials(self, **kwargs):
477+
"""**INTERNAL** not intended for use in public API.
478+
479+
Expects kwargs to contain:
480+
- auth: dict of authenticator values (see couchbase.auth.Authenticator.as_dict())
481+
"""
482+
483+
auth = kwargs.pop('auth', None)
484+
if auth is None:
485+
raise InvalidArgumentException('Authenticator (auth) must be provided.')
486+
487+
ret = update_credentials(self._connection, auth=auth)
488+
if isinstance(ret, BaseCouchbaseException):
489+
raise ErrorMapper.build_exception(ret)
490+
return ret
491+
474492
def _set_connection(self, conn):
475493
self._connection = conn
476494

couchbase/tests/credentials_t.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Copyright 2016-2025. Couchbase, Inc.
2+
# All Rights Reserved.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License")
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
import uuid
17+
18+
import pytest
19+
20+
from couchbase.auth import CertificateAuthenticator, PasswordAuthenticator
21+
from couchbase.cluster import Cluster
22+
from couchbase.exceptions import InvalidArgumentException
23+
from couchbase.options import ClusterOptions
24+
25+
26+
class ClassicCredentialsTests:
27+
28+
def test_update_credentials_reflected_in_connection_info(self, couchbase_config):
29+
conn_string = couchbase_config.get_connection_string()
30+
username, pw = couchbase_config.get_username_and_pw()
31+
32+
cluster = Cluster.connect(conn_string, ClusterOptions(PasswordAuthenticator(username, pw)))
33+
34+
# capture original
35+
info_before = cluster._get_client_connection_info()
36+
assert 'credentials' in info_before
37+
orig_creds = info_before['credentials']
38+
39+
# update to new (likely invalid) creds; we only assert that core origin is updated
40+
new_user = f"pycbc_{uuid.uuid4().hex[:8]}"
41+
new_pass = f"pw_{uuid.uuid4().hex[:8]}"
42+
cluster.update_credentials(PasswordAuthenticator(new_user, new_pass))
43+
44+
info_after = cluster._get_client_connection_info()
45+
assert info_after['credentials']['username'] == new_user
46+
assert info_after['credentials']['password'] == new_pass
47+
48+
# restore original to avoid impacting other tests
49+
cluster.update_credentials(PasswordAuthenticator(orig_creds.get('username', username),
50+
orig_creds.get('password', pw)))
51+
52+
cluster.close()
53+
54+
def test_update_to_certificate_auth_without_tls_fails(self, couchbase_config):
55+
conn_string = couchbase_config.get_connection_string()
56+
username, pw = couchbase_config.get_username_and_pw()
57+
58+
# Non-TLS connection (expected couchbase://)
59+
cluster = Cluster.connect(conn_string, ClusterOptions(PasswordAuthenticator(username, pw)))
60+
61+
# Core should reject this at validation step, surfacing as InvalidArgumentException
62+
with pytest.raises(InvalidArgumentException):
63+
cluster.update_credentials(CertificateAuthenticator(cert_path='path/to/cert',
64+
key_path='path/to/key'))
65+
66+
cluster.close()
67+
68+
def test_update_credentials_failure_does_not_change_state(self, couchbase_config):
69+
conn_string = couchbase_config.get_connection_string()
70+
username, pw = couchbase_config.get_username_and_pw()
71+
72+
cluster = Cluster.connect(conn_string, ClusterOptions(PasswordAuthenticator(username, pw)))
73+
74+
# capture original
75+
info_before = cluster._get_client_connection_info()
76+
assert 'credentials' in info_before
77+
orig_creds = info_before['credentials']
78+
79+
# attempt to switch to certificate auth on non-TLS connection; expect failure
80+
with pytest.raises(InvalidArgumentException):
81+
cluster.update_credentials(CertificateAuthenticator(cert_path='path/to/cert',
82+
key_path='path/to/key'))
83+
84+
# ensure credentials are unchanged after the failed update
85+
info_after = cluster._get_client_connection_info()
86+
assert info_after['credentials'] == orig_creds
87+
88+
cluster.close()

deps/couchbase-cxx-client

src/client.cxx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,17 @@ create_connection(PyObject* self, PyObject* args, PyObject* kwargs)
300300
return res;
301301
}
302302

303+
static PyObject*
304+
update_credentials(PyObject* self, PyObject* args, PyObject* kwargs)
305+
{
306+
PyObject* res = handle_update_credentials(self, args, kwargs);
307+
if (res == nullptr && PyErr_Occurred() == nullptr) {
308+
pycbc_set_python_exception(
309+
PycbcError::UnsuccessfulOperation, __FILE__, __LINE__, "Unable to update credentials.");
310+
}
311+
return res;
312+
}
313+
303314
static PyObject*
304315
get_connection_information(PyObject* self, PyObject* args, PyObject* kwargs)
305316
{
@@ -336,6 +347,10 @@ static struct PyMethodDef methods[] = {
336347
(PyCFunction)create_connection,
337348
METH_VARARGS | METH_KEYWORDS,
338349
"Create connection object" },
350+
{ "update_credentials",
351+
(PyCFunction)update_credentials,
352+
METH_VARARGS | METH_KEYWORDS,
353+
"Update connection credentials" },
339354
{ "get_connection_info",
340355
(PyCFunction)get_connection_information,
341356
METH_VARARGS | METH_KEYWORDS,

src/connection.cxx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1565,3 +1565,43 @@ handle_open_or_close_bucket([[maybe_unused]] PyObject* self, PyObject* args, PyO
15651565
}
15661566
Py_RETURN_NONE;
15671567
}
1568+
1569+
PyObject*
1570+
handle_update_credentials([[maybe_unused]] PyObject* self, PyObject* args, PyObject* kwargs)
1571+
{
1572+
PyObject* pyObj_conn = nullptr;
1573+
PyObject* pyObj_auth = nullptr;
1574+
1575+
static const char* kw_list[] = { "", "auth", nullptr };
1576+
1577+
const char* kw_format = "O!O!";
1578+
int ret = PyArg_ParseTupleAndKeywords(args,
1579+
kwargs,
1580+
kw_format,
1581+
const_cast<char**>(kw_list),
1582+
&PyCapsule_Type,
1583+
&pyObj_conn,
1584+
&PyDict_Type,
1585+
&pyObj_auth);
1586+
1587+
if (!ret) {
1588+
std::string msg = "Cannot update credentials. Unable to parse args/kwargs.";
1589+
pycbc_set_python_exception(PycbcError::InvalidArgument, __FILE__, __LINE__, msg.c_str());
1590+
return nullptr;
1591+
}
1592+
1593+
connection* conn = reinterpret_cast<connection*>(PyCapsule_GetPointer(pyObj_conn, "conn_"));
1594+
if (nullptr == conn) {
1595+
pycbc_set_python_exception(PycbcError::InvalidArgument, __FILE__, __LINE__, NULL_CONN_OBJECT);
1596+
return nullptr;
1597+
}
1598+
1599+
couchbase::core::cluster_credentials auth = get_cluster_credentials(pyObj_auth);
1600+
1601+
auto err = conn->cluster_.update_credentials(std::move(auth));
1602+
if (err.ec) {
1603+
return pycbc_build_exception(err.ec, __FILE__, __LINE__, err.message);
1604+
}
1605+
1606+
Py_RETURN_NONE;
1607+
}

src/connection.hxx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,6 @@ handle_close_connection(PyObject* self, PyObject* args, PyObject* kwargs);
3030

3131
PyObject*
3232
handle_open_or_close_bucket(PyObject* self, PyObject* args, PyObject* kwargs);
33+
34+
PyObject*
35+
handle_update_credentials(PyObject* self, PyObject* args, PyObject* kwargs);

0 commit comments

Comments
 (0)