Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Upcoming (TBD)
Features
---------
* Make password sources and precedence configurable.
* Ability to deselect fallback interactive password prompt.


Bug Fixes
Expand Down
14 changes: 8 additions & 6 deletions mycli/client_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions mycli/myclirc
Original file line number Diff line number Diff line change
Expand Up @@ -207,15 +207,17 @@ 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=<literal> at the CLI
# * file # --password-file=<file> at the CLI
# * environment # $MYSQL_PWD environment variable
# * dsn # mysql://user:password@ field in a DSN
# * vault # value retrieved from "vault kv get"
# * login_path # --login-path=<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
Expand Down
2 changes: 2 additions & 0 deletions mycli/password_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
'vault',
'login_path',
'keyring',
'fallback',
]
PasswordValue = str | int
PasswordLoader = Callable[[], PasswordValue | None]
Expand All @@ -28,6 +29,7 @@
'vault',
'login_path',
'keyring',
'fallback',
]


Expand Down
6 changes: 4 additions & 2 deletions test/myclirc
Original file line number Diff line number Diff line change
Expand Up @@ -207,15 +207,17 @@ 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=<literal> at the CLI
# * file # --password-file=<file> at the CLI
# * environment # $MYSQL_PWD environment variable
# * dsn # mysql://user:password@ field in a DSN
# * vault # value retrieved from "vault kv get"
# * login_path # --login-path=<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
Expand Down
25 changes: 25 additions & 0 deletions test/pytests/test_client_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
11 changes: 11 additions & 0 deletions test/pytests/test_password_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading