Skip to content

Commit c289613

Browse files
committed
Add a stub auth implementation
Users must set their token to "i_understand_that_pulp_rust_does_not_support_proper_auth_yet" to perform a publish or yank operation. If the token isn't set with this value, auth will fail. Assisted-By: claude-opus-4.6
1 parent a02374c commit c289613

10 files changed

Lines changed: 453 additions & 138 deletions

File tree

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ See the [REST API documentation](site:pulp_rust/restapi/) for detailed endpoint
1313

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
16+
- Publish crates with `cargo publish` and manage them with `cargo yank`
1617
- Implements the [Cargo sparse registry protocol](https://doc.rust-lang.org/cargo/reference/registry-index.html#sparse-index) for compatibility with standard Cargo tooling
1718
- Download crates on-demand to reduce disk usage
1819
- Every operation creates a restorable snapshot with Versioned Repositories

docs/user/guides/private-registry.md

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,6 @@ This guide walks you through setting up Pulp as a private Cargo registry for hos
44
crates. This is useful for organizations that need to distribute proprietary or internal-only
55
Rust packages.
66

7-
!!! note
8-
Package publishing support (`cargo publish`) is not yet available but is planned for an
9-
upcoming release. In the meantime, content can be uploaded through the Pulp REST API.
10-
117
## Create a Repository
128

139
```bash
@@ -16,13 +12,15 @@ pulp rust repository create --name my-crates
1612

1713
## Create a Distribution
1814

19-
A distribution makes the repository's content available to Cargo over HTTP.
15+
A distribution makes the repository's content available to Cargo over HTTP. Set `--allow-uploads`
16+
to enable publishing crates via `cargo publish`.
2017

2118
```bash
2219
pulp rust distribution create \
2320
--name my-crates \
2421
--base-path my-crates \
25-
--repository my-crates
22+
--repository my-crates \
23+
--allow-uploads
2624
```
2725

2826
Your private registry is now served at `http://<pulp-host>/pulp/cargo/my-crates/`.
@@ -36,6 +34,58 @@ Add the private registry to your Cargo configuration. Create or edit `~/.cargo/c
3634
index = "sparse+http://<pulp-host>/pulp/cargo/my-crates/"
3735
```
3836

37+
## Authentication
38+
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.
57+
58+
Read-only operations (downloading crates, browsing the index) do not require a token.
59+
60+
## Publish a Crate
61+
62+
Once the registry is configured and a distribution with `--allow-uploads` exists, you can publish
63+
crates using standard Cargo tooling:
64+
65+
```bash
66+
cargo publish --registry my-crates
67+
```
68+
69+
This uploads the crate to Pulp, which creates the artifact, content metadata, and a new repository
70+
version. The crate is immediately available for download through the distribution.
71+
72+
Publishing the same crate version twice is rejected — crate versions are immutable, consistent
73+
with crates.io behavior.
74+
75+
## Yank and Unyank
76+
77+
Yanking marks a crate version as unavailable for new dependency resolution, while still allowing
78+
existing projects that already depend on it to continue downloading it. This matches the
79+
[crates.io yank semantics](https://doc.rust-lang.org/cargo/reference/publishing.html#cargo-yank).
80+
81+
```bash
82+
# Yank a version
83+
cargo yank --registry my-crates --version 1.0.0 my-crate
84+
85+
# Unyank a version
86+
cargo yank --registry my-crates --version 1.0.0 --undo my-crate
87+
```
88+
3989
### Using the Private Registry as a Dependency Source
4090

4191
To depend on crates from your private registry, specify the registry in your `Cargo.toml`:

pulp_rust/app/auth.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Stub authentication for Cargo API endpoints.
2+
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+
"""
7+
8+
import functools
9+
import json
10+
11+
from django.http import HttpResponse
12+
13+
STUB_TOKEN = "i_understand_that_pulp_rust_does_not_support_proper_auth_yet"
14+
15+
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):
24+
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

pulp_rust/app/models.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717

1818
logger = getLogger(__name__)
1919

20+
# Cache for the "dl" template from each registry's config.json.
21+
# Keyed by index URL; effectively never changes for a given registry.
22+
_dl_template_cache = {}
23+
2024

2125
def _strip_sparse_prefix(url):
2226
"""Strip the sparse+ prefix from a Cargo registry URL."""
@@ -194,9 +198,8 @@ class RustDependency(models.Model):
194198
default="normal",
195199
)
196200

197-
# @TODO: I suspect this isn't needed
198-
# URL of alternative registry if dependency comes from a non-default registry
199-
# Null means the dependency is from the same registry as the parent package
201+
# URL of alternative registry if dependency comes from a non-default registry.
202+
# Null means the dependency is from the same registry as the parent package.
200203
registry = models.CharField(max_length=512, blank=True, null=True)
201204

202205
# Original crate name if the dependency was renamed
@@ -235,11 +238,12 @@ def get_remote_artifact_url(self, relative_path=None, request=None):
235238
crate_name, version = _parse_crate_relative_path(relative_path)
236239
index_url = _strip_sparse_prefix(self.url).rstrip("/")
237240

238-
# TODO: Cache the config.json response to avoid fetching it on every request.
239-
config_url = f"{index_url}/config.json"
240-
response = urllib.request.urlopen(config_url)
241-
config = json.loads(response.read())
242-
dl_template = config["dl"]
241+
if index_url not in _dl_template_cache:
242+
config_url = f"{index_url}/config.json"
243+
response = urllib.request.urlopen(config_url, timeout=30)
244+
config = json.loads(response.read())
245+
_dl_template_cache[index_url] = config["dl"]
246+
dl_template = _dl_template_cache[index_url]
243247

244248
if "{crate}" in dl_template or "{version}" in dl_template:
245249
return dl_template.replace("{crate}", crate_name).replace("{version}", version)

pulp_rust/app/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
IndexRoot,
66
CargoIndexApiViewSet,
77
CargoDownloadApiView,
8+
CargoMeApiView,
89
CargoPublishApiView,
910
)
1011

@@ -15,6 +16,11 @@
1516

1617

1718
urlpatterns = [
19+
path(
20+
CRATES_IO_URL + "me",
21+
CargoMeApiView.as_view(),
22+
name="cargo-me-api",
23+
),
1824
path(
1925
CRATES_IO_URL + "api/v1/crates/new",
2026
CargoPublishApiView.as_view(),

pulp_rust/app/views.py

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import json
22
import logging
3+
import os
4+
import struct
35
import tempfile
46
import urllib.request
57
import urllib.error
@@ -29,6 +31,7 @@
2931
RustPackageYank,
3032
_strip_sparse_prefix,
3133
)
34+
from pulp_rust.app.auth import require_cargo_token
3235
from pulp_rust.app.tasks import (
3336
ayank_package,
3437
aunyank_package,
@@ -76,7 +79,7 @@ def get_distribution(repo):
7679
try:
7780
return distro_qs.get(base_path=repo, pulp_domain=get_domain())
7881
except ObjectDoesNotExist:
79-
raise Http404(f"No RustDistribution found for base_path {repo}") # TODO: broken
82+
raise Http404(f"No RustDistribution found for base_path {repo}")
8083

8184
@staticmethod
8285
def get_repository_version(distribution):
@@ -180,7 +183,7 @@ def retrieve(self, request, path, **kwargs):
180183
index_url = _strip_sparse_prefix(remote.url).rstrip("/")
181184
upstream_url = f"{index_url}/{path}"
182185
try:
183-
response = urllib.request.urlopen(upstream_url)
186+
response = urllib.request.urlopen(upstream_url, timeout=30)
184187
return HttpResponse(response.read(), content_type="text/plain")
185188
except urllib.error.HTTPError as e:
186189
if e.code == 404:
@@ -258,6 +261,23 @@ def retrieve(self, request, repo):
258261
return HttpResponse(json.dumps(data), content_type="application/json")
259262

260263

264+
class CargoMeApiView(APIView):
265+
"""
266+
Auth verification endpoint for ``cargo login``.
267+
268+
Cargo calls GET /me after login to verify the token is valid.
269+
See: https://doc.rust-lang.org/cargo/reference/registry-web-api.html
270+
"""
271+
272+
authentication_classes = []
273+
permission_classes = []
274+
renderer_classes = [JSONRenderer]
275+
276+
@require_cargo_token
277+
def get(self, request, **kwargs):
278+
return HttpResponse(json.dumps({"ok": True}), content_type="application/json")
279+
280+
261281
class CargoPublishApiView(APIView):
262282
"""
263283
View for Cargo's crate publish endpoint (PUT /api/v1/crates/new).
@@ -268,9 +288,8 @@ class CargoPublishApiView(APIView):
268288
See: https://doc.rust-lang.org/cargo/reference/registry-web-api.html#publish
269289
"""
270290

271-
# TODO: Authentication/authorization is not yet implemented.
272-
# All users with network access can publish. In production, this should
273-
# require a valid token and verify crate ownership.
291+
# Authentication uses a stub token via @require_cargo_token decorator.
292+
# TODO: Replace with proper per-user token auth and RBAC integration.
274293
authentication_classes = []
275294
permission_classes = []
276295
renderer_classes = [JSONRenderer]
@@ -288,6 +307,7 @@ def _error_response(detail, status=400):
288307
status=status,
289308
)
290309

310+
@require_cargo_token
291311
def put(self, request, **kwargs):
292312
"""
293313
Handle ``cargo publish`` requests.
@@ -308,7 +328,7 @@ def put(self, request, **kwargs):
308328

309329
try:
310330
metadata, crate_bytes = parse_cargo_publish_body(request.body)
311-
except Exception:
331+
except (struct.error, json.JSONDecodeError, UnicodeDecodeError):
312332
return self._error_response("invalid publish request body")
313333

314334
name = metadata.get("name")
@@ -327,17 +347,20 @@ def put(self, request, **kwargs):
327347
tmp.write(crate_bytes)
328348
tmp.close()
329349

330-
task = dispatch(
331-
apublish_package,
332-
exclusive_resources=[distro.repository],
333-
immediate=True,
334-
kwargs={
335-
"repository_pk": str(distro.repository.pk),
336-
"metadata": metadata,
337-
"crate_path": tmp.name,
338-
},
339-
)
340-
has_task_completed(task)
350+
try:
351+
task = dispatch(
352+
apublish_package,
353+
exclusive_resources=[distro.repository],
354+
immediate=True,
355+
kwargs={
356+
"repository_pk": str(distro.repository.pk),
357+
"metadata": metadata,
358+
"crate_path": tmp.name,
359+
},
360+
)
361+
has_task_completed(task)
362+
finally:
363+
os.unlink(tmp.name)
341364

342365
return HttpResponse(
343366
json.dumps(
@@ -363,7 +386,7 @@ class CargoDownloadApiView(APIView):
363386
permission_classes = []
364387
renderer_classes = [PlainTextRenderer, JSONRenderer]
365388

366-
def get_full_path(self, base_path, pulp_domain=None): # TODO: replace with ApiMixin?
389+
def get_full_path(self, base_path, pulp_domain=None):
367390
if settings.DOMAIN_ENABLED:
368391
domain = pulp_domain or get_domain()
369392
return f"{domain.name}/{base_path}"
@@ -393,10 +416,11 @@ def get(self, request, name, version, rest, **kwargs):
393416
relative_path = f"{name}/{name}-{version}.crate"
394417
return self.redirect_to_content_app(distro, relative_path, request)
395418
elif rest == "readme":
396-
raise NotImplementedError("Readme endpoint is not yet implemented")
419+
raise Http404("Readme endpoint is not yet implemented")
397420
else:
398421
raise Http404(f"Unknown action: {rest}")
399422

423+
@require_cargo_token
400424
def delete(self, request, name, version, rest, **kwargs):
401425
"""
402426
Responds to DELETE requests for yanking crate versions.
@@ -436,6 +460,7 @@ def delete(self, request, name, version, rest, **kwargs):
436460
has_task_completed(task)
437461
return HttpResponse(json.dumps({"ok": True}), content_type="application/json")
438462

463+
@require_cargo_token
439464
def put(self, request, name, version, rest, **kwargs):
440465
"""
441466
Responds to PUT requests for unyanking crate versions.

0 commit comments

Comments
 (0)