From 9dd9386e0ce66c63a95d6a98058cb68c066d8935 Mon Sep 17 00:00:00 2001 From: Roland Walker Date: Sat, 18 Jul 2026 14:21:52 -0400 Subject: [PATCH] make keyring-save credential sources configurable and by default remove some sources such as vault, which are unlikely to be wanted for saving in a separate store. Similarly, remove the keyring itself as a keyring-save source, since it can only save back the same value. Improve the verbose keyring-save message with a note about the source of the saved credential. --- changelog.md | 8 ++++++ mycli/client_connection.py | 36 ++++++++++++++++++++------ mycli/myclirc | 5 ++++ test/myclirc | 5 ++++ test/pytests/test_client_connection.py | 10 +++++-- 5 files changed, 54 insertions(+), 10 deletions(-) diff --git a/changelog.md b/changelog.md index 830e1495..bbafc6c3 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,11 @@ +Upcoming (TBD) +============== + +Features +--------- +* Configurable sources of credentials to save to the keyring. + + 2.4.0 (2026/07/18) ============== diff --git a/mycli/client_connection.py b/mycli/client_connection.py index 6ee8169f..66a3a9e1 100644 --- a/mycli/client_connection.py +++ b/mycli/client_connection.py @@ -80,6 +80,7 @@ def connect( user_connection_config = self.config_without_package_defaults.get('connection', {}) self.keepalive_ticks = keepalive_ticks self.ssh_tunnel = None + self.selected_password = None int_port = port and int(port) if not int_port: @@ -92,6 +93,11 @@ def connect( else: password_source_precedence = ['prompt'] + if self.config.get('main', {}).get('keyring_sources'): + keyring_sources = self.config.get('main').as_list('keyring_sources') + else: + keyring_sources = [] + if ssh_jump: remote_host = host or DEFAULT_HOST remote_port = int_port or DEFAULT_PORT @@ -185,9 +191,9 @@ def connect( if use_keyring and not reset_keyring: password_candidates.add_loader('keyring', lambda: keyring.get_password(keyring_domain, keyring_identifier)) - selected_password = password_candidates.resolve(password_source_precedence) - passwd = selected_password.value if selected_password is not None else None - keyring_retrieved_cleanly = selected_password is not None and selected_password.source == 'keyring' + self.selected_password = password_candidates.resolve(password_source_precedence) + passwd = self.selected_password.value if self.selected_password is not None else None + keyring_retrieved_cleanly = self.selected_password is not None and self.selected_password.source == 'keyring' # prompt for password if requested by user if passwd == EMPTY_PASSWORD_FLAG_SENTINEL: @@ -253,15 +259,25 @@ def connect( connection_info['port'] = int_port connection_info['socket'] = socket - def _update_keyring(password: str | None, keyring_retrieved_cleanly: bool): + def _update_keyring( + password: str | None, + password_source: str | None, + keyring_retrieved_cleanly: bool, + ): if not password: return + if password_source not in keyring_sources: + return if reset_keyring or (use_keyring and not keyring_retrieved_cleanly): try: saved_pw = keyring.get_password(keyring_domain, keyring_identifier) if password != saved_pw or reset_keyring: keyring.set_password(keyring_domain, keyring_identifier, password) - click.secho(f'Password saved to the system keyring at {keyring_domain}/{keyring_identifier}', err=True) + click.secho( + f'Password from source: "{password_source}" ' + f'saved to the system keyring at {keyring_domain}/{keyring_identifier}', + err=True, + ) except Exception as e: click.secho(f'Password not saved to the system keyring: {e}', err=True, fg='red') @@ -273,7 +289,11 @@ def _connect( ) -> None: try: if keyring_save_eligible: - _update_keyring(connection_info["password"], keyring_retrieved_cleanly=keyring_retrieved_cleanly) + _update_keyring( + connection_info["password"], + self.selected_password.source if self.selected_password else None, + keyring_retrieved_cleanly=keyring_retrieved_cleanly, + ) self.sqlexecute = SQLExecute(**connection_info) except pymysql.OperationalError as e1: if e1.args[0] == HANDSHAKE_ERROR and ssl is not None and ssl.get("mode", None) == "auto": @@ -295,8 +315,8 @@ def _connect( f"Enter password for {user}", hide_input=True, show_default=False, default='', type=str, err=True ), ) - fallback_password = password_candidates.resolve(['fallback']) - connection_info["password"] = fallback_password.value if fallback_password is not None else None + self.selected_password = password_candidates.resolve(['fallback']) + connection_info["password"] = self.selected_password.value if self.selected_password else None keyring_retrieved_cleanly = False _connect( retry_password=True, diff --git a/mycli/myclirc b/mycli/myclirc index 5adb2686..b7ad0534 100644 --- a/mycli/myclirc +++ b/mycli/myclirc @@ -232,6 +232,11 @@ password_sources = prompt, literal, file, environment, dsn, boundary, vault, log # Recommended: auto use_keyring = False +# Comma-separated password sources eligible to be saved in the keyring. +# Valid choices: same as password_sources (above). +# use_keyring must be True or auto. +keyring_sources = prompt, literal, file, environment, dsn, login_path, fallback + [editor] # Command line to invoke for /edit, instead of respecting the $VISUAL/$EDITOR diff --git a/test/myclirc b/test/myclirc index b33f0f7f..26789650 100644 --- a/test/myclirc +++ b/test/myclirc @@ -232,6 +232,11 @@ password_sources = prompt, literal, file, environment, dsn, boundary, vault, log # Recommended: auto use_keyring = False +# Comma-separated password sources eligible to be saved in the keyring. +# Valid choices: same as password_sources (above). +# use_keyring must be True or auto. +keyring_sources = prompt, literal, file, environment, dsn, login_path, fallback + [editor] # Command line to invoke for /edit, instead of respecting the $VISUAL/$EDITOR diff --git a/test/pytests/test_client_connection.py b/test/pytests/test_client_connection.py index fdf9b7e4..c5773062 100644 --- a/test/pytests/test_client_connection.py +++ b/test/pytests/test_client_connection.py @@ -38,7 +38,13 @@ def __init__( ) -> None: self.cnf = cnf or default_cnf() self.mylogin_cnf = object() - self.config = ConfigObj(config) or ConfigObj({'main': {'password_sources': KNOWN_PASSWORD_SOURCES}, 'connection': {}}) + self.config = ConfigObj(config) or ConfigObj({ + 'main': { + 'password_sources': KNOWN_PASSWORD_SOURCES, + 'keyring_sources': ['prompt', 'literal', 'file', 'environment', 'dsn', 'login_path', 'fallback'], + }, + 'connection': {}, + }) self.config_without_package_defaults = config_without_package_defaults or {} self.keepalive_ticks: int | None = None self.sandbox_mode = False @@ -327,7 +333,7 @@ def test_connect_saves_selected_password_to_keyring(monkeypatch: pytest.MonkeyPa client.connect(user='alice', host='db', port=3307, password_candidates=candidates, use_keyring=True) assert set_password_calls == [('mycli.net', 'alice@db:3307:', 'new-secret')] - assert secho_calls == [('Password saved to the system keyring at mycli.net/alice@db:3307:', {'err': True})] + assert secho_calls == [('Password from source: "dsn" saved to the system keyring at mycli.net/alice@db:3307:', {'err': True})] def test_connect_reports_keyring_save_error(monkeypatch: pytest.MonkeyPatch) -> None: