diff --git a/CHANGES.rst b/CHANGES.rst index c4802de3..1274b18b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,8 @@ Changelog Unreleased ---------- +* Fixed password update for users who have ``jwt`` configured. + 2.41.0 (2024-08-22) ------------------- diff --git a/crate/operator/update_user_password.py b/crate/operator/update_user_password.py index ce1ff4e0..12190dcc 100644 --- a/crate/operator/update_user_password.py +++ b/crate/operator/update_user_password.py @@ -30,6 +30,8 @@ from crate.operator.config import config from crate.operator.utils.formatting import b64decode +from crate.operator.utils.jwt import crate_version_supports_jwt +from crate.operator.utils.kubeapi import get_cratedb_resource from crate.operator.utils.notifications import send_operation_progress_notification from crate.operator.webhooks import WebhookAction, WebhookOperation, WebhookStatus @@ -62,6 +64,8 @@ async def update_user_password( """ scheme = "https" if has_ssl else "http" password = b64decode(new_password) + cratedb = await get_cratedb_resource(namespace, cluster_id) + crate_version = cratedb["spec"]["cluster"]["version"] def get_curl_command(payload: dict) -> List[str]: return [ @@ -78,6 +82,18 @@ def get_curl_command(payload: dict) -> List[str]: "\\n", ] + async def pod_exec(cmd): + return await core_ws.connect_get_namespaced_pod_exec( + namespace=namespace, + name=pod_name, + command=cmd, + container="crate", + stderr=True, + stdin=False, + stdout=True, + tty=False, + ) + command_alter_user = get_curl_command( { "stmt": 'ALTER USER "{}" SET (password = $1)'.format(username), @@ -88,51 +104,90 @@ def get_curl_command(payload: dict) -> List[str]: async with WsApiClient() as ws_api_client: core_ws = CoreV1Api(ws_api_client) - try: - logger.info("Trying to update user password ...") - result = await core_ws.connect_get_namespaced_pod_exec( - namespace=namespace, - name=pod_name, - command=command_alter_user, - container="crate", - stderr=True, - stdin=False, - stdout=True, - tty=False, + if crate_version_supports_jwt(crate_version): + # For users with `jwt` and `password` set, we need to reset + # `jwt` config first to be able to update the password. + iss = cratedb["spec"].get("grandCentral", {}).get("jwkUrl") + + command_reset_user_jwt = get_curl_command( + { + "stmt": 'ALTER USER "{}" SET (jwt = NULL)'.format(username), + "args": [], + } ) - if "rowcount" in result: - logger.info("... success") + try: + logger.info("Trying to reset user jwt config ...") + result = await pod_exec(command_reset_user_jwt) + except ApiException as e: + exception_logger( + "... failed. Status: %s Reason: %s", e.status, e.reason + ) + raise _temporary_error() + except TemporaryError: + raise + except WSServerHandshakeError as e: + exception_logger( + "... failed. Status: %s Message: %s", e.status, e.message + ) + raise _temporary_error() + except Exception as e: + exception_logger( + "... failed. Unexpected exception. Class: %s. Message: %s", + type(e).__name__, + str(e), + ) + raise _temporary_error() else: - logger.info("... error. %s", result) - raise TemporaryError(delay=config.BOOTSTRAP_RETRY_DELAY) + if "rowcount" in result: + logger.info("... success") + command_alter_user = get_curl_command( + { + "stmt": ( + 'ALTER USER "{}" SET (password = $1, jwt = ' + '{{"iss" = $2, "username" = $3, "aud" = $4}})' + ).format(username), + "args": [password, iss, username, cluster_id], + } + ) + else: + logger.info("... error. %s", result) + raise _temporary_error() - await send_operation_progress_notification( - namespace=namespace, - name=cluster_id, - message="Password updated successfully.", - logger=logger, - status=WebhookStatus.SUCCESS, - operation=WebhookOperation.UPDATE, - action=WebhookAction.PASSWORD_UPDATE, - ) + try: + logger.info("Trying to update user password ...") + result = await pod_exec(command_alter_user) except ApiException as e: - # We don't use `logger.exception()` to not accidentally include the - # password in the log messages which might be part of the string - # representation of the exception. exception_logger("... failed. Status: %s Reason: %s", e.status, e.reason) - raise TemporaryError(delay=config.BOOTSTRAP_RETRY_DELAY) - except WSServerHandshakeError as e: - # We don't use `logger.exception()` to not accidentally include the - # password in the log messages which might be part of the string - # representation of the exception. - exception_logger("... failed. Status: %s Message: %s", e.status, e.message) - raise TemporaryError(delay=config.BOOTSTRAP_RETRY_DELAY) + raise _temporary_error() except TemporaryError: raise + except WSServerHandshakeError as e: + exception_logger("... failed. Status: %s Message: %s", e.status, e.message) + raise _temporary_error() except Exception as e: exception_logger( "... failed. Unexpected exception was raised. Class: %s. Message: %s", type(e).__name__, str(e), ) - raise TemporaryError(delay=config.BOOTSTRAP_RETRY_DELAY) + raise _temporary_error() + else: + if "rowcount" in result: + logger.info("... success") + else: + logger.info("... error. %s", result) + raise _temporary_error() + + await send_operation_progress_notification( + namespace=namespace, + name=cluster_id, + message="Password updated successfully.", + logger=logger, + status=WebhookStatus.SUCCESS, + operation=WebhookOperation.UPDATE, + action=WebhookAction.PASSWORD_UPDATE, + ) + + +def _temporary_error(): + return TemporaryError(delay=config.BOOTSTRAP_RETRY_DELAY) diff --git a/tests/test_update_password.py b/tests/test_update_password.py index dee8282e..7e7b8eb5 100644 --- a/tests/test_update_password.py +++ b/tests/test_update_password.py @@ -46,6 +46,7 @@ ) from .utils import ( + CRATE_VERSION, DEFAULT_TIMEOUT, assert_wait_for, start_cluster, @@ -182,12 +183,18 @@ async def test_update_cluster_password( ) +@mock.patch("crate.operator.update_user_password.get_cratedb_resource") @mock.patch("crate.operator.webhooks.webhook_client.send_notification") @mock.patch("kubernetes_asyncio.client.CoreV1Api.connect_get_namespaced_pod_exec") async def test_update_cluster_password_errors( - mock_pod_exec, mock_send_notification: mock.AsyncMock + mock_pod_exec, + mock_send_notification: mock.AsyncMock, + mock_get_cratedb_resource: mock.AsyncMock, ): mock_pod_exec.side_effect = Exception("test-exception") + mock_get_cratedb_resource.return_value = { + "spec": {"cluster": {"version": CRATE_VERSION}} + } with pytest.raises(Exception) as e: await update_user_password( namespace="a-namespace",