From 7a6503017147084762eed8f8f3ddaa18e883c4e8 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 31 Jul 2026 23:19:49 +0100 Subject: [PATCH] feat(ENG-13681): add credential-helper generic command Emits a resolved credential as a versioned JSON document on stdout, so tools that cannot import the CLI can still authenticate through the full provider chain (API key, credentials.ini, system keyring, OIDC). The command takes no arguments. A Cloudsmith token is organisation-wide, so the host it will be used against cannot change which credential resolves; asking which hosts are Cloudsmith is a separate question, answered by `credential-helper domains` (added next). The document is serialised in one step and a single broad except guards the protocol boundary, so a caller invoking this as a subprocess can never see a partial document or a traceback. Refusals exit non-zero with a human-readable line on stderr and nothing on stdout. The CLI wiring tests resolve the credential from --api-key, so the chain never reads a developer's real credentials.ini or keyring. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 + .../commands/credential_helper/__init__.py | 2 + .../cli/commands/credential_helper/generic.py | 63 ++++++++++ .../test_credential_helper_generic.py | 118 ++++++++++++++++++ cloudsmith_cli/credential_helpers/generic.py | 63 ++++++++++ 5 files changed, 248 insertions(+) create mode 100644 cloudsmith_cli/cli/commands/credential_helper/generic.py create mode 100644 cloudsmith_cli/cli/tests/commands/test_credential_helper_generic.py create mode 100644 cloudsmith_cli/credential_helpers/generic.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d613f53..3ad58295 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added +- `cloudsmith credential-helper generic` resolves a credential through the full provider chain (API key, `credentials.ini`, system keyring, OIDC) and emits it as a versioned JSON document — `{"version": 1, "username": "token", "password": ""}` — for tools that shell out to the CLI rather than importing it. It takes no arguments: a Cloudsmith token is organisation-wide, so the host it will be used against does not change which credential resolves. Errors exit non-zero with a message on stderr and never emit a partial document. + - `cloudsmith credential-helper domains` emits a versioned JSON document — `{"version": 1, "domains": [{"host": ..., "format": ..., "enabled": ..., "validated": ..., "type": ..., "domain_type": ..., "org": ..., "repository": ...}]}` — listing the hosts Cloudsmith can authenticate: the built-in service domains plus any custom domains configured for an organisation, along with the package format each one serves (`format` is `null` for hosts that serve no single format, like the download CDN). Each entry's `domain_type` says what the host is for — `download` for the CDN, `upload` for the generic upload endpoint, or `native_api` for a host that speaks a single package format's own protocol — so a consumer can pick the right host without pattern-matching on hostnames. `org` and `repository` say which organisation a custom domain belongs to and, when it is scoped to one, which repository — built-in entries carry `null` for both. Disabled and unvalidated custom domains are included too, with their state intact, so a misconfigured domain is visible rather than silently missing. A failed custom-domain lookup (typo'd organisation, missing permission, unreachable API) exits non-zero with a message on stderr instead of silently listing built-in hosts only. The command takes no arguments: the organisation is resolved from `CLOUDSMITH_ORG` or `oidc_org` in `config.ini`. With an organisation configured, its custom domains are looked up (and a failed lookup still exits non-zero); with none configured, the command instead lists whatever custom domains an earlier run already cached, at no API cost, rather than querying every organisation the caller belongs to. Custom-domain lookups are cached on disk for seven days, since custom domains change rarely; pass `--refresh` to bypass the cache and fetch fresh data, for example right after adding or validating a domain. Like `credential-helper generic`, this command is for programmatic consumers only and has no human-readable output mode. - The built-in domain list can be replaced via a `[domains]` section in `config.ini`, mapping hostname to package format, for dedicated and on-premise deployments. For safety this is honoured only from trusted configuration — your user-level config directory, `~/.cloudsmith`, or an explicit `--config-file`. A `[domains]` section in a directory-relative `config.ini` is ignored with a warning, because that file can be committed to a repository and this list decides which hosts may receive a credential. diff --git a/cloudsmith_cli/cli/commands/credential_helper/__init__.py b/cloudsmith_cli/cli/commands/credential_helper/__init__.py index 3c9fd746..6ec81b00 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/__init__.py +++ b/cloudsmith_cli/cli/commands/credential_helper/__init__.py @@ -11,6 +11,7 @@ from ..main import main from .docker import docker as docker_cmd from .domains import domains_cmd +from .generic import generic as generic_cmd from .manage import install_cmd, list_cmd, uninstall_cmd @@ -34,6 +35,7 @@ def credential_helper(): credential_helper.add_command(docker_cmd, name="docker") credential_helper.add_command(domains_cmd, name="domains") +credential_helper.add_command(generic_cmd, name="generic") credential_helper.add_command(install_cmd, name="install") credential_helper.add_command(uninstall_cmd, name="uninstall") credential_helper.add_command(list_cmd, name="list") diff --git a/cloudsmith_cli/cli/commands/credential_helper/generic.py b/cloudsmith_cli/cli/commands/credential_helper/generic.py new file mode 100644 index 00000000..13f36c86 --- /dev/null +++ b/cloudsmith_cli/cli/commands/credential_helper/generic.py @@ -0,0 +1,63 @@ +# Copyright 2026 Cloudsmith Ltd +""" +Generic credential helper command. + +Emits a versioned JSON credential document. +""" + +import sys + +import click + +from ....credential_helpers.generic import execute +from ...decorators import ( + common_api_auth_options, + common_cli_config_options, + resolve_credentials, +) + + +@click.command() +@common_cli_config_options +@common_api_auth_options +@resolve_credentials +def generic(opts): + """ + Emit a Cloudsmith credential as JSON. + + Resolves a credential through the full provider chain and writes a + versioned JSON document to stdout. Takes no arguments: a Cloudsmith token + is organisation-wide, so the host it will be used against does not change + which credential resolves. Use ``cloudsmith credential-helper domains`` to + discover which hosts Cloudsmith can authenticate. + + \b + Output (stdout): + JSON: {"version": 1, "username": "token", "password": ""} + + \b + Exit codes: + 0: Success + 1: No credential could be resolved + + Examples: + + \b + # Resolve a credential + $ cloudsmith credential-helper generic + + \b + # Extract just the token + $ cloudsmith credential-helper generic | jq -r .password + + \b + Environment variables: + CLOUDSMITH_API_KEY: API key for authentication (optional) + """ + exit_code, stdout, stderr = execute(credential=opts.credential) + + if stdout is not None: + click.echo(stdout) + if stderr is not None: + click.echo(stderr, err=True) + sys.exit(exit_code) diff --git a/cloudsmith_cli/cli/tests/commands/test_credential_helper_generic.py b/cloudsmith_cli/cli/tests/commands/test_credential_helper_generic.py new file mode 100644 index 00000000..2fd06a5f --- /dev/null +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_generic.py @@ -0,0 +1,118 @@ +# Copyright 2026 Cloudsmith Ltd +"""Tests for the `cloudsmith credential-helper generic` command.""" + +import json +from unittest.mock import patch + +from ....cli.commands.credential_helper.generic import generic +from ....core.credentials.models import CredentialResult +from ....credential_helpers.generic import _REFUSAL_MESSAGE, PROTOCOL_VERSION, execute + +TOKEN = "k_secret_token_value" + +HERMETIC_ARGS = ["--api-key", "fake-api-key"] + + +def test_execute_success_returns_versioned_document(): + """A resolved credential produces the exact version-1 contract.""" + credential = CredentialResult(api_key=TOKEN, source_name="env") + + code, stdout, stderr = execute(credential=credential) + + assert code == 0 + assert stderr is None + assert json.loads(stdout) == { + "version": PROTOCOL_VERSION, + "username": "token", + "password": TOKEN, + } + + +def test_execute_document_has_no_extra_keys(): + """Consumers pin on the contract - no undeclared keys may leak in.""" + credential = CredentialResult(api_key=TOKEN, source_name="env") + + _, stdout, _ = execute(credential=credential) + + assert set(json.loads(stdout)) == {"version", "username", "password"} + + +def test_execute_no_credential_refuses(): + """No credential -> exit 1, message on stderr, nothing on stdout.""" + code, stdout, stderr = execute(credential=None) + + assert code == 1 + assert stdout is None + assert "Unable to retrieve credentials" in stderr + + +def test_execute_empty_api_key_refuses(): + """An empty api_key is not a usable credential.""" + credential = CredentialResult(api_key="", source_name="env") + + code, stdout, stderr = execute(credential=credential) + + assert code == 1 + assert stdout is None + assert stderr == _REFUSAL_MESSAGE + + +def test_execute_degrades_on_unexpected_exception(): + """A raising credential degrades to a clean refusal, never a traceback.""" + + class ExplodingCredential: + """Stands in for any object whose attribute access misbehaves.""" + + @property + def api_key(self): + raise RuntimeError("boom") + + code, stdout, stderr = execute(credential=ExplodingCredential()) + + assert code == 1 + assert stdout is None + assert stderr == _REFUSAL_MESSAGE + + +def test_token_only_ever_appears_on_stdout(): + """The secret must never be written to stderr.""" + credential = CredentialResult(api_key=TOKEN, source_name="env") + + _, stdout, stderr = execute(credential=credential) + + assert TOKEN in stdout + assert stderr is None + + +def test_cli_emits_bare_contract_on_stdout(runner): + """The command echoes execute()'s stdout verbatim, with nothing on stderr.""" + document = json.dumps( + {"version": PROTOCOL_VERSION, "username": "token", "password": TOKEN} + ) + + with patch( + "cloudsmith_cli.cli.commands.credential_helper.generic.execute", + return_value=(0, document, None), + ): + result = runner.invoke(generic, args=HERMETIC_ARGS, catch_exceptions=False) + + assert result.exit_code == 0 + assert json.loads(result.stdout) == { + "version": PROTOCOL_VERSION, + "username": "token", + "password": TOKEN, + } + assert result.stderr == "" + + +def test_cli_refusal_exits_1_with_empty_stdout(runner): + """A refusal must not emit a partial document on stdout.""" + with patch( + "cloudsmith_cli.cli.commands.credential_helper.generic.execute", + return_value=(1, None, _REFUSAL_MESSAGE), + ): + result = runner.invoke(generic, args=HERMETIC_ARGS, catch_exceptions=False) + + assert result.exit_code == 1 + assert result.stdout == "" + assert "Unable to retrieve credentials" in result.stderr diff --git a/cloudsmith_cli/credential_helpers/generic.py b/cloudsmith_cli/credential_helpers/generic.py new file mode 100644 index 00000000..cbb2de05 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/generic.py @@ -0,0 +1,63 @@ +# Copyright 2026 Cloudsmith Ltd +""" +Generic credential helper runtime. + +Emits a versioned JSON credential document. +""" + +import json +import logging + +logger = logging.getLogger(__name__) + +PROTOCOL_VERSION = 1 + +_REFUSAL_MESSAGE = ( + "Error: Unable to retrieve credentials. " + "Provide credentials via the CLOUDSMITH_API_KEY environment variable, " + "credentials.ini, the system keyring, or an OIDC service. " + "Verify current authentication with `cloudsmith whoami --verbose`." +) + + +def build_response(credential): + """ + Build the versioned credential document. + + Args: + credential: Pre-resolved CredentialResult from the provider chain + + Returns: + dict: The credential document, or None when no credential is available + """ + if not credential or not credential.api_key: + return None + + return { + "version": PROTOCOL_VERSION, + "username": "token", + "password": credential.api_key, + } + + +def execute(credential=None) -> tuple[int, str | None, str | None]: + """ + Resolve a credential into a versioned JSON document. + + Args: + credential: Pre-resolved CredentialResult from the provider chain + + Returns: + A (exit_code, stdout_text, stderr_text) tuple. Either text value may + be None if there is nothing to write to that stream. The document is + serialised in one step, so a partial document can never be emitted. + """ + try: + response = build_response(credential) + if response is None: + return (1, None, _REFUSAL_MESSAGE) + + return (0, json.dumps(response), None) + except Exception as exc: # pylint: disable=broad-except + logger.debug("generic credential-helper failed: %s", exc, exc_info=True) + return (1, None, _REFUSAL_MESSAGE)