Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .oss-scanner/threat_model.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Untrusted:
Trusted: command-line flags, environment variables, `config.ini` and `credentials.ini` that the user owns, and the OS keyring.

## Components that matter most / least
- Credential scoping. A credential must only go to the host that it belongs to. This covers `core/credentials/chain.py`, the providers in `core/credentials/providers/`, the credential helper runtimes, and the custom-domain lookup (`credential_helpers/custom_domains.py`, `credential_helpers/common.py`). Host-name parsing and matching bugs are in scope.
- Credential scoping. A credential must only go to the host that it belongs to. This covers `core/credentials/chain.py`, the providers in `core/credentials/providers/`, the credential helper runtimes, and the custom-domain lookup (`credential_helpers/domains.py`, `credential_helpers/common.py`). Host-name parsing and matching bugs are in scope.
- Token storage: `core/keyring.py`, `core/macos_keychain.py`, and the OIDC token cache (`core/credentials/oidc/cache.py`). File permissions and cross-profile leaks are in scope.
- The SSO callback server: CSRF, token injection, and open redirects.
- File writes from untrusted data: download paths from API filenames, tar extraction in `core/self_update.py`, and edits to package-manager and MCP client config files (`cli/commands/mcp.py`).
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

## [Unreleased]

### Changed

- `cloudsmith domains list` and the credential helpers now get the domains from `GET /v2/workspaces/{workspace}/domains/` on the configured API host. This list includes the Cloudsmith-owned hosts, so the CLI no longer contains a list of Cloudsmith hostnames. The CLI keeps a separate domain cache for each API host.
- `cloudsmith domains list` keeps version 1 of its JSON document. The API does not give `primary` and `created_at`, so these fields are now always `null`. The hosts are in the preference order of the API. `domain_type` can also be `cloudsmith_api`.
- Without a Workspace, `cloudsmith domains list` lists the domains that earlier runs cached. If no domains are cached, it gets the domains of a Workspace that the credential is a member of. Without a credential, the list is empty.
- `cloudsmith credential-helper install` now configures the Cloudsmith-owned hosts that the API lists for the Workspace. It uses its built-in default host, such as `docker.cloudsmith.io`, only when it cannot get the domains from the API.

### Removed

- The CLI no longer reads a `[domains]` section from `config.ini`. The configured API host supplies the domains.

## [1.29.0] - 2026-10-15

### Added
Expand Down
251 changes: 126 additions & 125 deletions cloudsmith_cli/cli/commands/domains.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,27 @@
# Copyright 2026 Cloudsmith Ltd
"""List the hosts Cloudsmith can authenticate: built-in and custom domains.
"""List the hosts Cloudsmith can authenticate: Cloudsmith-owned and custom domains.

The built-in ``*.cloudsmith.io`` service hosts ship with the CLI so that every
consumer shares one authoritative table; custom domains cannot be inferred, so
discovering them requires an API lookup.
The configured API host lists the domains that serve a Workspace, so the CLI
holds no table of Cloudsmith hostnames and works against any Cloudsmith.
"""

from __future__ import annotations

import json
import os

import click
from click.core import ParameterSource

from ...core.api.exceptions import ApiException
from ...core.api.orgs import list_orgs
from ...core.pagination import PageInfo
from ...credential_helpers.custom_domains import (
get_custom_domains,
order_by_precedence,
read_all_cached_domains,
)
from ...credential_helpers.default_domains import (
from ...credential_helpers.domains import (
DomainType,
format_for_backend_kind,
load_default_domains,
untrusted_config_declares_domains,
WorkspaceDomain,
get_workspace_domains,
read_all_cached_domains,
)
from .. import command
from ..config import get_default_config_path
from ..decorators import (
common_api_auth_options,
common_cli_config_options,
Expand All @@ -55,22 +48,86 @@ def _is_domain_type(domain_type: DomainType, wanted: str | None) -> bool:
return wanted is None or domain_type.value == wanted


def _custom_entries(records) -> list[dict]:
"""Render custom-domain records as document entries, in the order given."""
return [
{
"host": record.host,
"format": format_for_backend_kind(record.backend_kind),
"type": "custom",
"domain_type": record.domain_type.value,
"workspace": record.org,
"org": record.org,
"repository": record.repository,
"primary": record.primary,
"created_at": record.created_at,
}
for record in records
]
def _entry(record: WorkspaceDomain) -> dict:
"""Render a domain record as a document entry."""
return {
"host": record.host,
"format": record.format_label,
"type": "custom" if record.is_custom else "default",
"domain_type": record.domain_type.value,
"workspace": record.workspace,
"org": record.workspace,
"repository": record.repository,
"primary": None,
"created_at": None,
}


def _merge_cached(records: list[WorkspaceDomain]) -> list[WorkspaceDomain]:
"""Merge the cached answers for several Workspaces into one list.

No single API order covers several Workspaces, so custom domains are
grouped by Workspace ahead of the Cloudsmith-owned ones. Each group keeps
the API order. Every cached answer repeats the Cloudsmith-owned domains,
so each one is listed once.
"""
custom = [record for record in records if record.is_custom]
cloudsmith_owned = [record for record in records if not record.is_custom]
return sorted(custom, key=lambda record: record.workspace or "") + list(
dict.fromkeys(cloudsmith_owned)
)


def _member_workspace(opts) -> str | None:
"""Return a Workspace that the credential is a member of, or None."""
if opts.credential is None:
return None
workspaces = list_orgs(page=1, page_size=1)
return workspaces[0]["slug"] if workspaces else None


def _get_domains(opts, refresh: bool) -> list[WorkspaceDomain]:
"""Get the domains of the configured Workspace, or of a cached or member one.

Every Workspace answer holds the Cloudsmith-owned domains. Without a
configured Workspace, the cached answers supply them, or else the answer
for any Workspace that the credential is a member of.
"""
workspace = opts.org
if not workspace:
cached = read_all_cached_domains(opts.api_host)
if cached:
if refresh:
click.secho(
"Warning: --refresh needs a Workspace to fetch from, so the "
"cached domains below are unchanged. Set --workspace, "
"CLOUDSMITH_WORKSPACE, or workspace in config.ini.",
fg="yellow",
err=True,
)
return _merge_cached(cached)
try:
workspace = _member_workspace(opts)
except ApiException as exc:
raise click.ClickException(
f"Failed to find a Workspace to fetch domains for: {exc}"
) from exc
if workspace is None:
return []

try:
return get_workspace_domains(
workspace,
credential=opts.credential,
api_host=opts.api_host,
refresh=refresh,
strict=True,
configure_api=False,
)
except ApiException as exc:
raise click.ClickException(
f"Failed to fetch domains for {workspace!r}: {exc}"
) from exc


@main.group(name="domains", cls=command.AliasGroup, aliases=["domain"])
Expand All @@ -87,7 +144,7 @@ def domains_():
"--refresh",
is_flag=True,
default=False,
help="Bypass the custom-domain cache and fetch fresh data from the API.",
help="Bypass the domain cache and fetch fresh data from the API.",
)
@click.option(
"--format",
Expand All @@ -101,7 +158,8 @@ def domains_():
"--domain-type",
"domain_type",
default=None,
help="Only list hosts with this purpose: download, upload, or native_api. "
help="Only list hosts with this purpose: download, upload, cloudsmith_api, "
"or native_api. "
"Every host has exactly one, so this is an exact match.",
)
@click.option(
Expand Down Expand Up @@ -130,30 +188,30 @@ def list_domains( # pylint: disable=too-many-arguments
) -> None:
"""Emit the Cloudsmith hosts and their package formats as JSON.

Machine-readable output for programmatic consumers. It lists the built-in
``*.cloudsmith.io`` service hosts alongside custom domains, with ``type``
distinguishing ``default`` from ``custom`` and ``domain_type`` saying what
each host is for: ``download``, ``upload``, or ``native_api`` for a host
speaking one package format's own protocol. A Workspace's own custom
domains are listed ahead of the built-in hosts, which serve as the
fallback. Only usable hosts are listed: a custom domain that is disabled or
not yet validated serves nothing, so it is left out rather than offered as
somewhere to publish to. Check it in the Cloudsmith UI if one you expect is
missing here.

Built-in hosts are always listed and need no Workspace or authentication.
A Workspace from ``--workspace``, CLOUDSMITH_WORKSPACE, or ``workspace`` in
``config.ini`` adds its custom domains, and a failed lookup exits non-zero
rather than rendering as "no domains". With no Workspace the command lists
whatever earlier runs cached and makes no API call; ``--refresh`` bypasses
that cache for a configured Workspace.

Where two custom domains could serve the same request Cloudsmith picks the
one bound to the repository in hand, then ``primary`` over secondary, then
the oldest — so ``created_at`` is reported to make that order reproducible.
The custom domains are always listed in that order, so the first is the one
Cloudsmith would bind; ``--repo`` is what lets the repository-bound hosts
take part in it.
Machine-readable output for programmatic consumers. The configured API host
lists the domains that serve a Workspace, so the hosts match whichever
Cloudsmith the CLI talks to. ``type`` distinguishes Cloudsmith-owned
(``default``) from ``custom`` domains, and ``domain_type`` says what each
host is for: ``download``, ``upload``, ``cloudsmith_api``, or
``native_api`` for a host speaking one package format's own protocol. Only
usable hosts are listed: the API leaves out a custom domain that is
disabled or not yet validated. Check it in the Cloudsmith UI if one you
expect is missing here.

The lookup uses the Workspace from ``--workspace``, CLOUDSMITH_WORKSPACE,
or ``workspace`` in ``config.ini``, and needs authentication as a member
of it. A failed lookup exits non-zero rather than rendering as "no
domains". With no Workspace the command lists whatever earlier runs cached
for the configured API host. If nothing is cached, it fetches the domains
of a Workspace that the credential is a member of, which include all the
Cloudsmith-owned hosts. ``--refresh`` bypasses the cache for a configured
Workspace.

Hosts are listed in the API's preference order: domains restricted to a
repository, then the Workspace's own, then the Cloudsmith-owned ones.
Within each group the primary domain comes first, then the oldest, so the
first host usable for a request is the one Cloudsmith would choose.
``--repo`` drops the domains restricted to other repositories.

Results support the same --page/--page-size (-p/-l) options as other
`list` commands, selecting a page of the combined, filtered list. Unlike
Expand All @@ -165,8 +223,12 @@ def list_domains( # pylint: disable=too-many-arguments
\b
Output (stdout):
JSON: {"version": 1, "domains": [{"host": ..., "format": ...,
"type": ..., "domain_type": ..., "workspace": ..., "repository": ...,
"primary": ..., "created_at": ...}], "meta": {"pagination": {...}}}
"type": ..., "domain_type": ..., "workspace": ..., "org": ...,
"repository": ..., "primary": null, "created_at": null}],
"meta": {"pagination": {...}}}

"org" repeats "workspace". The API does not give "primary" and
"created_at", so they are always null. Use the list order instead.

"meta" is only present when the result is paginated (i.e. not
--page-all), and mirrors the pagination metadata other `list`
Expand All @@ -175,11 +237,11 @@ def list_domains( # pylint: disable=too-many-arguments
Examples:

\b
# List built-in hosts plus any custom domains already cached
# List the Cloudsmith-owned hosts and any cached custom domains
$ cloudsmith domains list

\b
# List built-in hosts plus a Workspace's custom domains
# List the domains that serve a Workspace
$ cloudsmith domains list --workspace my-workspace

\b
Expand Down Expand Up @@ -208,79 +270,18 @@ def list_domains( # pylint: disable=too-many-arguments
# cache, so paging by default would hide entries for no benefit.
page_all = True

explicit_config = ctx.meta.get("config_file")
if explicit_config and os.path.isdir(explicit_config):
explicit_config = os.path.join(explicit_config, "config.ini")

if explicit_config is None and untrusted_config_declares_domains():
click.secho(
"Warning: ignoring [domains] section in ./config.ini -- config.ini "
"in the current directory is untrusted. Put it in "
f"{get_default_config_path()} or ~/.cloudsmith instead.",
fg="yellow",
err=True,
)

wanted_format = _label(format_)
wanted_type = _label(domain_type)
repository = _label(repo)

default_entries = [
{
"host": domain.host,
"format": domain.format_label,
"type": "default",
"domain_type": domain.domain_type.value,
"workspace": None,
"org": None,
"repository": None,
"primary": True,
"created_at": None,
}
for domain in load_default_domains(config_path=explicit_config)
if _serves_format(domain.format_label, wanted_format)
and _is_domain_type(domain.domain_type, wanted_type)
]

org = opts.org
if org:
try:
records = get_custom_domains(
org,
credential=opts.credential,
api_host=opts.api_host,
refresh=refresh,
strict=True,
configure_api=False,
)
except ApiException as exc:
raise click.ClickException(
f"Failed to fetch custom domains for {org!r}: {exc}"
) from exc
else:
if refresh:
click.secho(
"Warning: --refresh needs a Workspace to fetch from, so the "
"cached custom domains below are unchanged. Set --workspace, "
"CLOUDSMITH_WORKSPACE, or workspace in config.ini.",
fg="yellow",
err=True,
)
records = read_all_cached_domains()

records = [
record
for record in records
if _serves_format(format_for_backend_kind(record.backend_kind), wanted_format)
for record in _get_domains(opts, refresh)
if _serves_format(record.format_label, wanted_format)
and _is_domain_type(record.domain_type, wanted_type)
and record.is_active
and (repository is None or record.serves_repository(repository))
]
records = sorted(
order_by_precedence(records, repository), key=lambda record: record.org
)

data = _custom_entries(records) + default_entries
data = [_entry(record) for record in records]

document = {"version": PROTOCOL_VERSION, "domains": data}

Expand Down
Loading
Loading