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/48.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Cargo tokens can now be restricted to specific distributions and actions.
32 changes: 31 additions & 1 deletion docs/user/guides/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-host>/pulp/api/v3/cargo/tokens/ \
-a alice:password \
name="ci-publish-only" \
distributions:='["/pulp/api/v3/distributions/rust/rust/<uuid>/"]' \
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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions pulp_rust/app/global_access_conditions.py
Original file line number Diff line number Diff line change
@@ -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", [])
)
Original file line number Diff line number Diff line change
@@ -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'),
),
]
29 changes: 29 additions & 0 deletions pulp_rust/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -359,13 +361,40 @@ 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"
)
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()
17 changes: 17 additions & 0 deletions pulp_rust/app/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,13 +318,30 @@ 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
fields = core_serializers.ModelSerializer.Meta.fields + (
"name",
"token",
"last_used",
"distributions",
"actions",
)
read_only_fields = ("token", "last_used")

Expand Down
5 changes: 5 additions & 0 deletions pulp_rust/app/settings.py
Original file line number Diff line number Diff line change
@@ -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"],
}
23 changes: 23 additions & 0 deletions pulp_rust/app/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
11 changes: 10 additions & 1 deletion pulp_rust/app/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
},
],
}

Expand Down
Loading
Loading