Skip to content
Merged
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.
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ See the [REST API documentation](site:pulp_rust/restapi/) for detailed endpoint
- [Use Pulp as a pull-through cache](site:pulp_rust/docs/user/guides/pull-through-cache/) for crates.io or any Cargo sparse registry
- [Host a private Cargo registry](site:pulp_rust/docs/user/guides/private-registry/) for internal crates
- Publish crates with `cargo publish` and manage them with `cargo yank`
- [Per-user token authentication](site:pulp_rust/docs/user/guides/authentication/) for Cargo API endpoints with distribution-scoped access control
- Implements the [Cargo sparse registry protocol](https://doc.rust-lang.org/cargo/reference/registry-index.html#sparse-index) for compatibility with standard Cargo tooling
- Download crates on-demand to reduce disk usage
- Every operation creates a restorable snapshot with Versioned Repositories
Expand Down
1 change: 1 addition & 0 deletions docs/user/guides/_SUMMARY.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
* [Pull-Through Cache](pull-through-cache.md)
* [Host a Private Registry](private-registry.md)
* [Authentication & Authorization](authentication.md)
93 changes: 93 additions & 0 deletions docs/user/guides/authentication.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Authentication & Authorization

Pulp Rust uses per-user API tokens for Cargo protocol endpoints (publish, yank, unyank) and
Pulp's standard RBAC system for controlling access to repositories, remotes, and distributions.

## Cargo Token Authentication

### Creating a Token

Create a token via the Pulp REST API using your Pulp credentials:

```bash
http POST http://<pulp-host>/pulp/api/v3/cargo/tokens/ \
-a admin:password \
name="my-laptop"
```

The response includes the token value (prefixed with `crg_`). This is shown **once** -- it
cannot be retrieved again. Store it securely.

### Using with Cargo

Pass the token to Cargo via `cargo login`:

```bash
cargo login --registry my-crates
# Paste the crg_... token when prompted
```

Or set it directly in `~/.cargo/credentials.toml`:

```toml
[registries.my-crates]
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.

### Managing Tokens

```bash
# List your tokens (token values are not shown)
http GET http://<pulp-host>/pulp/api/v3/cargo/tokens/ -a user:password

# Revoke a token
http DELETE http://<pulp-host>/pulp/api/v3/cargo/tokens/<token-uuid>/ -a user:password
```

Users can only see and revoke their own tokens.

## Distribution-Scoped Permissions

Access to publish and yank is controlled per-distribution using Pulp's RBAC system. A user
needs the appropriate role on a distribution before they can publish or yank crates through it.

### Roles

| Role | Permissions |
|------|-------------|
| `rust.rustdistribution_owner` | Full control: view, change, delete, manage roles, publish, yank |
| `rust.rustdistribution_publisher` | Publish crates and yank/unyank versions |
| `rust.rustdistribution_viewer` | View the distribution |

The user who creates a distribution automatically receives the `owner` role on it.

### Granting Access

Grant a user permission to publish to a specific distribution:

```bash
http POST http://<pulp-host>/pulp/api/v3/distributions/rust/rust/<uuid>/add_role/ \
-a admin:password \
role="rust.rustdistribution_publisher" \
users:='["alice"]'
```

Alice can now publish and yank crates on that distribution using her Cargo token.

## Differences from crates.io

| Feature | crates.io | Pulp Rust |
|---------|-----------|-----------|
| Access scope | Per-crate ownership | Per-distribution |
| 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 |

!!! warning "No per-crate ownership"
Pulp Rust currently controls access at the distribution level, not per-crate. Any user with
the `publisher` role on a distribution can publish any crate name to it. Per-crate ownership
(where only the crate's owner can publish new versions) is planned for a future release.
21 changes: 3 additions & 18 deletions docs/user/guides/private-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,24 +36,9 @@ index = "sparse+http://<pulp-host>/pulp/cargo/my-crates/"

## Authentication

State-changing operations (publishing, yanking, and unyanking) require an authorization token.
Configure the token for your registry in `~/.cargo/credentials.toml`:

```toml
[registries.my-crates]
token = "i_understand_that_pulp_rust_does_not_support_proper_auth_yet"
```

Alternatively, you can pass the token on the command line:

```bash
cargo publish --registry my-crates --token "i_understand_that_pulp_rust_does_not_support_proper_auth_yet"
```

!!! warning
This is a temporary stub token. Proper token-based authentication is planned for a future
release. The stub token exists to ensure that the authentication workflow is exercised and that
state-changing operations are not completely open.
State-changing operations (publishing, yanking, and unyanking) require a Cargo API token.
See the [Authentication & Authorization](authentication.md) guide for how to create tokens
and manage access.

Read-only operations (downloading crates, browsing the index) do not require a token.

Expand Down
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"
17 changes: 17 additions & 0 deletions pulp_rust/app/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,23 @@ 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, null otherwise."),
)

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

@dralley dralley Jul 28, 2026

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 isn't displaying the token values to any API user, right? Even the user that created it should probably not be able to see it again after initial creation.

It also doesn't map directly to any field name

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.

Yes, in this implementation there is no way to retrieve token value. It's shown just once, right after creation in the response. So this field exists only for this one response. Any reads on this field will return None.

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.

So you're saying that the token value is filled out on creation, but afterwards (list, detail, etc.) it shows as None?

Do you think it would be better to use two serializers so that the field is ommitted entirely in the post-creation lookup cases, rather than set null?

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.

Yeah I guess you are right, that makes more sense

"last_used",
)
read_only_fields = ("token", "last_used")


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

Expand Down
Loading
Loading