Skip to content

Commit 665d848

Browse files
refactor(no-ticket): pass resolved credentials through credential-helper plumbing (#336)
* refactor(no-ticket): pass resolved credentials through credential-helper plumbing The credential-helper plumbing flattened opts.credential into loose api_key/auth_type strings, threaded the pair through every signature, then rebuilt a CredentialResult (with a fabricated source_name) just to hand it to initialise_api. The getattr fallback guarding auth_type could never fire: it is a declared dataclass field with a default. Pass the CredentialResult itself instead. The object carries its own auth scheme end to end, so a bearer credential's custom-domain lookup now goes out as Authorization: Bearer rather than being re-derived at the bottom of the stack. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(no-ticket): clear stale X-Api-Key when switching to a bearer credential initialise_api() resets config.headers on every call but never clears config.api_key, which Configuration.set_default() makes sticky across calls. Re-initialising with a bearer credential therefore left a previously configured X-Api-Key in place, so subsequent requests carried both auth headers. This is reachable via the SSO login path: the initialise_api decorator sets X-Api-Key from credentials.ini, then refresh_api_config_after_auth() re-initialises with the bearer token. The API could then authenticate as the pre-login identity while the CLI reported a successful login. The bearer test previously called unset_api_key() to work around this; it now seeds a stale key and asserts it is cleared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(no-ticket): guard custom-domain discovery on a usable key and dry-run Two defects in the Docker credential-helper install path: CredentialResult is a plain dataclass, so `if not credential` and `if org and credential` are true for a credential carrying an empty api_key — the guards they replaced (`if not api_key`) were not re-established. Both now check credential.api_key, so a blank credential no longer reaches an unauthenticated custom-domains lookup. Auto-discovery also ran before the dry_run short-circuit, so `install docker --dry-run` issued a live API call and overwrote the on-disk domain cache despite promising to make no changes. Discovery is now skipped under dry_run and reported as such in the planned actions. Also hoists the function-local is_cloudsmith_domain imports to module level and gives the without-credential test the httpretty and config-path isolation its neighbours use, so it can no longer pass by falling through to a live 401. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent ca3819c commit 665d848

8 files changed

Lines changed: 192 additions & 71 deletions

File tree

cloudsmith_cli/cli/commands/credential_helper/manage.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -139,12 +139,6 @@ def install_cmd(
139139
"""
140140
installer = _get_installer(helper)
141141
org = org or os.environ.get("CLOUDSMITH_ORG", "").strip() or None
142-
api_key = opts.credential.api_key if opts.credential else None
143-
auth_type = (
144-
getattr(opts.credential, "auth_type", "api_key")
145-
if opts.credential
146-
else "api_key"
147-
)
148142
try:
149143
actions = installer.install(
150144
bin_dir=bin_dir,
@@ -153,8 +147,7 @@ def install_cmd(
153147
discover=not no_discover,
154148
refresh=refresh,
155149
org=org,
156-
api_key=api_key,
157-
auth_type=auth_type,
150+
credential=opts.credential,
158151
api_host=opts.api_host,
159152
)
160153
except OSError as exc:

cloudsmith_cli/cli/tests/commands/test_credential_helper.py

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
import pytest
1010

1111
from ....cli.commands.credential_helper.docker import docker
12+
from ....core.api.init import initialise_api
1213
from ....core.credentials.models import CredentialResult
1314
from ....credential_helpers.backends import BackendKind
15+
from ....credential_helpers.common import is_cloudsmith_domain
1416
from ....credential_helpers.custom_domains import (
1517
CustomDomain,
1618
get_cache_path,
@@ -157,8 +159,9 @@ def test_get_credentials(server_url, credential, is_cloudsmith_return, expected)
157159
with patch(
158160
"cloudsmith_cli.credential_helpers.docker.runtime.is_cloudsmith_domain",
159161
return_value=is_cloudsmith_return,
160-
):
162+
) as mock_check:
161163
result = helper_get_credentials(server_url, credential=credential)
164+
assert mock_check.call_args.kwargs["credential"] is credential
162165

163166
assert result == expected
164167

@@ -281,7 +284,8 @@ def test_get_custom_domains_status_matrix(
281284
content_type="application/json",
282285
)
283286

284-
result = get_custom_domains("acme", api_key="k_abc", api_host=API_HOST)
287+
credential = CredentialResult(api_key="k_abc", source_name="test")
288+
result = get_custom_domains("acme", credential=credential, api_host=API_HOST)
285289
cache = read_cache(get_cache_path("acme"))
286290

287291
if expect_domains:
@@ -300,6 +304,43 @@ def test_get_custom_domains_status_matrix(
300304
assert httpretty.last_request().headers.get("X-Api-Key") == "k_abc"
301305

302306

307+
@httpretty.activate(allow_net_connect=False)
308+
def test_get_custom_domains_bearer_credential_sends_authorization_header(
309+
tmp_path, monkeypatch
310+
):
311+
"""A bearer credential authenticates the lookup with its own header scheme.
312+
313+
The credential object flows through to the API layer unmodified, so an
314+
SSO access token goes out as ``Authorization: Bearer``, never ``X-Api-Key``.
315+
"""
316+
monkeypatch.setattr(
317+
"cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path",
318+
lambda: str(tmp_path),
319+
)
320+
httpretty.register_uri(
321+
httpretty.GET,
322+
f"{API_HOST}/orgs/acme/custom-domains/",
323+
body=json.dumps([]),
324+
status=200,
325+
content_type="application/json",
326+
)
327+
328+
# A previously configured API key must not leak into a bearer-authenticated
329+
# request: initialise_api clears the class-default X-Api-Key when switching.
330+
initialise_api(
331+
host=API_HOST,
332+
credential=CredentialResult(api_key="k_stale", source_name="test"),
333+
)
334+
credential = CredentialResult(
335+
api_key="jwt_token", source_name="test", auth_type="bearer"
336+
)
337+
get_custom_domains("acme", credential=credential, api_host=API_HOST)
338+
339+
headers = httpretty.last_request().headers
340+
assert headers.get("Authorization") == "Bearer jwt_token"
341+
assert headers.get("X-Api-Key") is None
342+
343+
303344
# ---------------------------------------------------------------------------
304345
# 7. get_custom_domains — cache edge cases
305346
# ---------------------------------------------------------------------------
@@ -401,7 +442,10 @@ def test_get_format_domains_filters_correctly(tmp_path, monkeypatch):
401442
)
402443

403444
hosts = get_format_domains(
404-
"acme", BackendKind.DOCKER, api_key="k", api_host=API_HOST
445+
"acme",
446+
BackendKind.DOCKER,
447+
credential=CredentialResult(api_key="k", source_name="test"),
448+
api_host=API_HOST,
405449
)
406450

407451
assert hosts == ["docker.acme.com"]
@@ -507,8 +551,6 @@ def test_is_cloudsmith_domain(
507551
tmp_path, monkeypatch, host, env_org, cached_domains, backend_kind, expected
508552
):
509553
"""is_cloudsmith_domain returns correct bool for standard, custom, and edge cases."""
510-
from ....credential_helpers.common import is_cloudsmith_domain
511-
512554
monkeypatch.setattr(
513555
"cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path",
514556
lambda: str(tmp_path),
@@ -522,14 +564,53 @@ def test_is_cloudsmith_domain(
522564
if cached_domains is not None:
523565
write_cache(get_cache_path(env_org), cached_domains)
524566

525-
kwargs = {"api_key": "k_abc", "api_host": API_HOST}
567+
kwargs = {
568+
"credential": CredentialResult(api_key="k_abc", source_name="test"),
569+
"api_host": API_HOST,
570+
}
526571
if backend_kind is not None:
527572
kwargs["backend_kind"] = backend_kind
528573

529574
result = is_cloudsmith_domain(host, **kwargs)
530575
assert result is expected
531576

532577

578+
@pytest.mark.parametrize(
579+
"credential",
580+
[
581+
# no credential at all
582+
None,
583+
# a credential carrying no usable key — truthy as an object, so the
584+
# guard has to look at api_key, not just the credential
585+
CredentialResult(api_key="", source_name="test"),
586+
],
587+
)
588+
@httpretty.activate(allow_net_connect=False)
589+
def test_is_cloudsmith_domain_custom_domain_without_credential(
590+
tmp_path, monkeypatch, credential
591+
):
592+
"""A custom-domain check without a usable credential refuses without an API call."""
593+
monkeypatch.setattr(
594+
"cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path",
595+
lambda: str(tmp_path),
596+
)
597+
monkeypatch.setenv("CLOUDSMITH_ORG", "acme")
598+
599+
called = []
600+
monkeypatch.setattr(
601+
"cloudsmith_cli.credential_helpers.custom_domains.list_custom_domains",
602+
lambda *_a, **_kw: called.append(True) or [],
603+
)
604+
605+
assert (
606+
is_cloudsmith_domain(
607+
"docker.acme.com", credential=credential, api_host=API_HOST
608+
)
609+
is False
610+
)
611+
assert not called, "the custom-domain API must not be queried without a credential"
612+
613+
533614
# ---------------------------------------------------------------------------
534615
# 10. Docker runtime backend_kind wiring
535616
# ---------------------------------------------------------------------------

cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py

Lines changed: 79 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import click.testing
1414
import pytest
1515

16+
from ....core.credentials.models import CredentialResult
1617
from ....credential_helpers.docker.installer import DockerInstaller
1718
from ....credential_helpers.launchers import (
1819
_launcher_content,
@@ -333,7 +334,9 @@ def test_docker_installer_status_type_contract(tmp_path, monkeypatch):
333334
"discovery_on",
334335
"no_discover",
335336
"missing_org",
336-
"missing_api_key",
337+
"missing_credential",
338+
"blank_credential",
339+
"dry_run",
337340
"discovery_raises",
338341
],
339342
)
@@ -347,19 +350,25 @@ def test_autodiscovery(tmp_path, monkeypatch, scenario):
347350
bin_dir = tmp_path / "bin"
348351
monkeypatch.setenv("PATH", str(bin_dir))
349352

353+
credential = CredentialResult(api_key="k_test", source_name="test")
354+
350355
if scenario == "discovery_on":
351-
monkeypatch.setattr(
352-
_INSTALLER_GET_FORMAT_DOMAINS,
353-
lambda *_a, **_kw: ["docker.acme.com"],
354-
)
356+
captured = {}
357+
358+
def _fake_get_format_domains(*_a, **kwargs):
359+
captured.update(kwargs)
360+
return ["docker.acme.com"]
361+
362+
monkeypatch.setattr(_INSTALLER_GET_FORMAT_DOMAINS, _fake_get_format_domains)
355363
installer = DockerInstaller()
356364
actions = installer.install(
357-
bin_dir=str(bin_dir), discover=True, org="acme", api_key="k_test"
365+
bin_dir=str(bin_dir), discover=True, org="acme", credential=credential
358366
)
359367
cfg = json.loads((docker_dir / "config.json").read_text())
360368
assert cfg["credHelpers"]["docker.cloudsmith.io"] == "cloudsmith"
361369
assert cfg["credHelpers"]["docker.acme.com"] == "cloudsmith"
362370
assert any("discovered" in a and "1" in a for a in actions)
371+
assert captured["credential"] is credential
363372

364373
elif scenario == "no_discover":
365374
called = []
@@ -371,14 +380,14 @@ def _should_not_be_called(*_a, **_kw):
371380
monkeypatch.setattr(_INSTALLER_GET_FORMAT_DOMAINS, _should_not_be_called)
372381
installer = DockerInstaller()
373382
installer.install(
374-
bin_dir=str(bin_dir), discover=False, org="acme", api_key="k_test"
383+
bin_dir=str(bin_dir), discover=False, org="acme", credential=credential
375384
)
376385
assert not called, "get_format_domains must not be called when discover=False"
377386
cfg = json.loads((docker_dir / "config.json").read_text())
378387
assert "docker.cloudsmith.io" in cfg["credHelpers"]
379388
assert "docker.acme.com" not in cfg["credHelpers"]
380389

381-
elif scenario in ("missing_org", "missing_api_key"):
390+
elif scenario in ("missing_org", "missing_credential", "blank_credential"):
382391
called = []
383392

384393
def _should_not_be_called(*_a, **_kw):
@@ -388,14 +397,40 @@ def _should_not_be_called(*_a, **_kw):
388397
monkeypatch.setattr(_INSTALLER_GET_FORMAT_DOMAINS, _should_not_be_called)
389398
installer = DockerInstaller()
390399
org = None if scenario == "missing_org" else "acme"
391-
api_key = "k_test" if scenario == "missing_org" else None
392-
installer.install(bin_dir=str(bin_dir), discover=True, org=org, api_key=api_key)
400+
creds = {
401+
"missing_org": credential,
402+
"missing_credential": None,
403+
"blank_credential": CredentialResult(api_key="", source_name="test"),
404+
}
405+
installer.install(
406+
bin_dir=str(bin_dir), discover=True, org=org, credential=creds[scenario]
407+
)
393408
assert (
394409
not called
395-
), "get_format_domains must not be called when org/api_key absent"
410+
), "get_format_domains must not be called when org/credential absent"
396411
cfg = json.loads((docker_dir / "config.json").read_text())
397412
assert cfg["credHelpers"]["docker.cloudsmith.io"] == "cloudsmith"
398413

414+
elif scenario == "dry_run":
415+
called = []
416+
417+
def _should_not_be_called(*_a, **_kw):
418+
called.append(True)
419+
return []
420+
421+
monkeypatch.setattr(_INSTALLER_GET_FORMAT_DOMAINS, _should_not_be_called)
422+
installer = DockerInstaller()
423+
actions = installer.install(
424+
bin_dir=str(bin_dir),
425+
discover=True,
426+
org="acme",
427+
credential=credential,
428+
dry_run=True,
429+
)
430+
assert not called, "a dry run must not query the custom-domain API"
431+
assert not (docker_dir / "config.json").exists()
432+
assert any("skipped custom-domain auto-discovery" in a for a in actions)
433+
399434
else: # discovery_raises — graceful failure guard
400435

401436
def _raise(*_a, **_kw):
@@ -405,7 +440,7 @@ def _raise(*_a, **_kw):
405440
installer = DockerInstaller()
406441
# Must NOT raise
407442
actions = installer.install(
408-
bin_dir=str(bin_dir), discover=True, org="acme", api_key="k_test"
443+
bin_dir=str(bin_dir), discover=True, org="acme", credential=credential
409444
)
410445
cfg = json.loads((docker_dir / "config.json").read_text())
411446
assert cfg["credHelpers"]["docker.cloudsmith.io"] == "cloudsmith"
@@ -464,7 +499,11 @@ def _fake_list(*_a, **_kw):
464499
"cloudsmith_cli.credential_helpers.custom_domains.list_custom_domains",
465500
_fake_list,
466501
):
467-
result = get_custom_domains("acme", api_key="k", refresh=refresh)
502+
result = get_custom_domains(
503+
"acme",
504+
credential=CredentialResult(api_key="k", source_name="test"),
505+
refresh=refresh,
506+
)
468507

469508
if refresh:
470509
# API must have been called
@@ -512,6 +551,33 @@ def test_manage_cli_dry_run_exits_0(runner, tmp_path, monkeypatch):
512551
assert "would" in result.output.lower() or "dry run" in result.output.lower()
513552

514553

554+
def test_manage_cli_passes_resolved_credential_to_installer(
555+
runner, tmp_path, monkeypatch
556+
):
557+
"""install hands the resolved CredentialResult to the installer intact."""
558+
monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker"))
559+
560+
from ....cli.commands.credential_helper.manage import install_cmd
561+
562+
with patch.object(DockerInstaller, "install", return_value=[]) as mock_install:
563+
result = runner.invoke(
564+
install_cmd,
565+
[
566+
"docker",
567+
"--no-discover",
568+
"--bin-dir",
569+
str(tmp_path / "bin"),
570+
"--api-key",
571+
"k_flag",
572+
],
573+
)
574+
575+
assert result.exit_code == 0, result.output
576+
credential = mock_install.call_args.kwargs["credential"]
577+
assert isinstance(credential, CredentialResult)
578+
assert credential.api_key == "k_flag"
579+
580+
515581
# ---------------------------------------------------------------------------
516582
# 14. PATH warning
517583
# ---------------------------------------------------------------------------

cloudsmith_cli/core/api/init.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ def initialise_api(
4343

4444
if credential:
4545
if credential.auth_type == "bearer":
46+
# set_default() makes api_key sticky across calls, so an X-Api-Key
47+
# left by an earlier credential would ride along with the bearer token.
48+
config.api_key.pop("X-Api-Key", None)
4649
config.headers["Authorization"] = f"Bearer {credential.api_key}"
4750
if config.debug:
4851
click.echo("SSO access token config value set")

0 commit comments

Comments
 (0)