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
1 change: 1 addition & 0 deletions CHANGES/28.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A repository can no longer be used for both pull-through caching and uploads at the same time.
13 changes: 9 additions & 4 deletions docs/user/guides/private-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ Rust packages.
pulp rust repository create --name my-crates
```

!!! note
A repository may be used for pull-through caching **or** for uploads, but not both. Pulp
rejects attempts to attach an upload distribution to a repository that is already used for
caching (and vice versa). Use separate repositories for caching and publishing.

## Create a Distribution

A distribution makes the repository's content available to Cargo over HTTP. Set `--allow-uploads`
Expand Down Expand Up @@ -166,10 +171,10 @@ publishing `my-crate` when `my_crate` already exists in the same repository is r
duplicate. Yank and unyank operations use the same matching.

!!! tip "Separate Registries"
Keep private registries and public pull-through caches as separate distributions (and
preferably separate repositories). This makes it easy to audit which registries have
upstream access and reduces the risk of accidental misconfiguration. For additional
isolation or access control, they could be kept on entirely separate domains.
Pulp enforces that private registries and pull-through caches use separate repositories.
A repository that is a pull-through cache (targeted by a distribution with a `remote`, or
with its own `remote` set) cannot also accept uploads, and vice versa. This prevents
dependency-confusion attacks where uploaded content could override cached upstream content.

## Further Reading

Expand Down
37 changes: 37 additions & 0 deletions pulp_rust/app/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,13 +261,50 @@ def validate(self, data):
allow_uploads = data.get(
"allow_uploads", self.instance.allow_uploads if self.instance else False
)
repository = data.get("repository", self.instance.repository if self.instance else None)

# A single distribution cannot both cache from a remote and accept uploads.
if remote and allow_uploads:
raise serializers.ValidationError(
_(
"A distribution cannot have both a remote and allow_uploads enabled. "
"Use separate distributions for pull-through caching and publishing."
)
)

# A single repository can't be used for both pull-through caching and uploads.
if repository:
repo = repository.cast()
sibling_distributions = models.RustDistribution.objects.filter(repository=repo)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems fragile, at the very least the Distribution could probably be pointing to a repository version - "frozen" so to speak.

Which probably ought to also disable uploads?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But why would anyone try to upload something to a repository version? We are trying to prevent mixing of cached and uploaded content and uploading to a repo version doesn't lead to it.

I'm open to other solutions, but I don't see an issue with this one.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They're not uploading to the repository version, they're uploading to the distribution, which has a repo version set. And it shouldn't work. I think right now (not under this PR, I haven't checked, but master branch) it would upload to the repository and make new versions even though the distribution & distributed content doesn't change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understood the code correctly, uploading via cargo publish won't work if a distribution has repo_version set, since it relies on distro.repository having a value: https://github.com/pulp/pulp_rust/blob/main/pulp_rust/app/views.py#L347

And base distribution class doesn't allow for a distro to have both repo and repo_version set.
https://github.com/pulp/pulpcore/blob/main/pulpcore/app/serializers/publication.py#L315

Was that your concern or am I missing smt?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose that covers it, although it's not a very good error message for this specific case.

if self.instance:
sibling_distributions = sibling_distributions.exclude(pk=self.instance.pk)

if allow_uploads:
if repo.remote_id:
raise serializers.ValidationError(
_(
"This repository has a remote set for caching and cannot also be "
"used for uploads. Use separate repositories for pull-through "
"caching and publishing."
)
)
if sibling_distributions.exclude(remote=None).exists():
raise serializers.ValidationError(
_(
"This repository is already used for pull-through caching by "
"another distribution and cannot also be used for uploads. Use "
"separate repositories for pull-through caching and publishing."
)
)

if remote and sibling_distributions.filter(allow_uploads=True).exists():
raise serializers.ValidationError(
_(
"This repository is already used for uploads by another distribution "
"and cannot also be used for pull-through caching. Use separate "
"repositories for pull-through caching and publishing."
)
)
return data

class Meta:
Expand Down
108 changes: 108 additions & 0 deletions pulp_rust/tests/functional/api/test_mixed_content.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Tests preventing a repository from mixing pull-through cached and uploaded content.

A repository must be used either for pull-through caching or for uploads, never both.
"""

import pytest

from pulpcore.client.pulp_rust.exceptions import ApiException

from pulp_rust.tests.functional.utils import CRATES_IO_URL


def test_single_distribution_remote_and_uploads_rejected(
rust_repo_factory,
rust_remote_factory,
rust_distribution_factory,
):
"""A single distribution cannot have both a remote and allow_uploads."""
repo = rust_repo_factory()
remote = rust_remote_factory(url=CRATES_IO_URL)

with pytest.raises(ApiException) as exc:
rust_distribution_factory(
repository=repo.pulp_href, remote=remote.pulp_href, allow_uploads=True
)
assert exc.value.status == 400


def test_upload_distribution_on_pull_through_repo_rejected(
rust_repo_factory,
rust_remote_factory,
rust_distribution_factory,
):
"""A repo already used for pull-through caching cannot get an upload distribution."""
repo = rust_repo_factory()
remote = rust_remote_factory(url=CRATES_IO_URL)

# First distribution caches into the repo via a remote.
rust_distribution_factory(repository=repo.pulp_href, remote=remote.pulp_href)

# A second distribution accepting uploads into the same repo must be rejected.
with pytest.raises(ApiException) as exc:
rust_distribution_factory(repository=repo.pulp_href, allow_uploads=True)
assert exc.value.status == 400


def test_pull_through_distribution_on_upload_repo_rejected(
rust_repo_factory,
rust_remote_factory,
rust_distribution_factory,
):
"""A repo already used for uploads cannot get a pull-through distribution."""
repo = rust_repo_factory()
remote = rust_remote_factory(url=CRATES_IO_URL)

# First distribution accepts uploads into the repo.
rust_distribution_factory(repository=repo.pulp_href, allow_uploads=True)

# A second distribution caching into the same repo must be rejected.
with pytest.raises(ApiException) as exc:
rust_distribution_factory(repository=repo.pulp_href, remote=remote.pulp_href)
assert exc.value.status == 400


def test_upload_distribution_on_repo_with_remote_rejected(
rust_repo_factory,
rust_remote_factory,
rust_distribution_factory,
):
"""A repo with its own remote (for syncing) cannot get an upload distribution."""
remote = rust_remote_factory(url=CRATES_IO_URL)
repo = rust_repo_factory(remote=remote.pulp_href)

with pytest.raises(ApiException) as exc:
rust_distribution_factory(repository=repo.pulp_href, allow_uploads=True)
assert exc.value.status == 400


def test_separate_repos_for_cache_and_uploads_succeed(
rust_repo_factory,
rust_remote_factory,
rust_distribution_factory,
):
"""Using separate repositories for caching and uploads is allowed."""
remote = rust_remote_factory(url=CRATES_IO_URL)

cache_repo = rust_repo_factory()
cache_distro = rust_distribution_factory(
repository=cache_repo.pulp_href, remote=remote.pulp_href
)
assert cache_distro is not None

upload_repo = rust_repo_factory()
upload_distro = rust_distribution_factory(repository=upload_repo.pulp_href, allow_uploads=True)
assert upload_distro is not None


def test_multiple_upload_distributions_on_one_repo_succeed(
rust_repo_factory,
rust_distribution_factory,
):
"""Multiple upload distributions may share one repo (no caching involved)."""
repo = rust_repo_factory()

first = rust_distribution_factory(repository=repo.pulp_href, allow_uploads=True)
second = rust_distribution_factory(repository=repo.pulp_href, allow_uploads=True)
assert first is not None
assert second is not None
24 changes: 21 additions & 3 deletions pulp_rust/tests/functional/api/test_rbac.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,27 @@ def test_basic_crud(self, gen_users, rust_repo_api_client, try_action):
assert (b_list.count, c_list.count) == (0, 0)

# Create — only creator (bob) can create
try_action(alice, rust_repo_api_client, "create", 403, {"name": str(uuid.uuid4())})
repo = try_action(bob, rust_repo_api_client, "create", 201, {"name": str(uuid.uuid4())})
try_action(charlie, rust_repo_api_client, "create", 403, {"name": str(uuid.uuid4())})
try_action(
alice,
rust_repo_api_client,
"create",
403,
{"name": str(uuid.uuid4())},
)
repo = try_action(
bob,
rust_repo_api_client,
"create",
201,
{"name": str(uuid.uuid4())},
)
try_action(
charlie,
rust_repo_api_client,
"create",
403,
{"name": str(uuid.uuid4())},
)

# Read — alice has model-level viewer, bob is owner (creation hook), charlie has nothing
try_action(alice, rust_repo_api_client, "read", 200, repo.pulp_href)
Expand Down
Loading