diff --git a/CHANGES/48.feature b/CHANGES/48.feature new file mode 100644 index 0000000..51b0a2b --- /dev/null +++ b/CHANGES/48.feature @@ -0,0 +1 @@ +Cargo tokens can now be restricted to specific distributions and actions. diff --git a/docs/user/guides/authentication.md b/docs/user/guides/authentication.md index 7ef6350..8f8e2eb 100644 --- a/docs/user/guides/authentication.md +++ b/docs/user/guides/authentication.md @@ -37,6 +37,40 @@ token = "crg_..." Cargo sends the token automatically on state-changing operations (publish, yank, unyank). Read-only operations (downloading crates, browsing the index) do not require a token. +### Scoping a Token + +A token can be restricted to a subset of what you are allowed to do, which is useful when +handing one out to CI. Pass `distributions`, `actions`, or both when creating it: + +```bash +http POST http:///pulp/api/v3/cargo/tokens/ \ + -a alice:password \ + name="ci-publish-only" \ + distributions:='["/pulp/api/v3/distributions/rust/rust//"]' \ + actions:='["publish-new", "publish-update"]' +``` + +The available actions follow crates.io: `publish-new` (uploading a crate name the +distribution's repository does not have yet), `publish-update` (uploading another version of +a crate it already has), and `yank` (which covers both yanking and unyanking). crates.io's +`change-owners` has no equivalent here because Pulp Rust has no per-crate ownership. + +Leaving either field out means the token is not restricted on that axis: an empty +`distributions` allows every distribution you have access to, and an empty `actions` allows +every action. + +Scopes only ever narrow access. A token cannot do anything its owner's roles do not already +permit, and you can only scope a token to distributions you can already view -- naming one you +have no access to is rejected. + +Scopes are fixed when the token is created. To change them, revoke the token and create a +new one. + +Deleting a distribution removes it from the scope of every token that referenced it. A token +scoped to several distributions stays valid for the ones that remain, but a token that loses +its last scoped distribution is revoked -- an empty scope means unrestricted, so leaving it in +place would widen the token instead of narrowing it. + ### Managing Tokens ```bash @@ -85,7 +119,7 @@ Alice can now publish and yank crates on that distribution using her Cargo token | Owner management | `cargo owner --add` | Pulp REST API role assignment | | Token creation | Web UI at crates.io | Pulp REST API | | Per-crate ownership | Yes (user and team owners) | Not supported (planned) | -| Token scoping | Scoped to endpoints/crates | Not yet supported | +| Token scoping | Scoped to endpoints/crates | Scoped to distributions/actions | !!! warning "No per-crate ownership" Pulp Rust currently controls access at the distribution level, not per-crate. Any user with diff --git a/pulp_rust/app/global_access_conditions.py b/pulp_rust/app/global_access_conditions.py new file mode 100644 index 0000000..573eb11 --- /dev/null +++ b/pulp_rust/app/global_access_conditions.py @@ -0,0 +1,16 @@ +from django.conf import settings + + +def has_distributions_param_model_or_domain_or_obj_perms(request, view, action, permission): + """Check the permission against every distribution in the `distributions` parameter.""" + if request.user.has_perm(permission): + return True + if settings.DOMAIN_ENABLED and request.user.has_perm(permission, obj=request.pulp_domain): + return True + + serializer = view.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + return all( + request.user.has_perm(permission, distribution) + for distribution in serializer.validated_data.get("distributions", []) + ) diff --git a/pulp_rust/app/migrations/0005_rustcargotoken_actions_rustcargotoken_distributions.py b/pulp_rust/app/migrations/0005_rustcargotoken_actions_rustcargotoken_distributions.py new file mode 100644 index 0000000..74c4829 --- /dev/null +++ b/pulp_rust/app/migrations/0005_rustcargotoken_actions_rustcargotoken_distributions.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.14 on 2026-09-29 12:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('rust', '0004_alter_rustcargotoken_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='rustcargotoken', + name='actions', + field=models.JSONField(blank=True, default=list), + ), + migrations.AddField( + model_name='rustcargotoken', + name='distributions', + field=models.ManyToManyField(blank=True, related_name='cargo_tokens', to='rust.rustdistribution'), + ), + ] diff --git a/pulp_rust/app/models.py b/pulp_rust/app/models.py index fa7bbbc..a4b00fa 100755 --- a/pulp_rust/app/models.py +++ b/pulp_rust/app/models.py @@ -4,6 +4,8 @@ from django.conf import settings from django.db import models +from django.db.models.signals import pre_delete +from django.dispatch import receiver from django_lifecycle import AFTER_CREATE, hook from pulpcore.plugin.models import ( @@ -359,6 +361,9 @@ class Meta: ] +CARGO_TOKEN_ACTIONS = ("publish-new", "publish-update", "yank") + + class RustCargoToken(BaseModel): user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="cargo_tokens" @@ -366,6 +371,24 @@ class RustCargoToken(BaseModel): name = models.CharField(max_length=255, blank=False, null=False) token_hash = models.CharField(max_length=64, unique=True, db_index=True) last_used = models.DateTimeField(null=True, blank=True) + distributions = models.ManyToManyField( + RustDistribution, blank=True, related_name="cargo_tokens" + ) + actions = models.JSONField(default=list, blank=True) class Meta: default_related_name = "%(app_label)s_%(model_name)s" + + +@receiver(pre_delete, sender=RustDistribution) +def revoke_emptied_cargo_tokens(instance, **kwargs): + """An emptied scope reads as unrestricted, so revoke rather than let the token widen.""" + # Counted off a pk filter rather than off instance.cargo_tokens, because annotating over a + # relation already filtered on would only count this distribution. + scoped = list(instance.cargo_tokens.values_list("pk", flat=True)) + emptied = ( + RustCargoToken.objects.filter(pk__in=scoped) + .annotate(scope=models.Count("distributions")) + .filter(scope=1) + ) + RustCargoToken.objects.filter(pk__in=list(emptied.values_list("pk", flat=True))).delete() diff --git a/pulp_rust/app/serializers.py b/pulp_rust/app/serializers.py index 510b1ca..cbf79b8 100755 --- a/pulp_rust/app/serializers.py +++ b/pulp_rust/app/serializers.py @@ -318,6 +318,21 @@ class CargoTokenSerializer(core_serializers.ModelSerializer): read_only=True, help_text=_("The token value. Shown once at creation, null otherwise."), ) + distributions = core_serializers.DetailRelatedField( + many=True, + required=False, + view_name_pattern=r"distributions(-.*/.*)-detail", + queryset=models.RustDistribution.objects.all(), + help_text=_( + "Restrict this token to these distributions. Leave empty to allow every " + "distribution the user has access to." + ), + ) + actions = serializers.ListField( + required=False, + child=serializers.ChoiceField(choices=models.CARGO_TOKEN_ACTIONS), + help_text=_("Restrict this token to these actions. Leave empty to allow every action."), + ) class Meta: model = models.RustCargoToken @@ -325,6 +340,8 @@ class Meta: "name", "token", "last_used", + "distributions", + "actions", ) read_only_fields = ("token", "last_used") diff --git a/pulp_rust/app/settings.py b/pulp_rust/app/settings.py index 664dcab..9cdb723 100755 --- a/pulp_rust/app/settings.py +++ b/pulp_rust/app/settings.py @@ -1,3 +1,8 @@ import socket CRATES_IO_API_HOSTNAME = "https://" + socket.getfqdn() + +DRF_ACCESS_POLICY = { + "dynaconf_merge_unique": True, + "reusable_conditions": ["pulp_rust.app.global_access_conditions"], +} diff --git a/pulp_rust/app/views.py b/pulp_rust/app/views.py index 10ead26..482039c 100644 --- a/pulp_rust/app/views.py +++ b/pulp_rust/app/views.py @@ -80,6 +80,22 @@ def repository_write_error(distro): return cargo_error("No repository associated with this distribution", status=404) +def token_scope_error(token, action, distro): + """Return a Cargo error response if the token's scopes forbid the action, else None. + + Scopes only narrow what a token can do, so this runs in addition to the RBAC check + rather than in place of it. An empty scope means the token is not narrowed at all. + """ + if token.actions and action not in token.actions: + return cargo_error(f"this token is not scoped for the {action} action", status=403) + + scoped_distributions = set(token.distributions.values_list("pk", flat=True)) + if scoped_distributions and distro.pk not in scoped_distributions: + return cargo_error("this token is not scoped for this distribution", status=403) + + return None + + class PlainTextRenderer(BaseRenderer): """Renderer for text/plain responses (Cargo sends Accept: text/plain).""" @@ -390,11 +406,14 @@ def put(self, request, **kwargs): canonical = canonicalize_crate_name(name) vers_base = strip_semver_build_metadata(vers) repo_version = distro.repository.latest_version() - if RustContent.objects.filter( - pk__in=repo_version.content, canonical_name=canonical, vers=vers_base - ).exists(): + existing = RustContent.objects.filter(pk__in=repo_version.content, canonical_name=canonical) + if existing.filter(vers=vers_base).exists(): return cargo_error(f"crate version `{name}@{vers}` is already uploaded") + action = "publish-update" if existing.exists() else "publish-new" + if error := token_scope_error(request.auth, action, distro): + return error + # Write the .crate bytes to a temp file — raw bytes can't be passed # through dispatch() because task kwargs are stored as JSON. tmp = tempfile.NamedTemporaryFile(suffix=".crate", delete=False) @@ -490,6 +509,8 @@ def delete(self, request, name, version, rest, **kwargs): distro = self.get_distribution() if not request.user.has_perm("rust.yank_rustdistribution", distro): return cargo_error("insufficient permissions", status=403) + if error := token_scope_error(request.auth, "yank", distro): + return error if error := repository_write_error(distro): return error @@ -532,6 +553,8 @@ def put(self, request, name, version, rest, **kwargs): distro = self.get_distribution() if not request.user.has_perm("rust.yank_rustdistribution", distro): return cargo_error("insufficient permissions", status=403) + if error := token_scope_error(request.auth, "yank", distro): + return error if error := repository_write_error(distro): return error diff --git a/pulp_rust/app/viewsets.py b/pulp_rust/app/viewsets.py index c065de6..62ed7d9 100755 --- a/pulp_rust/app/viewsets.py +++ b/pulp_rust/app/viewsets.py @@ -97,10 +97,19 @@ class CargoTokenViewSet(NamedModelViewSet, CreateModelMixin, ListModelMixin, Des DEFAULT_ACCESS_POLICY = { "statements": [ { - "action": ["create", "list", "retrieve", "destroy"], + "action": ["list", "retrieve", "destroy"], "principal": "authenticated", "effect": "allow", }, + { + "action": ["create"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_distributions_param_model_or_domain_or_obj_perms:" + "rust.view_rustdistribution", + ], + }, ], } diff --git a/pulp_rust/tests/functional/api/test_token_scope.py b/pulp_rust/tests/functional/api/test_token_scope.py new file mode 100644 index 0000000..ffff0a9 --- /dev/null +++ b/pulp_rust/tests/functional/api/test_token_scope.py @@ -0,0 +1,318 @@ +"""Tests for Cargo token scoping. + +A token may be restricted to a set of distributions and a set of actions. Scopes only +narrow what the owner can already do, so these tests use a user with full permissions and +check that the scopes alone are what blocks the request. +""" + +import uuid +from urllib.parse import urljoin + +import pytest +import requests + +from pulpcore.client.pulp_rust.exceptions import ApiException + +from pulp_rust.tests.functional.utils import ( + cargo_unyank, + cargo_yank, + minimal_publish_request, +) + +# --- Action scopes --- + + +def test_publish_scoped_token_cannot_yank( + rust_token_factory, + pulp_admin_user, + populated_repo, +): + """A token scoped to publish should be rejected on yank.""" + token = rust_token_factory(pulp_admin_user, actions=["publish-new", "publish-update"]) + headers = {"Authorization": token.token} + base_url = populated_repo["base_url"] + + response = cargo_yank(base_url, "itoa", "1.0.0", headers=headers) + assert response.status_code == 403 + errors = response.json()["errors"] + assert any("not scoped for the yank action" in e["detail"] for e in errors) + + +def test_yank_scoped_token_cannot_publish( + rust_token_factory, + pulp_admin_user, + rust_repo_factory, + rust_distribution_factory, + cargo_registry_url, +): + """A token scoped to yank should be rejected on publish.""" + token = rust_token_factory(pulp_admin_user, actions=["yank"]) + headers = {"Authorization": token.token} + + repository = rust_repo_factory() + distribution = rust_distribution_factory(repository=repository.pulp_href, allow_uploads=True) + base = cargo_registry_url(distribution.base_path) + + response = minimal_publish_request(base, headers=headers) + assert response.status_code == 403 + errors = response.json()["errors"] + assert any("not scoped for the publish-new action" in e["detail"] for e in errors) + + +@pytest.fixture +def populated_upload_repo( + populated_repo, rust_repo_api_client, rust_distro_api_client, monitor_task +): + """The populated repo (itoa 1.0.0/1.0.1) with uploads turned on. + + A repository keeps its caching remote, which is rejected alongside allow_uploads, so the + remote has to go before the flag can be set. + """ + repo_href = populated_repo["repository"].pulp_href + monitor_task(rust_repo_api_client.partial_update(repo_href, {"remote": None}).task) + distro_href = populated_repo["distribution"].pulp_href + monitor_task(rust_distro_api_client.partial_update(distro_href, {"allow_uploads": True}).task) + return populated_repo + + +def test_publish_new_scope_rejects_a_crate_the_repo_already_has( + rust_token_factory, + pulp_admin_user, + populated_upload_repo, +): + """Adding a version to a crate already in the repo is publish-update, not publish-new.""" + token = rust_token_factory(pulp_admin_user, actions=["publish-new"]) + + response = minimal_publish_request( + populated_upload_repo["base_url"], + headers={"Authorization": token.token}, + name="itoa", + vers="9.9.9", + ) + assert response.status_code == 403 + errors = response.json()["errors"] + assert any("not scoped for the publish-update action" in e["detail"] for e in errors) + + +def test_publish_update_scope_rejects_an_unknown_crate( + rust_token_factory, + pulp_admin_user, + populated_upload_repo, +): + """A crate name the repo has never seen is publish-new, not publish-update.""" + token = rust_token_factory(pulp_admin_user, actions=["publish-update"]) + + response = minimal_publish_request( + populated_upload_repo["base_url"], + headers={"Authorization": token.token}, + name="never-seen-here", + ) + assert response.status_code == 403 + errors = response.json()["errors"] + assert any("not scoped for the publish-new action" in e["detail"] for e in errors) + + +def test_yank_scoped_token_can_yank_and_unyank( + rust_token_factory, + pulp_admin_user, + populated_repo, +): + """A token scoped to yank should allow both yank and unyank.""" + token = rust_token_factory(pulp_admin_user, actions=["yank"]) + headers = {"Authorization": token.token} + base_url = populated_repo["base_url"] + + response = cargo_yank(base_url, "itoa", "1.0.0", headers=headers) + assert response.status_code == 200 + + response = cargo_unyank(base_url, "itoa", "1.0.0", headers=headers) + assert response.status_code == 200 + + +def test_unscoped_token_allows_every_action( + rust_token_factory, + pulp_admin_user, + populated_repo, +): + """A token created without scopes should not be narrowed at all.""" + token = rust_token_factory(pulp_admin_user) + headers = {"Authorization": token.token} + base_url = populated_repo["base_url"] + + response = cargo_yank(base_url, "itoa", "1.0.0", headers=headers) + assert response.status_code == 200 + + +def test_unknown_action_rejected(pulp_api_v3_url, bindings_cfg): + """Creating a token with an action outside the known set should be rejected. + + Sent as a raw request because the generated client rejects the value locally. + """ + response = requests.post( + urljoin(pulp_api_v3_url, "cargo/tokens/"), + json={"name": str(uuid.uuid4()), "actions": ["delete-everything"]}, + auth=(bindings_cfg.username, bindings_cfg.password), + ) + assert response.status_code == 400 + assert "actions" in response.json() + + +# --- Distribution scopes --- + + +def test_token_rejected_on_unscoped_distribution( + rust_token_factory, + pulp_admin_user, + rust_repo_factory, + rust_distribution_factory, + cargo_registry_url, +): + """A token scoped to one distribution should be rejected on another.""" + scoped_distribution = rust_distribution_factory( + repository=rust_repo_factory().pulp_href, allow_uploads=True + ) + other_distribution = rust_distribution_factory( + repository=rust_repo_factory().pulp_href, allow_uploads=True + ) + + token = rust_token_factory(pulp_admin_user, distributions=[scoped_distribution.pulp_href]) + headers = {"Authorization": token.token} + + base = cargo_registry_url(other_distribution.base_path) + response = minimal_publish_request(base, headers=headers) + assert response.status_code == 403 + errors = response.json()["errors"] + assert any("not scoped for this distribution" in e["detail"] for e in errors) + + +def test_token_accepted_on_scoped_distribution( + rust_token_factory, + pulp_admin_user, + rust_repo_factory, + rust_distribution_factory, + cargo_registry_url, +): + """A token scoped to a distribution should be accepted on that distribution.""" + distribution = rust_distribution_factory( + repository=rust_repo_factory().pulp_href, allow_uploads=True + ) + token = rust_token_factory(pulp_admin_user, distributions=[distribution.pulp_href]) + headers = {"Authorization": token.token} + + base = cargo_registry_url(distribution.base_path) + response = minimal_publish_request(base, headers=headers) + # The crate payload is fake, so this fails validation rather than scoping. + assert response.status_code != 403 + + +def test_token_can_be_scoped_to_several_distributions( + rust_token_factory, + pulp_admin_user, + rust_repo_factory, + rust_distribution_factory, + cargo_registry_url, +): + """A token scoped to several distributions should be accepted on each of them.""" + first = rust_distribution_factory(repository=rust_repo_factory().pulp_href, allow_uploads=True) + second = rust_distribution_factory(repository=rust_repo_factory().pulp_href, allow_uploads=True) + token = rust_token_factory(pulp_admin_user, distributions=[first.pulp_href, second.pulp_href]) + headers = {"Authorization": token.token} + + for distribution in (first, second): + response = minimal_publish_request( + cargo_registry_url(distribution.base_path), headers=headers + ) + # The crate payload is fake, so this fails validation rather than scoping. + assert response.status_code != 403 + + +def test_token_is_revoked_when_its_last_distribution_is_deleted( + rust_token_factory, + pulp_admin_user, + rust_repo_factory, + rust_distribution_factory, + rust_distro_api_client, + cargo_registry_url, + monitor_task, +): + """Losing the last scoped distribution should revoke the token rather than widen it.""" + first, second, other = ( + rust_distribution_factory(repository=rust_repo_factory().pulp_href, allow_uploads=True) + for _ in range(3) + ) + token = rust_token_factory(pulp_admin_user, distributions=[first.pulp_href, second.pulp_href]) + headers = {"Authorization": token.token} + + monitor_task(rust_distro_api_client.delete(first.pulp_href).task) + + # Part of the scope survives, so the token is still narrowed to it. + kept = minimal_publish_request(cargo_registry_url(second.base_path), headers=headers) + assert kept.status_code != 403 + rejected = minimal_publish_request(cargo_registry_url(other.base_path), headers=headers) + assert rejected.status_code == 403 + + monitor_task(rust_distro_api_client.delete(second.pulp_href).task) + + # The scope is empty now, so the token is gone instead of applying to everything. + revoked = minimal_publish_request(cargo_registry_url(other.base_path), headers=headers) + assert revoked.status_code == 401 + + +def test_scopes_are_readable_on_the_token( + rust_token_factory, + pulp_admin_user, + rust_repo_factory, + rust_distribution_factory, +): + """Scopes set at creation should come back on the token.""" + distribution = rust_distribution_factory(repository=rust_repo_factory().pulp_href) + token = rust_token_factory( + pulp_admin_user, distributions=[distribution.pulp_href], actions=["publish-new"] + ) + + assert token.distributions == [distribution.pulp_href] + assert token.actions == ["publish-new"] + + +# --- Scopes narrow, they never grant --- + + +def test_scoped_token_still_needs_permission( + gen_user, + rust_token_factory, + rust_repo_factory, + rust_distro_api_client, + rust_distribution_factory, + cargo_registry_url, +): + """Scoping a token to a distribution must not grant access the user lacks.""" + alice = gen_user() + distribution = rust_distribution_factory( + repository=rust_repo_factory().pulp_href, allow_uploads=True + ) + rust_distro_api_client.add_role( + distribution.pulp_href, + {"role": "rust.rustdistribution_viewer", "users": [alice.username]}, + ) + token = rust_token_factory(alice, distributions=[distribution.pulp_href]) + + # Alice can see the distribution, but seeing it is not permission to publish to it. + base = cargo_registry_url(distribution.base_path) + response = minimal_publish_request(base, headers={"Authorization": token.token}) + assert response.status_code == 403 + + +def test_cannot_scope_to_an_invisible_distribution( + gen_user, + rust_token_factory, + rust_repo_factory, + rust_distribution_factory, +): + """A distribution the user cannot see should not be selectable as a scope.""" + alice = gen_user() + distribution = rust_distribution_factory(repository=rust_repo_factory().pulp_href) + + with pytest.raises(ApiException) as exc: + rust_token_factory(alice, distributions=[distribution.pulp_href]) + + assert exc.value.status == 403 diff --git a/pulp_rust/tests/functional/utils.py b/pulp_rust/tests/functional/utils.py index 48da5c3..90aa583 100644 --- a/pulp_rust/tests/functional/utils.py +++ b/pulp_rust/tests/functional/utils.py @@ -106,13 +106,13 @@ def cargo_publish(url, metadata, crate_bytes, content_type=None, headers=None): ) -def minimal_publish_request(url, headers=None): +def minimal_publish_request(url, headers=None, name="fake", vers="0.0.1"): """Send a minimal publish request to the Cargo publish endpoint. Builds a tiny fake crate body — useful for testing auth and validation without needing a real .crate file. """ - metadata = {"name": "fake", "vers": "0.0.1", "deps": [], "features": {}} + metadata = {"name": name, "vers": vers, "deps": [], "features": {}} return cargo_api_request( "PUT", f"{url}api/v1/crates/new",