From 2a1b868bbe6aae7dbaf0630bb9fac72fb3c9f025 Mon Sep 17 00:00:00 2001 From: Aliaksei Klimau Date: Wed, 30 Sep 2026 17:02:22 +0200 Subject: [PATCH] Add distribution and action scoping to Cargo tokens closes #48 Co-Authored-By: Claude Opus 5 --- CHANGES/48.feature | 1 + docs/user/guides/authentication.md | 32 ++- pulp_rust/app/global_access_conditions.py | 16 ++ ...en_actions_rustcargotoken_distributions.py | 23 ++ pulp_rust/app/models.py | 29 ++ pulp_rust/app/serializers.py | 17 ++ pulp_rust/app/settings.py | 5 + pulp_rust/app/views.py | 23 ++ pulp_rust/app/viewsets.py | 11 +- .../tests/functional/api/test_token_scope.py | 265 ++++++++++++++++++ 10 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 CHANGES/48.feature create mode 100644 pulp_rust/app/global_access_conditions.py create mode 100644 pulp_rust/app/migrations/0005_rustcargotoken_actions_rustcargotoken_distributions.py create mode 100644 pulp_rust/tests/functional/api/test_token_scope.py 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..34d013e 100644 --- a/docs/user/guides/authentication.md +++ b/docs/user/guides/authentication.md @@ -37,6 +37,36 @@ 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"]' +``` + +The available actions are `publish` and `yank`, where `yank` covers both yanking and +unyanking. 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 +115,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..a845a3c 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 post_delete, 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", "yank") + + class RustCargoToken(BaseModel): user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="cargo_tokens" @@ -366,6 +371,30 @@ 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 remember_scoped_cargo_tokens(instance, **kwargs): + """Record the tokens scoped to this distribution before the m2m rows are cascaded away.""" + instance._scoped_cargo_token_pks = list(instance.cargo_tokens.values_list("pk", flat=True)) + + +@receiver(post_delete, sender=RustDistribution) +def revoke_emptied_cargo_tokens(instance, **kwargs): + """Revoke tokens whose distribution scope was emptied by this deletion. + + An empty scope means unrestricted, so a token that loses its last distribution would + silently widen to everything its owner can reach. Revoking it keeps the deletion from + granting the bearer reach the owner never delegated. + """ + token_pks = getattr(instance, "_scoped_cargo_token_pks", None) + if not token_pks: + return + RustCargoToken.objects.filter(pk__in=token_pks, distributions__isnull=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..8cc7d0d 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).""" @@ -359,6 +375,9 @@ def put(self, request, **kwargs): if not request.user.has_perm("rust.publish_rustdistribution", distro): return cargo_error("insufficient permissions", status=403) + if error := token_scope_error(request.auth, "publish", distro): + return error + if not distro.allow_uploads: return cargo_error("this registry does not allow uploads", status=403) @@ -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..92dd6b3 --- /dev/null +++ b/pulp_rust/tests/functional/api/test_token_scope.py @@ -0,0 +1,265 @@ +"""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"]) + 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 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"] + ) + + assert token.distributions == [distribution.pulp_href] + assert token.actions == ["publish"] + + +# --- 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