Skip to content

Commit 023b333

Browse files
BartoszBlizniakcloudsmith-iduffyCopilot
authored
refactor: change authentication order and seperate out existing crede… (#296)
* feat: add credential provider chain concept * fix: review feedback * fix: metadata test * fix: claude local review * refactor: change authentication order and seperate out existing credential providers * fix: test * fix: decorator copilot feedback * fix: move to top level import --------- Co-authored-by: Ian Duffy <iduffy@cloudsmith.io> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 2c30f96 commit 023b333

14 files changed

Lines changed: 385 additions & 20 deletions

cloudsmith_cli/cli/config.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,33 @@ def api_key(self, value):
319319
"""Set value for API key."""
320320
self._set_option("api_key", value)
321321

322+
@property
323+
def api_key_from_flag(self):
324+
"""Get API key set explicitly via --api-key CLI flag."""
325+
return self._get_option("api_key_from_flag")
326+
327+
@api_key_from_flag.setter
328+
def api_key_from_flag(self, value):
329+
self._set_option("api_key_from_flag", value, allow_clear=True)
330+
331+
@property
332+
def api_key_from_env(self):
333+
"""Get API key from CLOUDSMITH_API_KEY environment variable."""
334+
return self._get_option("api_key_from_env")
335+
336+
@api_key_from_env.setter
337+
def api_key_from_env(self, value):
338+
self._set_option("api_key_from_env", value, allow_clear=True)
339+
340+
@property
341+
def api_key_from_file(self):
342+
"""Get API key loaded from credentials.ini."""
343+
return self._get_option("api_key_from_file")
344+
345+
@api_key_from_file.setter
346+
def api_key_from_file(self, value):
347+
self._set_option("api_key_from_file", value, allow_clear=True)
348+
322349
@property
323350
def api_proxy(self):
324351
"""Get value for API proxy."""

cloudsmith_cli/cli/decorators.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import functools
44

55
import click
6+
from click.core import ParameterSource
67

78
from cloudsmith_cli.cli import validators
89

@@ -116,6 +117,7 @@ def wrapper(ctx, *args, **kwargs):
116117

117118
opts.load_config_file(path=config_file, profile=profile)
118119
opts.load_creds_file(path=creds_file, profile=profile)
120+
opts.api_key_from_file = opts.api_key
119121
kwargs["opts"] = opts
120122
return ctx.invoke(f, *args, **kwargs)
121123

@@ -226,6 +228,20 @@ def wrapper(ctx, *args, **kwargs):
226228
# pylint: disable=missing-docstring
227229
opts = config.get_or_create_options(ctx)
228230
api_key = kwargs.pop("api_key")
231+
232+
source = ctx.get_parameter_source("api_key")
233+
api_key_nonempty = api_key and api_key.strip()
234+
if source == ParameterSource.COMMANDLINE and api_key_nonempty:
235+
opts.api_key_from_flag = api_key
236+
opts.api_key_from_env = None
237+
elif source == ParameterSource.ENVIRONMENT and api_key_nonempty:
238+
opts.api_key_from_flag = None
239+
opts.api_key_from_env = api_key
240+
else:
241+
opts.api_key_from_flag = None
242+
opts.api_key_from_env = None
243+
244+
# Keep opts.api_key populated for any code that still reads it directly.
229245
if api_key:
230246
opts.api_key = api_key
231247
kwargs["opts"] = opts
@@ -302,7 +318,9 @@ def wrapper(ctx, *args, **kwargs):
302318

303319
context = CredentialContext(
304320
session=opts.session,
305-
api_key=opts.api_key,
321+
api_key_from_flag=opts.api_key_from_flag,
322+
api_key_from_env=opts.api_key_from_env,
323+
api_key_from_file=opts.api_key_from_file,
306324
api_host=opts.api_host or "https://api.cloudsmith.io",
307325
creds_file_path=ctx.meta.get("creds_file"),
308326
profile=ctx.meta.get("profile"),

cloudsmith_cli/core/credentials/chain.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@
1010

1111
from .models import CredentialContext, CredentialResult
1212
from .provider import CredentialProvider
13+
from .providers import (
14+
CLIFlagProvider,
15+
CredentialsFileProvider,
16+
EnvVarProvider,
17+
KeyringProvider,
18+
)
1319

1420
logger = logging.getLogger(__name__)
1521

@@ -18,18 +24,18 @@ class CredentialProviderChain:
1824
"""Evaluates credential providers in order, returning the first valid result.
1925
2026
If no providers are given, uses the default chain:
21-
KeyringCLIFlag.
27+
CLIFlagEnvVar → CredentialsFile → Keyring.
2228
"""
2329

2430
def __init__(self, providers: list[CredentialProvider] | None = None):
2531
if providers is not None:
2632
self.providers = providers
2733
else:
28-
from .providers import CLIFlagProvider, KeyringProvider
29-
3034
self.providers = [
31-
KeyringProvider(),
3235
CLIFlagProvider(),
36+
EnvVarProvider(),
37+
CredentialsFileProvider(),
38+
KeyringProvider(),
3339
]
3440

3541
def resolve(self, context: CredentialContext) -> CredentialResult | None:

cloudsmith_cli/core/credentials/models.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,15 @@
1212
class CredentialContext:
1313
"""Context passed to credential providers during resolution.
1414
15-
All values are populated directly from Click options / ``opts``.
15+
Separate per-source fields allow the chain to evaluate sources in priority
16+
order without conflating them. Populated from Click options in
17+
``resolve_credentials``.
1618
"""
1719

1820
session: requests.Session | None = None
19-
api_key: str | None = None
21+
api_key_from_flag: str | None = None
22+
api_key_from_env: str | None = None
23+
api_key_from_file: str | None = None
2024
api_host: str = "https://api.cloudsmith.io"
2125
creds_file_path: str | None = None
2226
profile: str | None = None
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
"""Credential providers for the Cloudsmith CLI."""
22

33
from .cli_flag import CLIFlagProvider
4+
from .credentials_file import CredentialsFileProvider
5+
from .env_var import EnvVarProvider
46
from .keyring_provider import KeyringProvider
57

68
__all__ = [
79
"CLIFlagProvider",
10+
"CredentialsFileProvider",
11+
"EnvVarProvider",
812
"KeyringProvider",
913
]

cloudsmith_cli/core/credentials/providers/cli_flag.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,18 @@
77

88

99
class CLIFlagProvider(CredentialProvider):
10-
"""Resolves credentials from a CLI flag value passed via CredentialContext."""
10+
"""Resolves credentials from the --api-key CLI flag."""
1111

1212
name = "cli_flag"
1313

1414
def resolve(self, context: CredentialContext) -> CredentialResult | None:
15-
api_key = context.api_key
15+
api_key = context.api_key_from_flag
1616
if api_key and api_key.strip():
17-
suffix = api_key.strip()[-4:]
17+
api_key = api_key.strip()
18+
suffix = api_key[-4:]
1819
return CredentialResult(
19-
api_key=api_key.strip(),
20+
api_key=api_key,
2021
source_name="cli_flag",
21-
source_detail=f"key resolved via CLI options (ends with ...{suffix})",
22+
source_detail=f"--api-key flag (ends with ...{suffix})",
2223
)
2324
return None
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Credentials file provider."""
2+
3+
from __future__ import annotations
4+
5+
from ..models import CredentialContext, CredentialResult
6+
from ..provider import CredentialProvider
7+
8+
9+
class CredentialsFileProvider(CredentialProvider):
10+
"""Resolves credentials from the api_key stored in credentials.ini."""
11+
12+
name = "credentials_file"
13+
14+
def resolve(self, context: CredentialContext) -> CredentialResult | None:
15+
api_key = context.api_key_from_file
16+
if api_key and api_key.strip():
17+
api_key = api_key.strip()
18+
suffix = api_key[-4:]
19+
return CredentialResult(
20+
api_key=api_key,
21+
source_name="credentials_file",
22+
source_detail=f"credentials.ini (ends with ...{suffix})",
23+
)
24+
return None
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Environment variable credential provider."""
2+
3+
from __future__ import annotations
4+
5+
from ..models import CredentialContext, CredentialResult
6+
from ..provider import CredentialProvider
7+
8+
9+
class EnvVarProvider(CredentialProvider):
10+
"""Resolves credentials from the CLOUDSMITH_API_KEY environment variable."""
11+
12+
name = "env_var"
13+
14+
def resolve(self, context: CredentialContext) -> CredentialResult | None:
15+
api_key = context.api_key_from_env
16+
if api_key and api_key.strip():
17+
api_key = api_key.strip()
18+
suffix = api_key[-4:]
19+
return CredentialResult(
20+
api_key=api_key,
21+
source_name="env_var",
22+
source_detail=f"CLOUDSMITH_API_KEY env var (ends with ...{suffix})",
23+
)
24+
return None

cloudsmith_cli/core/tests/test_cli_flag_provider.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,30 +5,42 @@
55

66

77
class TestCLIFlagProvider:
8-
def test_resolves_from_context(self):
8+
def test_resolves_from_flag(self):
99
provider = CLIFlagProvider()
10-
context = CredentialContext(api_key="my-api-key-1234")
10+
context = CredentialContext(api_key_from_flag="my-api-key-1234")
1111
result = provider.resolve(context)
1212
assert result is not None
1313
assert result.api_key == "my-api-key-1234"
1414
assert result.source_name == "cli_flag"
1515
assert result.auth_type == "api_key"
1616
assert "1234" in result.source_detail
17+
assert "--api-key" in result.source_detail
1718

1819
def test_returns_none_when_not_set(self):
1920
provider = CLIFlagProvider()
20-
context = CredentialContext(api_key=None)
21+
context = CredentialContext(api_key_from_flag=None)
2122
result = provider.resolve(context)
2223
assert result is None
2324

2425
def test_returns_none_for_empty_value(self):
2526
provider = CLIFlagProvider()
26-
context = CredentialContext(api_key=" ")
27+
context = CredentialContext(api_key_from_flag=" ")
2728
result = provider.resolve(context)
2829
assert result is None
2930

3031
def test_strips_whitespace(self):
3132
provider = CLIFlagProvider()
32-
context = CredentialContext(api_key=" my-key ")
33+
context = CredentialContext(api_key_from_flag=" my-key ")
3334
result = provider.resolve(context)
3435
assert result.api_key == "my-key"
36+
37+
def test_ignores_env_and_file_keys(self):
38+
"""CLIFlagProvider must not resolve keys from other sources."""
39+
provider = CLIFlagProvider()
40+
context = CredentialContext(
41+
api_key_from_flag=None,
42+
api_key_from_env="env-key",
43+
api_key_from_file="file-key",
44+
)
45+
result = provider.resolve(context)
46+
assert result is None

0 commit comments

Comments
 (0)