Skip to content

Commit 5e4e272

Browse files
aKlimauclaude
authored andcommitted
Add per-user Cargo token authentication
closes: #24 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dba2766 commit 5e4e272

18 files changed

Lines changed: 619 additions & 223 deletions

CHANGES/24.feature

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Added per-user Cargo token authentication for Cargo API endpoints (publish, yank, unyank, /me),
2+
replacing the hardcoded stub token. Tokens are created via the REST API and sent by Cargo
3+
in the `Authorization` header.

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ See the [REST API documentation](site:pulp_rust/restapi/) for detailed endpoint
1414
- [Use Pulp as a pull-through cache](site:pulp_rust/docs/user/guides/pull-through-cache/) for crates.io or any Cargo sparse registry
1515
- [Host a private Cargo registry](site:pulp_rust/docs/user/guides/private-registry/) for internal crates
1616
- Publish crates with `cargo publish` and manage them with `cargo yank`
17+
- [Per-user token authentication](site:pulp_rust/docs/user/guides/authentication/) for Cargo API endpoints with distribution-scoped access control
1718
- Implements the [Cargo sparse registry protocol](https://doc.rust-lang.org/cargo/reference/registry-index.html#sparse-index) for compatibility with standard Cargo tooling
1819
- Download crates on-demand to reduce disk usage
1920
- Every operation creates a restorable snapshot with Versioned Repositories

docs/user/guides/_SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
* [Pull-Through Cache](pull-through-cache.md)
22
* [Host a Private Registry](private-registry.md)
3+
* [Authentication & Authorization](authentication.md)

docs/user/guides/authentication.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# Authentication & Authorization
2+
3+
Pulp Rust uses per-user API tokens for Cargo protocol endpoints (publish, yank, unyank) and
4+
Pulp's standard RBAC system for controlling access to repositories, remotes, and distributions.
5+
6+
## Cargo Token Authentication
7+
8+
### Creating a Token
9+
10+
Create a token via the Pulp REST API using your Pulp credentials:
11+
12+
```bash
13+
http POST http://<pulp-host>/pulp/api/v3/cargo/tokens/ \
14+
-a admin:password \
15+
name="my-laptop"
16+
```
17+
18+
The response includes the token value (prefixed with `crg_`). This is shown **once** -- it
19+
cannot be retrieved again. Store it securely.
20+
21+
### Using with Cargo
22+
23+
Pass the token to Cargo via `cargo login`:
24+
25+
```bash
26+
cargo login --registry my-crates
27+
# Paste the crg_... token when prompted
28+
```
29+
30+
Or set it directly in `~/.cargo/credentials.toml`:
31+
32+
```toml
33+
[registries.my-crates]
34+
token = "crg_..."
35+
```
36+
37+
Cargo sends the token automatically on state-changing operations (publish, yank, unyank).
38+
Read-only operations (downloading crates, browsing the index) do not require a token.
39+
40+
### Managing Tokens
41+
42+
```bash
43+
# List your tokens (token values are not shown)
44+
http GET http://<pulp-host>/pulp/api/v3/cargo/tokens/ -a user:password
45+
46+
# Revoke a token
47+
http DELETE http://<pulp-host>/pulp/api/v3/cargo/tokens/<token-uuid>/ -a user:password
48+
```
49+
50+
Users can only see and revoke their own tokens.
51+
52+
## Distribution-Scoped Permissions
53+
54+
Access to publish and yank is controlled per-distribution using Pulp's RBAC system. A user
55+
needs the appropriate role on a distribution before they can publish or yank crates through it.
56+
57+
### Roles
58+
59+
| Role | Permissions |
60+
|------|-------------|
61+
| `rust.rustdistribution_owner` | Full control: view, change, delete, manage roles, publish, yank |
62+
| `rust.rustdistribution_publisher` | Publish crates and yank/unyank versions |
63+
| `rust.rustdistribution_viewer` | View the distribution |
64+
65+
The user who creates a distribution automatically receives the `owner` role on it.
66+
67+
### Granting Access
68+
69+
Grant a user permission to publish to a specific distribution:
70+
71+
```bash
72+
http POST http://<pulp-host>/pulp/api/v3/distributions/rust/rust/<uuid>/add_role/ \
73+
-a admin:password \
74+
role="rust.rustdistribution_publisher" \
75+
users:='["alice"]'
76+
```
77+
78+
Alice can now publish and yank crates on that distribution using her Cargo token.
79+
80+
## Differences from crates.io
81+
82+
| Feature | crates.io | Pulp Rust |
83+
|---------|-----------|-----------|
84+
| Access scope | Per-crate ownership | Per-distribution |
85+
| Owner management | `cargo owner --add` | Pulp REST API role assignment |
86+
| Token creation | Web UI at crates.io | Pulp REST API |
87+
| Per-crate ownership | Yes (user and team owners) | Not supported (planned) |
88+
| Token scoping | Scoped to endpoints/crates | Not yet supported |
89+
90+
!!! warning "No per-crate ownership"
91+
Pulp Rust currently controls access at the distribution level, not per-crate. Any user with
92+
the `publisher` role on a distribution can publish any crate name to it. Per-crate ownership
93+
(where only the crate's owner can publish new versions) is planned for a future release.

docs/user/guides/private-registry.md

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -36,24 +36,9 @@ index = "sparse+http://<pulp-host>/pulp/cargo/my-crates/"
3636

3737
## Authentication
3838

39-
State-changing operations (publishing, yanking, and unyanking) require an authorization token.
40-
Configure the token for your registry in `~/.cargo/credentials.toml`:
41-
42-
```toml
43-
[registries.my-crates]
44-
token = "i_understand_that_pulp_rust_does_not_support_proper_auth_yet"
45-
```
46-
47-
Alternatively, you can pass the token on the command line:
48-
49-
```bash
50-
cargo publish --registry my-crates --token "i_understand_that_pulp_rust_does_not_support_proper_auth_yet"
51-
```
52-
53-
!!! warning
54-
This is a temporary stub token. Proper token-based authentication is planned for a future
55-
release. The stub token exists to ensure that the authentication workflow is exercised and that
56-
state-changing operations are not completely open.
39+
State-changing operations (publishing, yanking, and unyanking) require a Cargo API token.
40+
See the [Authentication & Authorization](authentication.md) guide for how to create tokens
41+
and manage access.
5742

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

pulp_rust/app/auth.py

Lines changed: 22 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,29 @@
1-
"""Stub authentication for Cargo API endpoints.
1+
"""Cargo token authentication for Cargo API endpoints."""
22

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

8-
import functools
9-
import json
5+
from django.utils import timezone
6+
from rest_framework.authentication import BaseAuthentication
7+
from rest_framework.exceptions import AuthenticationFailed
108

11-
from django.http import HttpResponse
9+
from pulp_rust.app.models import RustCargoToken
1210

13-
STUB_TOKEN = "i_understand_that_pulp_rust_does_not_support_proper_auth_yet"
1411

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

16-
def require_cargo_token(view_method):
17-
"""Decorator that validates the Cargo Authorization header against the stub token.
18-
19-
Returns a 403 with a Cargo-style JSON error if the token is missing or incorrect.
20-
"""
21-
22-
@functools.wraps(view_method)
23-
def wrapper(self, request, *args, **kwargs):
15+
def authenticate(self, request):
2416
token = request.META.get("HTTP_AUTHORIZATION")
25-
if token == STUB_TOKEN:
26-
return view_method(self, request, *args, **kwargs)
27-
if not token:
28-
detail = "this endpoint requires an authorization token"
29-
else:
30-
detail = "invalid authorization token"
31-
return HttpResponse(
32-
json.dumps({"errors": [{"detail": detail}]}),
33-
content_type="application/json",
34-
status=403,
35-
)
36-
37-
return wrapper
17+
if not token or not token.startswith("crg_"):
18+
return None
19+
token_hash = hashlib.sha256(token.encode()).hexdigest()
20+
try:
21+
cargo_token = RustCargoToken.objects.select_related("user").get(token_hash=token_hash)
22+
except RustCargoToken.DoesNotExist:
23+
raise AuthenticationFailed("invalid cargo token")
24+
cargo_token.last_used = timezone.now()
25+
cargo_token.save(update_fields=["last_used"])
26+
return (cargo_token.user, cargo_token)
27+
28+
def authenticate_header(self, request):
29+
return "CargoToken"
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Generated by Django 5.2.14 on 2026-07-07 12:33
2+
3+
import django.db.models.deletion
4+
import django_lifecycle.mixins
5+
import pulpcore.app.models.base
6+
from django.conf import settings
7+
from django.db import migrations, models
8+
9+
10+
class Migration(migrations.Migration):
11+
12+
dependencies = [
13+
('rust', '0002_add_rbac'),
14+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15+
]
16+
17+
operations = [
18+
migrations.CreateModel(
19+
name='RustCargoToken',
20+
fields=[
21+
('pulp_id', models.UUIDField(default=pulpcore.app.models.base.pulp_uuid, editable=False, primary_key=True, serialize=False)),
22+
('pulp_created', models.DateTimeField(auto_now_add=True)),
23+
('pulp_last_updated', models.DateTimeField(auto_now=True, null=True)),
24+
('name', models.CharField(max_length=255)),
25+
('token_hash', models.CharField(db_index=True, max_length=64, unique=True)),
26+
('last_used', models.DateTimeField(blank=True, null=True)),
27+
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cargo_tokens', to=settings.AUTH_USER_MODEL)),
28+
],
29+
options={
30+
'abstract': False,
31+
},
32+
bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
33+
),
34+
]
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Generated by Django 5.2.14 on 2026-07-13 08:54
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('rust', '0003_rustcargotoken'),
10+
]
11+
12+
operations = [
13+
migrations.AlterModelOptions(
14+
name='rustcargotoken',
15+
options={'default_related_name': '%(app_label)s_%(model_name)s'},
16+
),
17+
migrations.AlterModelOptions(
18+
name='rustdistribution',
19+
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')]},
20+
),
21+
]

pulp_rust/app/models.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
import urllib.request
33
from logging import getLogger
44

5+
from django.conf import settings
56
from django.db import models
67
from django_lifecycle import AFTER_CREATE, hook
78

89
from pulpcore.plugin.models import (
910
AutoAddObjPermsMixin,
11+
BaseModel,
1012
Content,
1113
Distribution,
1214
Remote,
@@ -352,4 +354,18 @@ class Meta:
352354
default_related_name = "%(app_label)s_%(model_name)s"
353355
permissions = [
354356
("manage_roles_rustdistribution", "Can manage roles on rust distributions"),
357+
("publish_rustdistribution", "Can publish crates to this distribution"),
358+
("yank_rustdistribution", "Can yank/unyank crates in this distribution"),
355359
]
360+
361+
362+
class RustCargoToken(BaseModel):
363+
user = models.ForeignKey(
364+
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="cargo_tokens"
365+
)
366+
name = models.CharField(max_length=255, blank=False, null=False)
367+
token_hash = models.CharField(max_length=64, unique=True, db_index=True)
368+
last_used = models.DateTimeField(null=True, blank=True)
369+
370+
class Meta:
371+
default_related_name = "%(app_label)s_%(model_name)s"

pulp_rust/app/serializers.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,23 @@ class Meta:
275275
model = models.RustDistribution
276276

277277

278+
class CargoTokenSerializer(core_serializers.ModelSerializer):
279+
pulp_href = core_serializers.IdentityField(view_name="cargo/tokens-detail")
280+
token = serializers.CharField(
281+
read_only=True,
282+
help_text=_("The token value. Shown once at creation, null otherwise."),
283+
)
284+
285+
class Meta:
286+
model = models.RustCargoToken
287+
fields = core_serializers.ModelSerializer.Meta.fields + (
288+
"name",
289+
"token",
290+
"last_used",
291+
)
292+
read_only_fields = ("token", "last_used")
293+
294+
278295
class YankSerializer(serializers.Serializer):
279296
"""Serializer for yank/unyank operations on a repository."""
280297

0 commit comments

Comments
 (0)