diff --git a/changelog.md b/changelog.md index e003db3b..e666696e 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,7 @@ Upcoming (TBD) Features --------- * Make password sources and precedence configurable. +* Ability to deselect fallback interactive password prompt. Bug Fixes diff --git a/mycli/client_connection.py b/mycli/client_connection.py index f07f6b18..6ee8169f 100644 --- a/mycli/client_connection.py +++ b/mycli/client_connection.py @@ -192,7 +192,6 @@ def connect( # prompt for password if requested by user if passwd == EMPTY_PASSWORD_FLAG_SENTINEL: passwd = click.prompt(f"Enter password for {user}", hide_input=True, show_default=False, default='', type=str, err=True) - keyring_retrieved_cleanly = False # should not fail, but will help the typechecker assert not isinstance(passwd, int) @@ -286,15 +285,18 @@ def _connect( _connect( retry_ssl=True, keyring_retrieved_cleanly=keyring_retrieved_cleanly, keyring_save_eligible=keyring_save_eligible ) - elif e1.args[0] == ACCESS_DENIED_ERROR and connection_info["password"] is None: + elif e1.args[0] == ACCESS_DENIED_ERROR and connection_info["password"] is None and 'fallback' in password_source_precedence: # if we already tried and failed to connect with a new password, raise the error if retry_password: raise e1 - # ask the user for a new password and try to connect again - new_password = click.prompt( - f"Enter password for {user}", hide_input=True, show_default=False, default='', type=str, err=True + password_candidates.add_loader( + 'fallback', + lambda: click.prompt( + f"Enter password for {user}", hide_input=True, show_default=False, default='', type=str, err=True + ), ) - connection_info["password"] = new_password + fallback_password = password_candidates.resolve(['fallback']) + connection_info["password"] = fallback_password.value if fallback_password is not None else None keyring_retrieved_cleanly = False _connect( retry_password=True, diff --git a/mycli/myclirc b/mycli/myclirc index f3d470c4..39d55fad 100644 --- a/mycli/myclirc +++ b/mycli/myclirc @@ -207,7 +207,7 @@ pager = 'less' # Comma-separated sources to respect for a password, in order of precedence. # Valid choices: -# * prompt # interactive fallback, or plain trailing --password at the CLI +# * prompt # plain trailing --password at the CLI # * literal # --password= at the CLI # * file # --password-file= at the CLI # * environment # $MYSQL_PWD environment variable @@ -215,7 +215,9 @@ pager = 'less' # * vault # value retrieved from "vault kv get" # * login_path # --login-path= at the CLI # * keyring # value retrieved from system keyring -password_sources = prompt, literal, file, environment, dsn, vault, login_path, keyring +# * fallback # interactive prompt after a passwordless connection fails +# If "fallback" is given, it must come last. +password_sources = prompt, literal, file, environment, dsn, vault, login_path, keyring, fallback # Whether to store and retrieve passwords from the system keyring. # * False - never use keyring diff --git a/mycli/password_sources.py b/mycli/password_sources.py index be8df3fa..94339be7 100644 --- a/mycli/password_sources.py +++ b/mycli/password_sources.py @@ -15,6 +15,7 @@ 'vault', 'login_path', 'keyring', + 'fallback', ] PasswordValue = str | int PasswordLoader = Callable[[], PasswordValue | None] @@ -28,6 +29,7 @@ 'vault', 'login_path', 'keyring', + 'fallback', ] diff --git a/test/myclirc b/test/myclirc index 7945eefd..050d902d 100644 --- a/test/myclirc +++ b/test/myclirc @@ -207,7 +207,7 @@ pager = python test/features/wrappager.py ---boundary--- # Comma-separated sources to respect for a password, in order of precedence. # Valid choices: -# * prompt # interactive fallback, or plain trailing --password at the CLI +# * prompt # plain trailing --password at the CLI # * literal # --password= at the CLI # * file # --password-file= at the CLI # * environment # $MYSQL_PWD environment variable @@ -215,7 +215,9 @@ pager = python test/features/wrappager.py ---boundary--- # * vault # value retrieved from "vault kv get" # * login_path # --login-path= at the CLI # * keyring # value retrieved from system keyring -password_sources = prompt, literal, file, environment, dsn, vault, login_path, keyring +# * fallback # interactive prompt after a passwordless connection fails +# If "fallback" is given, it must come last. +password_sources = prompt, literal, file, environment, dsn, vault, login_path, keyring, fallback # Whether to store and retrieve passwords from the system keyring. # * False - never use keyring diff --git a/test/pytests/test_client_connection.py b/test/pytests/test_client_connection.py index 82c3b565..fdf9b7e4 100644 --- a/test/pytests/test_client_connection.py +++ b/test/pytests/test_client_connection.py @@ -695,6 +695,31 @@ def test_connect_prompts_and_retries_after_access_denied_without_password( assert [call['password'] for call in FakeSQLExecute.calls] == [None, 'new-secret'] +def test_connect_does_not_prompt_after_access_denied_when_fallback_is_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + client = DummyClient( + config={ + 'main': {'password_sources': [source for source in KNOWN_PASSWORD_SOURCES if source != 'fallback']}, + 'connection': {}, + } + ) + FakeSQLExecute.effects = [op_error(client_connection.ACCESS_DENIED_ERROR)] + prompts: list[str] = [] + monkeypatch.setattr( + client_connection.click, + 'prompt', + lambda text, **_kwargs: prompts.append(text) or 'new-secret', # type: ignore[func-returns-value] + ) + + with pytest.raises(SystemExit) as excinfo: + client.connect(user='alice', host='db', port=3307) + + assert excinfo.value.code == 1 + assert prompts == [] + assert [call['password'] for call in FakeSQLExecute.calls] == [None] + + def test_connect_exits_when_password_retry_fails(monkeypatch: pytest.MonkeyPatch) -> None: client = DummyClient() FakeSQLExecute.effects = [ diff --git a/test/pytests/test_password_sources.py b/test/pytests/test_password_sources.py index 2d637735..d76df268 100644 --- a/test/pytests/test_password_sources.py +++ b/test/pytests/test_password_sources.py @@ -63,6 +63,17 @@ def test_resolve_falls_back_when_a_loader_returns_none() -> None: assert selected.value == 'mylogin-secret' +def test_resolve_fallback_loader() -> None: + candidates = PasswordCandidates() + candidates.add_loader('fallback', lambda: 'fallback-secret') + + selected = candidates.resolve(['fallback']) + + assert selected is not None + assert selected.source == 'fallback' + assert selected.value == 'fallback-secret' + + def test_resolve_skips_unknown_source_and_returns_none(capsys: pytest.CaptureFixture[str]) -> None: candidates = PasswordCandidates()