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
3 changes: 3 additions & 0 deletions CHANGES/24.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Added per-user Cargo token authentication for Cargo API endpoints (publish, yank, unyank, /me),
replacing the hardcoded stub token. Tokens are created via the REST API and sent by Cargo
in the `Authorization` header.
52 changes: 22 additions & 30 deletions pulp_rust/app/auth.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,29 @@
"""Stub authentication for Cargo API endpoints.
"""Cargo token authentication for Cargo API endpoints."""

This is a temporary placeholder — it validates the Authorization header against
a hardcoded token so that state-changing endpoints (publish, yank, unyank) are
not completely open. It will be replaced by proper token-based auth later.
"""
import hashlib

import functools
import json
from django.utils import timezone
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed

from django.http import HttpResponse
from pulp_rust.app.models import RustCargoToken

STUB_TOKEN = "i_understand_that_pulp_rust_does_not_support_proper_auth_yet"

class CargoTokenAuthentication(BaseAuthentication):
"""Authenticate Cargo requests via the Authorization header token."""

def require_cargo_token(view_method):
"""Decorator that validates the Cargo Authorization header against the stub token.

Returns a 403 with a Cargo-style JSON error if the token is missing or incorrect.
"""

@functools.wraps(view_method)
def wrapper(self, request, *args, **kwargs):
def authenticate(self, request):
token = request.META.get("HTTP_AUTHORIZATION")
if token == STUB_TOKEN:
return view_method(self, request, *args, **kwargs)
if not token:
detail = "this endpoint requires an authorization token"
else:
detail = "invalid authorization token"
return HttpResponse(
json.dumps({"errors": [{"detail": detail}]}),
content_type="application/json",
status=403,
)

return wrapper
if not token or not token.startswith("crg_"):
return None
token_hash = hashlib.sha256(token.encode()).hexdigest()
try:
cargo_token = RustCargoToken.objects.select_related("user").get(token_hash=token_hash)
except RustCargoToken.DoesNotExist:
raise AuthenticationFailed("invalid cargo token")
cargo_token.last_used = timezone.now()
cargo_token.save(update_fields=["last_used"])
return (cargo_token.user, cargo_token)

def authenticate_header(self, request):
return "CargoToken"
34 changes: 34 additions & 0 deletions pulp_rust/app/migrations/0003_rustcargotoken.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Generated by Django 5.2.14 on 2026-07-07 12:33

import django.db.models.deletion
import django_lifecycle.mixins
import pulpcore.app.models.base
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('rust', '0002_add_rbac'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='RustCargoToken',
fields=[
('pulp_id', models.UUIDField(default=pulpcore.app.models.base.pulp_uuid, editable=False, primary_key=True, serialize=False)),
('pulp_created', models.DateTimeField(auto_now_add=True)),
('pulp_last_updated', models.DateTimeField(auto_now=True, null=True)),
('name', models.CharField(max_length=255)),
('token_hash', models.CharField(db_index=True, max_length=64, unique=True)),
('last_used', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cargo_tokens', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 5.2.14 on 2026-07-13 08:54

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('rust', '0003_rustcargotoken'),
]

operations = [
migrations.AlterModelOptions(
name='rustcargotoken',
options={'default_related_name': '%(app_label)s_%(model_name)s'},
),
migrations.AlterModelOptions(
name='rustdistribution',
options={'default_related_name': '%(app_label)s_%(model_name)s', 'permissions': [('manage_roles_rustdistribution', 'Can manage roles on rust distributions'), ('publish_rustdistribution', 'Can publish crates to this distribution'), ('yank_rustdistribution', 'Can yank/unyank crates in this distribution')]},
),
]
16 changes: 16 additions & 0 deletions pulp_rust/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
import urllib.request
from logging import getLogger

from django.conf import settings
from django.db import models
from django_lifecycle import AFTER_CREATE, hook

from pulpcore.plugin.models import (
AutoAddObjPermsMixin,
BaseModel,
Content,
Distribution,
Remote,
Expand Down Expand Up @@ -352,4 +354,18 @@ class Meta:
default_related_name = "%(app_label)s_%(model_name)s"
permissions = [
("manage_roles_rustdistribution", "Can manage roles on rust distributions"),
("publish_rustdistribution", "Can publish crates to this distribution"),
("yank_rustdistribution", "Can yank/unyank crates in this distribution"),
]


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)

class Meta:
default_related_name = "%(app_label)s_%(model_name)s"
16 changes: 16 additions & 0 deletions pulp_rust/app/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,22 @@ class Meta:
model = models.RustDistribution


class CargoTokenSerializer(core_serializers.ModelSerializer):
pulp_href = core_serializers.IdentityField(view_name="cargo/tokens-detail")
token = serializers.CharField(
read_only=True, help_text=_("The token value. Shown once at creation.")
)

class Meta:
model = models.RustCargoToken
fields = core_serializers.ModelSerializer.Meta.fields + (
"name",
"token",
"last_used",
)
read_only_fields = ("token", "last_used")


class YankSerializer(serializers.Serializer):
"""Serializer for yank/unyank operations on a repository."""

Expand Down
41 changes: 27 additions & 14 deletions pulp_rust/app/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@
from drf_spectacular.utils import extend_schema
from dynaconf import settings
from rest_framework.exceptions import Throttled
from rest_framework.permissions import IsAuthenticated
from rest_framework.renderers import BaseRenderer, JSONRenderer
from rest_framework.views import APIView
from rest_framework.viewsets import ViewSet

from pulpcore.plugin.tasking import dispatch
from pulpcore.plugin.util import get_domain

from pulp_rust.app.auth import require_cargo_token
from pulp_rust.app.auth import CargoTokenAuthentication
from pulp_rust.app.models import (
RustContent,
RustDistribution,
Expand Down Expand Up @@ -292,11 +293,10 @@ class CargoMeApiView(APIView):
See: https://doc.rust-lang.org/cargo/reference/registry-web-api.html
"""

authentication_classes = []
permission_classes = []
authentication_classes = [CargoTokenAuthentication]
permission_classes = [IsAuthenticated]
renderer_classes = [JSONRenderer]

@require_cargo_token
def get(self, request, **kwargs):
return HttpResponse(json.dumps({"ok": True}), content_type="application/json")

Expand All @@ -311,10 +311,8 @@ class CargoPublishApiView(APIView):
See: https://doc.rust-lang.org/cargo/reference/registry-web-api.html#publish
"""

# Authentication uses a stub token via @require_cargo_token decorator.
# TODO: Replace with proper per-user token auth and RBAC integration.
authentication_classes = []
permission_classes = []
authentication_classes = [CargoTokenAuthentication]
permission_classes = [IsAuthenticated]
renderer_classes = [JSONRenderer]

def get_distribution(self):
Expand All @@ -330,7 +328,6 @@ def _error_response(detail, status=400):
status=status,
)

@require_cargo_token
def put(self, request, **kwargs):
"""
Handle ``cargo publish`` requests.
Expand All @@ -341,6 +338,9 @@ def put(self, request, **kwargs):
"""
distro = self.get_distribution()

if not request.user.has_perm("rust.publish_rustdistribution", distro):
return self._error_response("insufficient permissions", status=403)

if not distro.allow_uploads:
return self._error_response("this registry does not allow uploads", status=403)

Expand Down Expand Up @@ -419,11 +419,22 @@ class CargoDownloadApiView(APIView):
View for Cargo's crate download, readme, yank, and unyank endpoints.
"""

# Authentication disabled for now
authentication_classes = []
permission_classes = []
authentication_classes = [CargoTokenAuthentication]
renderer_classes = [PlainTextRenderer, JSONRenderer]

def get_permissions(self):
if self.request.method in ("DELETE", "PUT"):
return [IsAuthenticated()]
return []

@staticmethod
def _error_response(detail, status=400):
return HttpResponse(
json.dumps({"errors": [{"detail": detail}]}),
content_type="application/json",
status=status,
)

def get_full_path(self, base_path, pulp_domain=None):
if settings.DOMAIN_ENABLED:
domain = pulp_domain or get_domain()
Expand Down Expand Up @@ -458,7 +469,6 @@ def get(self, request, name, version, rest, **kwargs):
else:
raise Http404(f"Unknown action: {rest}")

@require_cargo_token
def delete(self, request, name, version, rest, **kwargs):
"""
Responds to DELETE requests for yanking crate versions.
Expand All @@ -470,6 +480,8 @@ def delete(self, request, name, version, rest, **kwargs):
raise Http404(f"Unknown action: {rest}")

distro = self.get_distribution()
if not request.user.has_perm("rust.yank_rustdistribution", distro):
return self._error_response("insufficient permissions", status=403)
if not distro.repository:
raise Http404("No repository associated with this distribution")

Expand Down Expand Up @@ -499,7 +511,6 @@ def delete(self, request, name, version, rest, **kwargs):
has_task_completed(task)
return HttpResponse(json.dumps({"ok": True}), content_type="application/json")

@require_cargo_token
def put(self, request, name, version, rest, **kwargs):
"""
Responds to PUT requests for unyanking crate versions.
Expand All @@ -511,6 +522,8 @@ def put(self, request, name, version, rest, **kwargs):
raise Http404(f"Unknown action: {rest}")

distro = self.get_distribution()
if not request.user.has_perm("rust.yank_rustdistribution", distro):
return self._error_response("insufficient permissions", status=403)
if not distro.repository:
raise Http404("No repository associated with this distribution")

Expand Down
45 changes: 44 additions & 1 deletion pulp_rust/app/viewsets.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import hashlib
import secrets

from django_filters import CharFilter
from drf_spectacular.utils import extend_schema
from rest_framework.decorators import action
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin, ListModelMixin
from rest_framework.response import Response

from pulpcore.plugin import viewsets as core
from pulpcore.plugin.actions import ModifyRepositoryActionMixin
Expand All @@ -9,7 +14,7 @@
RepositorySyncURLSerializer,
)
from pulpcore.plugin.tasking import dispatch
from pulpcore.plugin.viewsets import RemoteFilter
from pulpcore.plugin.viewsets import NamedModelViewSet, RemoteFilter

from . import models, serializers, tasks

Expand Down Expand Up @@ -82,6 +87,37 @@ class Meta:
]


class CargoTokenViewSet(NamedModelViewSet, CreateModelMixin, ListModelMixin, DestroyModelMixin):
"""Manage Cargo API tokens for the current user."""

endpoint_name = "cargo/tokens"
queryset = models.RustCargoToken.objects.all()
serializer_class = serializers.CargoTokenSerializer

DEFAULT_ACCESS_POLICY = {
"statements": [
{
"action": ["create", "list", "retrieve", "destroy"],
"principal": "authenticated",
"effect": "allow",
},
],
}

def get_queryset(self):
return super().get_queryset().filter(user=self.request.user)

def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
raw_token = f"crg_{secrets.token_hex(20)}"
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
serializer.save(user=request.user, token_hash=token_hash)
data = serializer.data
data["token"] = raw_token
return Response(data, status=201)


class RustRemoteViewSet(core.RemoteViewSet, core.RolesMixin):
"""
A ViewSet for RustRemote.
Expand Down Expand Up @@ -425,6 +461,13 @@ class RustDistributionViewSet(core.DistributionViewSet, core.RolesMixin):
"rust.change_rustdistribution",
"rust.delete_rustdistribution",
"rust.manage_roles_rustdistribution",
"rust.publish_rustdistribution",
"rust.yank_rustdistribution",
],
"rust.rustdistribution_publisher": [
"rust.view_rustdistribution",
"rust.publish_rustdistribution",
"rust.yank_rustdistribution",
],
"rust.rustdistribution_viewer": ["rust.view_rustdistribution"],
}
Loading
Loading