Skip to content

Commit 6ae4271

Browse files
feat: add OIDC detector enable/order controls (#311)
* feat: add OIDC detector enable/order controls Add two controls for OIDC detector selection, resolved through the credential context rather than read ad hoc from the environment: - CLOUDSMITH_OIDC_<ID>_DISABLED skips a specific detector (only the literal "true", case-insensitive, disables). The credentials decorator resolves these into context.oidc_disabled_detectors. - --oidc-detector-order (env var CLOUDSMITH_OIDC_DETECTOR_ORDER) overrides which detectors are considered and the order they are tried in (comma-separated ids; unlisted/unknown ids are skipped). When both are set the order list defines the candidate set and sequence, then the disabled set is applied on top, so a disabled detector is always skipped. Each detector gains a stable `id` attribute and a public `registered_detectors()` accessor is added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: deduplicate ids in --oidc-detector-order Duplicate detector ids in the order list previously ran the same detector once per occurrence, which is wasteful for detectors that hit metadata endpoints (e.g. AWS STS/IMDS). Duplicates now keep their first position so each detector is evaluated at most once. Also log at debug when the order/disable controls leave no detectors enabled, so an order string with no usable ids is diagnosable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: support OIDC detector controls in config file + warn on misconfig Address PR #311 review feedback: - Allow oidc_detector_order and oidc_disabled_detectors to be set in config.ini (under [default] or a profile). The config disabled list is additive with the per-detector CLOUDSMITH_OIDC_<ID>_DISABLED env vars; the --oidc-detector-order flag / env var still override the config order. - Surface a warning (in the CLI layer, where click lives) when --oidc-detector-order names unknown ids, or when the order/disable controls leave no detector enabled. Advisory only: the credential fallback chain is preserved rather than aborting with a UsageError, and the core detectors module stays click-free. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 792d9d6 commit 6ae4271

16 files changed

Lines changed: 418 additions & 1 deletion

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1616
- Added GitHub Actions to OIDC credential auto-discovery. When running in GitHub Actions (with `id-token: write` permission), the CLI fetches an OIDC token from the Actions runtime endpoint and exchanges it for a Cloudsmith access token. Works out of the box with no extra dependencies.
1717
- Added a generic fallback to OIDC credential auto-discovery. When no dedicated environment is detected, the CLI reads an OIDC token from the `CLOUDSMITH_OIDC_TOKEN` environment variable (useful for Jenkins or any custom CI/CD) and exchanges it for a Cloudsmith access token. Works out of the box with no extra dependencies.
1818
- Added GitLab CI to OIDC credential auto-discovery. When running in GitLab CI/CD, the CLI reads the OIDC token from the `CLOUDSMITH_OIDC_TOKEN` environment variable (configured via `id_tokens` in `.gitlab-ci.yml`) and exchanges it for a Cloudsmith access token. Works out of the box with no extra dependencies.
19+
- Added controls for OIDC detector selection. Set `CLOUDSMITH_OIDC_<DETECTOR>_DISABLED=true` to skip a specific detector (only the literal `true` disables), or use `--oidc-detector-order` (env var `CLOUDSMITH_OIDC_DETECTOR_ORDER`) with a comma-separated list of detector ids to override which detectors are considered and the order they are tried in. When both are set, disable flags take precedence over the order list. Both controls can also be set in `config.ini` via the `oidc_detector_order` and `oidc_disabled_detectors` keys (the latter additive with the `*_DISABLED` env vars). Unknown ids in the order, or controls that leave no detector enabled, are surfaced as a warning. Detector ids: `aws`, `azure_devops`, `bitbucket`, `circleci`, `generic`, `github`, `gitlab`.
1920

2021
## [1.18.0] - 2026-06-09
2122

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,25 @@ See the [Cloudsmith GitLab CI/CD integration guide](https://docs.cloudsmith.com/
211211

212212
As a fallback for environments without a dedicated detector (for example Jenkins with the [credentials binding plugin](https://plugins.jenkins.io/credentials-binding/), or any custom CI/CD system), set the `CLOUDSMITH_OIDC_TOKEN` environment variable to an OIDC JWT and the CLI will exchange it for a Cloudsmith access token. This detector runs last, so a dedicated environment is always preferred when present. See the [Cloudsmith Jenkins OIDC guide](https://docs.cloudsmith.com/authentication/setup-jenkins-to-authenticate-to-cloudsmith-using-oidc).
213213

214+
#### Controlling OIDC Detector Selection
215+
216+
By default the CLI tries each detector in a fixed priority order and uses the first that matches. Two controls let you override this when a detector matches an environment you don't want it to (for example, the AWS detector matching ambient instance credentials):
217+
218+
- **Disable a detector** — set `CLOUDSMITH_OIDC_<DETECTOR>_DISABLED=true` to skip it entirely. Only the literal value `true` (case-insensitive) disables; anything else leaves the detector enabled. For example, `CLOUDSMITH_OIDC_AWS_DISABLED=true` skips the AWS detector so an explicitly-set `CLOUDSMITH_OIDC_TOKEN` is picked up by the generic detector instead.
219+
- **Reorder evaluation** — use `--oidc-detector-order` (or the `CLOUDSMITH_OIDC_DETECTOR_ORDER` environment variable) with a comma-separated list of detector ids to control both which detectors are considered and the order they are tried in (first match wins). Ids not listed are skipped; unrecognised ids are ignored with a warning. For example, `--oidc-detector-order=generic,aws` tries the generic detector first and considers only those two.
220+
221+
When both are set, the order list defines the candidate set and sequence, then the `*_DISABLED` flags are applied on top — so a disabled detector is always skipped even if it appears in the order list. Detector ids are: `aws`, `azure_devops`, `bitbucket`, `circleci`, `generic`, `github`, `gitlab`.
222+
223+
Both controls can also be set in `config.ini`, under `[default]` or a profile section:
224+
225+
```ini
226+
[default]
227+
oidc_detector_order = github, aws
228+
oidc_disabled_detectors = aws, gitlab
229+
```
230+
231+
The `--oidc-detector-order` flag (or the `CLOUDSMITH_OIDC_DETECTOR_ORDER` environment variable) overrides the `oidc_detector_order` config value. The `oidc_disabled_detectors` config key is additive with the per-detector `CLOUDSMITH_OIDC_<DETECTOR>_DISABLED` environment variables — a detector disabled by either is skipped.
232+
214233
## Configuration
215234

216235
There are two configuration files used by the CLI:

cloudsmith_cli/cli/config.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ class Default(SectionSchema):
6969
oidc_audience = ConfigParam(name="oidc_audience", type=str)
7070
oidc_org = ConfigParam(name="oidc_org", type=str)
7171
oidc_service_slug = ConfigParam(name="oidc_service_slug", type=str)
72+
oidc_detector_order = ConfigParam(name="oidc_detector_order", type=str)
73+
oidc_disabled_detectors = ConfigParam(name="oidc_disabled_detectors", type=str)
7274
metadata_failure_mode = ConfigParam(name="metadata_failure_mode", type=str)
7375

7476
@matches_section("profile:*")
@@ -489,6 +491,26 @@ def oidc_discovery_disabled(self, value):
489491
"oidc_discovery_disabled", bool(value) if value is not None else False
490492
)
491493

494+
@property
495+
def oidc_detector_order(self):
496+
"""Get value for the OIDC detector evaluation order."""
497+
return self._get_option("oidc_detector_order")
498+
499+
@oidc_detector_order.setter
500+
def oidc_detector_order(self, value):
501+
"""Set value for the OIDC detector evaluation order."""
502+
self._set_option("oidc_detector_order", value)
503+
504+
@property
505+
def oidc_disabled_detectors(self):
506+
"""Get the comma-separated OIDC detector ids disabled via config."""
507+
return self._get_option("oidc_disabled_detectors")
508+
509+
@oidc_disabled_detectors.setter
510+
def oidc_disabled_detectors(self, value):
511+
"""Set the comma-separated OIDC detector ids disabled via config."""
512+
self._set_option("oidc_disabled_detectors", value)
513+
492514
@property
493515
def metadata_failure_mode(self):
494516
"""Get value for push-time metadata failure mode."""

cloudsmith_cli/cli/decorators.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""CLI - Decorators."""
22

33
import functools
4+
import os
45

56
import click
67
from click.core import ParameterSource
@@ -10,6 +11,10 @@
1011
from ..core.api.init import initialise_api as _initialise_api
1112
from ..core.credentials.chain import CredentialProviderChain
1213
from ..core.credentials.models import CredentialContext
14+
from ..core.credentials.oidc.detectors import (
15+
disabled_detectors_from_env,
16+
registered_detectors,
17+
)
1318
from ..core.mcp import server
1419
from ..core.rest import create_requests_session as _create_session
1520
from . import config, utils
@@ -307,6 +312,50 @@ def wrapper(ctx, *args, **kwargs):
307312
return wrapper
308313

309314

315+
def _parse_detector_ids(value):
316+
"""Split a comma-separated detector id string into stripped, lowercased ids."""
317+
return [
318+
token.strip().lower() for token in (value or "").split(",") if token.strip()
319+
]
320+
321+
322+
def _config_disabled_detectors(value):
323+
"""Detector ids disabled via the config-file ``oidc_disabled_detectors`` key."""
324+
return frozenset(_parse_detector_ids(value))
325+
326+
327+
def _warn_on_oidc_detector_controls(order, disabled):
328+
"""Warn when the OIDC detector order/disable controls look misconfigured.
329+
330+
Advisory only — detection itself is resolved in the core detectors module;
331+
these warnings surface user-facing mistakes (a typo'd id, or controls that
332+
leave nothing enabled) without aborting the credential fallback chain.
333+
"""
334+
valid_ids = [detector_cls.id for detector_cls in registered_detectors()]
335+
order_ids = _parse_detector_ids(order)
336+
337+
unknown_ids = [
338+
identifier for identifier in order_ids if identifier not in valid_ids
339+
]
340+
if unknown_ids:
341+
click.secho(
342+
"OIDC: ignoring unknown detector id(s) in --oidc-detector-order: "
343+
f"{', '.join(unknown_ids)}. Valid ids: {', '.join(valid_ids)}.",
344+
fg="yellow",
345+
err=True,
346+
)
347+
348+
candidate_ids = [i for i in order_ids if i in valid_ids] if order_ids else valid_ids
349+
enabled_ids = [i for i in candidate_ids if i not in disabled]
350+
if (order_ids or disabled) and not enabled_ids:
351+
click.secho(
352+
"OIDC: no detectors are enabled after applying the detector "
353+
"order/disable controls; OIDC auto-discovery will be skipped.",
354+
fg="yellow",
355+
err=True,
356+
)
357+
358+
310359
def resolve_credentials(f):
311360
"""Resolve credentials via the provider chain. Depends on initialise_session."""
312361

@@ -332,6 +381,12 @@ def resolve_credentials(f):
332381
envvar="CLOUDSMITH_OIDC_DISCOVERY_DISABLED",
333382
help="Disable OIDC auto-discovery.",
334383
)
384+
@click.option(
385+
"--oidc-detector-order",
386+
envvar="CLOUDSMITH_OIDC_DETECTOR_ORDER",
387+
help="Comma-separated OIDC detector ids to control which detectors "
388+
"are considered and the order they are tried in.",
389+
)
335390
@click.pass_context
336391
@functools.wraps(f)
337392
def wrapper(ctx, *args, **kwargs):
@@ -342,6 +397,7 @@ def wrapper(ctx, *args, **kwargs):
342397
oidc_org = kwargs.pop("oidc_org")
343398
oidc_service_slug = kwargs.pop("oidc_service_slug")
344399
oidc_discovery_disabled = _pop_boolean_flag(kwargs, "oidc_discovery_disabled")
400+
oidc_detector_order = kwargs.pop("oidc_detector_order")
345401

346402
if oidc_audience:
347403
opts.oidc_audience = oidc_audience
@@ -351,6 +407,15 @@ def wrapper(ctx, *args, **kwargs):
351407
opts.oidc_service_slug = oidc_service_slug
352408
if oidc_discovery_disabled:
353409
opts.oidc_discovery_disabled = oidc_discovery_disabled
410+
if oidc_detector_order:
411+
opts.oidc_detector_order = oidc_detector_order
412+
413+
oidc_disabled_detectors = disabled_detectors_from_env(
414+
os.environ
415+
) | _config_disabled_detectors(opts.oidc_disabled_detectors)
416+
_warn_on_oidc_detector_controls(
417+
opts.oidc_detector_order, oidc_disabled_detectors
418+
)
354419

355420
context = CredentialContext(
356421
session=opts.session,
@@ -365,6 +430,8 @@ def wrapper(ctx, *args, **kwargs):
365430
oidc_org=opts.oidc_org,
366431
oidc_service_slug=opts.oidc_service_slug,
367432
oidc_discovery_disabled=opts.oidc_discovery_disabled,
433+
oidc_detector_order=opts.oidc_detector_order,
434+
oidc_disabled_detectors=oidc_disabled_detectors,
368435
)
369436

370437
chain = CredentialProviderChain()
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Copyright 2026 Cloudsmith Ltd
2+
"""Tests for config-file + CLI-layer OIDC detector controls."""
3+
4+
import pytest
5+
6+
from ..config import ConfigReader, Options
7+
from ..decorators import _config_disabled_detectors, _warn_on_oidc_detector_controls
8+
9+
10+
@pytest.fixture
11+
def config_file(tmp_path):
12+
"""Yield a writer for a temporary config.ini, restoring reader state."""
13+
original_files = list(ConfigReader.config_files)
14+
15+
def write(body):
16+
path = tmp_path / "config.ini"
17+
path.write_text(body)
18+
return str(path)
19+
20+
yield write
21+
ConfigReader.config_files = original_files
22+
23+
24+
class TestConfigFileControls:
25+
def test_detector_order_is_read_from_config(self, config_file):
26+
path = config_file("[default]\noidc_detector_order = github, aws\n")
27+
opts = Options()
28+
opts.load_config_file(path)
29+
assert opts.oidc_detector_order == "github, aws"
30+
31+
def test_disabled_detectors_is_read_from_config(self, config_file):
32+
path = config_file("[default]\noidc_disabled_detectors = aws,gitlab\n")
33+
opts = Options()
34+
opts.load_config_file(path)
35+
assert opts.oidc_disabled_detectors == "aws,gitlab"
36+
37+
38+
class TestConfigDisabledDetectors:
39+
def test_parses_comma_separated_ids(self):
40+
assert _config_disabled_detectors("aws, gitlab") == frozenset({"aws", "gitlab"})
41+
42+
def test_lowercases_and_strips(self):
43+
assert _config_disabled_detectors(" AWS , GitLab ") == frozenset(
44+
{"aws", "gitlab"}
45+
)
46+
47+
def test_empty_or_none_is_empty_set(self):
48+
assert _config_disabled_detectors(None) == frozenset()
49+
assert _config_disabled_detectors(" ") == frozenset()
50+
51+
52+
class TestDetectorControlWarnings:
53+
def test_unknown_id_warns(self, capsys):
54+
_warn_on_oidc_detector_controls("github,nope", frozenset())
55+
err = capsys.readouterr().err
56+
assert "nope" in err
57+
assert "github" not in err.split("Valid ids", 1)[0]
58+
59+
def test_no_warning_when_all_ids_known(self, capsys):
60+
_warn_on_oidc_detector_controls("github,aws", frozenset())
61+
assert capsys.readouterr().err == ""
62+
63+
def test_no_warning_without_controls(self, capsys):
64+
_warn_on_oidc_detector_controls(None, frozenset())
65+
assert capsys.readouterr().err == ""
66+
67+
def test_warns_when_no_detectors_enabled_via_order(self, capsys):
68+
_warn_on_oidc_detector_controls("nope", frozenset())
69+
assert "no detectors are enabled" in capsys.readouterr().err.lower()
70+
71+
def test_warns_when_order_fully_disabled(self, capsys):
72+
_warn_on_oidc_detector_controls("aws", frozenset({"aws"}))
73+
assert "no detectors are enabled" in capsys.readouterr().err.lower()
74+
75+
def test_no_enabled_warning_in_normal_case(self, capsys):
76+
_warn_on_oidc_detector_controls("github,aws", frozenset())
77+
assert "no detectors are enabled" not in capsys.readouterr().err.lower()

cloudsmith_cli/core/credentials/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ class CredentialContext:
3030
oidc_org: str | None = None
3131
oidc_service_slug: str | None = None
3232
oidc_discovery_disabled: bool = False
33+
oidc_detector_order: str | None = None
34+
oidc_disabled_detectors: frozenset[str] = frozenset()
3335

3436

3537
@dataclass

cloudsmith_cli/core/credentials/oidc/detectors/__init__.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
from .gitlab_ci import GitLabCIDetector
1616

1717
if TYPE_CHECKING:
18+
from collections.abc import Mapping
19+
1820
from ... import CredentialContext
1921

2022
logger = logging.getLogger(__name__)
@@ -30,11 +32,77 @@
3032
]
3133

3234

35+
def registered_detectors() -> list[type[EnvironmentDetector]]:
36+
"""Return the registered OIDC detectors in their default priority order."""
37+
return list(_DETECTORS)
38+
39+
40+
def disable_env_var(identifier: str) -> str:
41+
"""The environment variable that disables the detector with this id."""
42+
return f"CLOUDSMITH_OIDC_{identifier.upper()}_DISABLED"
43+
44+
45+
def _is_disabled_value(value: str | None) -> bool:
46+
"""Only the literal string ``true`` (case-insensitive) disables a detector."""
47+
return (value or "").strip().lower() == "true"
48+
49+
50+
def disabled_detectors_from_env(environ: Mapping[str, str]) -> frozenset[str]:
51+
"""Detector ids disabled via their CLOUDSMITH_OIDC_<ID>_DISABLED variable."""
52+
return frozenset(
53+
detector_cls.id
54+
for detector_cls in _DETECTORS
55+
if _is_disabled_value(environ.get(disable_env_var(detector_cls.id)))
56+
)
57+
58+
59+
def _ordered_detectors(order: str | None) -> list[type[EnvironmentDetector]]:
60+
"""Detectors in evaluation order, honouring an explicit order string.
61+
62+
When ``order`` is unset/empty the default registration order is used.
63+
Otherwise only the listed ids are considered, in the listed order;
64+
unknown ids are logged and skipped, and duplicate ids keep their first
65+
position so each detector is evaluated at most once.
66+
"""
67+
raw_order = (order or "").strip()
68+
if not raw_order:
69+
return list(_DETECTORS)
70+
71+
detectors_by_id = {detector_cls.id: detector_cls for detector_cls in _DETECTORS}
72+
ordered: dict[str, type[EnvironmentDetector]] = {}
73+
for token in raw_order.split(","):
74+
identifier = token.strip().lower()
75+
if not identifier or identifier in ordered:
76+
continue
77+
detector_cls = detectors_by_id.get(identifier)
78+
if detector_cls is None:
79+
logger.debug("Ignoring unknown OIDC detector id: %s", identifier)
80+
continue
81+
ordered[identifier] = detector_cls
82+
return list(ordered.values())
83+
84+
85+
def _enabled_detectors(
86+
order: str | None, disabled: frozenset[str]
87+
) -> list[type[EnvironmentDetector]]:
88+
"""Ordered detectors with disabled ones removed (disable always wins)."""
89+
return [
90+
detector_cls
91+
for detector_cls in _ordered_detectors(order)
92+
if detector_cls.id not in disabled
93+
]
94+
95+
3396
def detect_environment(
3497
context: CredentialContext,
3598
) -> EnvironmentDetector | None:
3699
"""Try each detector in order, returning the first that matches."""
37-
for detector_cls in _DETECTORS:
100+
enabled = _enabled_detectors(
101+
context.oidc_detector_order, context.oidc_disabled_detectors
102+
)
103+
if not enabled:
104+
logger.debug("No OIDC detectors enabled after applying order/disable controls")
105+
for detector_cls in enabled:
38106
detector = detector_cls(context=context)
39107
try:
40108
if detector.detect():

cloudsmith_cli/core/credentials/oidc/detectors/aws.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class AWSDetector(EnvironmentDetector):
2424
"""Detects AWS environments and obtains a JWT via STS GetWebIdentityToken."""
2525

2626
name = "AWS"
27+
id = "aws"
2728

2829
def __init__(self, context):
2930
super().__init__(context)

cloudsmith_cli/core/credentials/oidc/detectors/azure_devops.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class AzureDevOpsDetector(EnvironmentDetector):
3030
"""Detects Azure DevOps and fetches an OIDC token via HTTP POST."""
3131

3232
name = "Azure DevOps"
33+
id = "azure_devops"
3334

3435
def detect(self) -> bool:
3536
return bool(os.environ.get("SYSTEM_OIDCREQUESTURI")) and bool(

cloudsmith_cli/core/credentials/oidc/detectors/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class EnvironmentDetector:
1212
"""Base class for OIDC environment detectors."""
1313

1414
name: str = "base"
15+
id: str = "base"
1516

1617
def __init__(self, context: CredentialContext):
1718
self.context = context

0 commit comments

Comments
 (0)