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)