Skip to content

Commit fa8c7bf

Browse files
feat: add credential provider chain concept
1 parent 36ca1c8 commit fa8c7bf

16 files changed

Lines changed: 630 additions & 379 deletions

cloudsmith_cli/cli/decorators.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from cloudsmith_cli.cli import validators
88

99
from ..core.api.init import initialise_api as _initialise_api
10+
from ..core.credentials import CredentialContext, CredentialProviderChain
1011
from ..core.mcp import server
1112
from . import config, utils
1213

@@ -314,13 +315,51 @@ def _set_boolean(name, invert=False):
314315
opts.error_retry_codes = kwargs.pop("error_retry_codes")
315316
opts.error_retry_cb = report_retry
316317

318+
credential_context = CredentialContext(
319+
api_host=opts.api_host or "https://api.cloudsmith.io",
320+
creds_file_path=ctx.meta.get("creds_file"),
321+
profile=ctx.meta.get("profile"),
322+
debug=opts.debug,
323+
cli_api_key=opts.api_key,
324+
proxy=opts.api_proxy,
325+
ssl_verify=opts.api_ssl_verify if opts.api_ssl_verify is not None else True,
326+
user_agent=opts.api_user_agent,
327+
headers=opts.api_headers,
328+
)
329+
330+
chain = CredentialProviderChain()
331+
332+
credential = chain.resolve(credential_context)
333+
334+
if credential_context.keyring_refresh_failed:
335+
click.secho(
336+
"An error occurred when attempting to refresh your SSO access token. "
337+
"To refresh this session, run 'cloudsmith auth'",
338+
fg="yellow",
339+
err=True,
340+
)
341+
if credential:
342+
click.secho(
343+
"Falling back to API key authentication.",
344+
fg="yellow",
345+
err=True,
346+
)
347+
348+
resolved_key = None
349+
resolved_access_token = None
350+
if credential:
351+
if credential.auth_type == "bearer":
352+
resolved_access_token = credential.api_key
353+
else:
354+
resolved_key = credential.api_key
355+
317356
def call_print_rate_limit_info_with_opts(rate_info):
318357
utils.print_rate_limit_info(opts, rate_info)
319358

320359
opts.api_config = _initialise_api(
321360
debug=opts.debug,
322361
host=opts.api_host,
323-
key=opts.api_key,
362+
key=resolved_key,
324363
proxy=opts.api_proxy,
325364
ssl_verify=opts.api_ssl_verify,
326365
user_agent=opts.api_user_agent,
@@ -331,6 +370,7 @@ def call_print_rate_limit_info_with_opts(rate_info):
331370
error_retry_backoff=opts.error_retry_backoff,
332371
error_retry_codes=opts.error_retry_codes,
333372
error_retry_cb=opts.error_retry_cb,
373+
access_token=resolved_access_token,
334374
)
335375

336376
kwargs["opts"] = opts

cloudsmith_cli/core/api/init.py

Lines changed: 5 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,7 @@
66
import click
77
import cloudsmith_api
88

9-
from ...cli import saml
10-
from .. import keyring
119
from ..rest import RestClient
12-
from .exceptions import ApiException
1310

1411

1512
def initialise_api(
@@ -45,63 +42,14 @@ def initialise_api(
4542
config.verify_ssl = ssl_verify
4643
config.client_side_validation = False
4744

48-
# Use directly provided access token (e.g. from SSO callback),
49-
# or fall back to keyring lookup if enabled.
50-
if not access_token:
51-
access_token = keyring.get_access_token(config.host)
52-
5345
if access_token:
54-
auth_header = config.headers.get("Authorization")
55-
56-
# overwrite auth header if empty or is basic auth without username or password
57-
if not auth_header or auth_header == config.get_basic_auth_token():
58-
refresh_token = keyring.get_refresh_token(config.host)
59-
60-
try:
61-
if keyring.should_refresh_access_token(config.host):
62-
new_access_token, new_refresh_token = saml.refresh_access_token(
63-
config.host,
64-
access_token,
65-
refresh_token,
66-
session=saml.create_configured_session(config),
67-
)
68-
keyring.store_sso_tokens(
69-
config.host, new_access_token, new_refresh_token
70-
)
71-
# Use the new tokens
72-
access_token = new_access_token
73-
except ApiException:
74-
keyring.update_refresh_attempted_at(config.host)
75-
76-
click.secho(
77-
"An error occurred when attempting to refresh your SSO access token. To refresh this session, run 'cloudsmith auth'",
78-
fg="yellow",
79-
err=True,
80-
)
81-
82-
# Clear access_token to prevent using expired token
83-
access_token = None
84-
85-
# Fall back to API key auth if available
86-
if key:
87-
click.secho(
88-
"Falling back to API key authentication.",
89-
fg="yellow",
90-
err=True,
91-
)
92-
config.api_key["X-Api-Key"] = key
93-
94-
# Only use SSO token if refresh didn't fail
95-
if access_token:
96-
config.headers["Authorization"] = "Bearer {access_token}".format(
97-
access_token=access_token
98-
)
99-
100-
if config.debug:
101-
click.echo("SSO access token config value set")
46+
config.headers["Authorization"] = "Bearer {access_token}".format(
47+
access_token=access_token
48+
)
49+
if config.debug:
50+
click.echo("SSO access token config value set")
10251
elif key:
10352
config.api_key["X-Api-Key"] = key
104-
10553
if config.debug:
10654
click.echo("User API key config value set")
10755

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""Credential Provider Chain for Cloudsmith CLI.
2+
3+
Implements an AWS SDK-style credential resolution chain that evaluates
4+
credential sources sequentially and returns the first valid result.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import logging
10+
from dataclasses import dataclass, field
11+
from typing import Optional
12+
13+
logger = logging.getLogger(__name__)
14+
15+
16+
@dataclass
17+
class CredentialContext: # pylint: disable=too-many-instance-attributes
18+
"""Context passed to credential providers during resolution."""
19+
20+
api_host: str = "https://api.cloudsmith.io"
21+
config_file_path: str | None = None
22+
creds_file_path: str | None = None
23+
profile: str | None = None
24+
debug: bool = False
25+
# Pre-resolved values from CLI flags (highest priority)
26+
cli_api_key: str | None = None
27+
# API networking configuration
28+
proxy: str | None = None
29+
ssl_verify: bool = True
30+
user_agent: str | None = None
31+
headers: dict | None = None
32+
keyring_refresh_failed: bool = False
33+
34+
35+
@dataclass
36+
class CredentialResult:
37+
"""Result from a successful credential resolution."""
38+
39+
api_key: str
40+
source_name: str
41+
source_detail: str | None = None
42+
auth_type: str = "api_key"
43+
44+
45+
class CredentialProvider:
46+
"""Base class for credential providers."""
47+
48+
name: str = "base"
49+
50+
def resolve(self, context: CredentialContext) -> CredentialResult | None:
51+
"""Attempt to resolve credentials. Return CredentialResult or None."""
52+
raise NotImplementedError
53+
54+
55+
class CredentialProviderChain:
56+
"""Evaluates credential providers in order, returning the first valid result.
57+
58+
If no providers are given, uses the default chain:
59+
Keyring → CLIFlag → EnvironmentVariable → ConfigFile.
60+
"""
61+
62+
def __init__(self, providers: list[CredentialProvider] | None = None):
63+
if providers is not None:
64+
self.providers = providers
65+
else:
66+
from .providers import (
67+
CLIFlagProvider,
68+
ConfigFileProvider,
69+
EnvironmentVariableProvider,
70+
KeyringProvider,
71+
)
72+
73+
self.providers = [
74+
KeyringProvider(),
75+
CLIFlagProvider(),
76+
EnvironmentVariableProvider(),
77+
ConfigFileProvider(),
78+
]
79+
80+
def resolve(self, context: CredentialContext) -> CredentialResult | None:
81+
"""Evaluate each provider in order. Return the first successful result."""
82+
for provider in self.providers:
83+
try:
84+
result = provider.resolve(context)
85+
if result is not None:
86+
if context.debug:
87+
logger.debug(
88+
"Credentials resolved by %s: %s",
89+
provider.name,
90+
result.source_detail or result.source_name,
91+
)
92+
return result
93+
if context.debug:
94+
logger.debug(
95+
"Provider %s did not resolve credentials, trying next",
96+
provider.name,
97+
)
98+
except Exception: # pylint: disable=broad-exception-caught
99+
# Intentionally broad - one provider failing shouldn't stop others
100+
logger.debug(
101+
"Provider %s raised an exception, skipping",
102+
provider.name,
103+
exc_info=True,
104+
)
105+
continue
106+
return None
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Credential providers for the Cloudsmith CLI."""
2+
3+
from .cli_flag import CLIFlagProvider
4+
from .config_file import ConfigFileProvider
5+
from .environment_variable import EnvironmentVariableProvider
6+
from .keyring_provider import KeyringProvider
7+
8+
__all__ = [
9+
"CLIFlagProvider",
10+
"ConfigFileProvider",
11+
"EnvironmentVariableProvider",
12+
"KeyringProvider",
13+
]
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""CLI flag credential provider."""
2+
3+
from __future__ import annotations
4+
5+
from .. import CredentialContext, CredentialProvider, CredentialResult
6+
7+
8+
class CLIFlagProvider(CredentialProvider):
9+
"""Resolves credentials from a CLI flag value passed via CredentialContext."""
10+
11+
name = "cli_flag"
12+
13+
def resolve(self, context: CredentialContext) -> CredentialResult | None:
14+
api_key = context.cli_api_key
15+
if api_key and api_key.strip():
16+
suffix = api_key.strip()[-4:]
17+
return CredentialResult(
18+
api_key=api_key.strip(),
19+
source_name="cli_flag",
20+
source_detail=f"--api-key flag or CLOUDSMITH_API_KEY (ends with ...{suffix})",
21+
)
22+
return None
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Config file credential provider."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
import os
7+
8+
from .. import CredentialContext, CredentialProvider, CredentialResult
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
class ConfigFileProvider(CredentialProvider):
14+
"""Resolves credentials from the credentials.ini config file."""
15+
16+
name = "config_file"
17+
18+
def resolve(self, context: CredentialContext) -> CredentialResult | None:
19+
from ....cli.config import CredentialsReader
20+
21+
reader = CredentialsReader
22+
path = context.creds_file_path
23+
24+
try:
25+
config = {}
26+
if path and os.path.exists(path):
27+
if os.path.isdir(path):
28+
reader.config_searchpath.insert(0, path)
29+
else:
30+
reader.config_files.insert(0, path)
31+
32+
raw_config = reader.read_config()
33+
values = raw_config.get("default", {})
34+
config.update(values)
35+
36+
if context.profile and context.profile != "default":
37+
profile_values = raw_config.get(f"profile:{context.profile}", {})
38+
config.update(profile_values)
39+
40+
api_key = config.get("api_key")
41+
if api_key and isinstance(api_key, str):
42+
api_key = api_key.strip().strip("'\"")
43+
if api_key:
44+
source_files = reader.find_existing_files()
45+
source = source_files[0] if source_files else "credentials.ini"
46+
return CredentialResult(
47+
api_key=api_key,
48+
source_name="config_file",
49+
source_detail=f"credentials.ini ({source})",
50+
)
51+
except Exception: # pylint: disable=broad-exception-caught
52+
# Config file errors can be varied (permissions, parse errors, etc.)
53+
logger.debug("ConfigFileProvider failed to read config", exc_info=True)
54+
55+
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+
import os
6+
7+
from .. import CredentialContext, CredentialProvider, CredentialResult
8+
9+
10+
class EnvironmentVariableProvider(CredentialProvider):
11+
"""Resolves credentials from the CLOUDSMITH_API_KEY environment variable."""
12+
13+
name = "environment_variable"
14+
15+
def resolve(self, context: CredentialContext) -> CredentialResult | None:
16+
api_key = os.environ.get("CLOUDSMITH_API_KEY")
17+
if api_key and api_key.strip():
18+
suffix = api_key.strip()[-4:]
19+
return CredentialResult(
20+
api_key=api_key.strip(),
21+
source_name="environment_variable",
22+
source_detail=f"CLOUDSMITH_API_KEY env var (ends with ...{suffix})",
23+
)
24+
return None

0 commit comments

Comments
 (0)