From 7875c0b61dd62055edc7cf98104bedbaf05f2f7c Mon Sep 17 00:00:00 2001 From: baptiste Date: Mon, 28 Sep 2026 14:45:20 +0200 Subject: [PATCH 1/2] feat(auth): accept instance authenticators on Cargo endpoints --- pulp_rust/app/views.py | 25 +++++--- pulp_rust/tests/functional/api/test_auth.py | 69 +++++++++++++++++++++ 2 files changed, 87 insertions(+), 7 deletions(-) diff --git a/pulp_rust/app/views.py b/pulp_rust/app/views.py index 10ead26..ba20ed4 100644 --- a/pulp_rust/app/views.py +++ b/pulp_rust/app/views.py @@ -306,12 +306,26 @@ def retrieve(self, request, repo): data = { "dl": self.base_download_url, "api": self.base_api_url, - "auth-required": False, + "auth-required": bool(self.distribution.content_guard_id), } return HttpResponse(json.dumps(data), content_type="application/json") -class CargoMeApiView(APIView): +class CargoAuthMixin: + """Authenticate the Cargo token first, then the deployment's configured authenticators. + + Cargo endpoints must not shadow ``DEFAULT_AUTHENTICATION_CLASSES``: hardcoding only + ``CargoTokenAuthentication`` locks them to ``crg_`` tokens and rejects every other + credential the instance accepts (Basic, session, SSO, OIDC/workload identity). + Prepending the Cargo token keeps ``cargo login`` working while letting any configured + authenticator (e.g. a CI bearer token) reach the view. + """ + + def get_authenticators(self): + return [CargoTokenAuthentication(), *super().get_authenticators()] + + +class CargoMeApiView(CargoAuthMixin, APIView): """ Auth verification endpoint for ``cargo login``. @@ -319,7 +333,6 @@ class CargoMeApiView(APIView): See: https://doc.rust-lang.org/cargo/reference/registry-web-api.html """ - authentication_classes = [CargoTokenAuthentication] permission_classes = [IsAuthenticated] renderer_classes = [JSONRenderer] @@ -327,7 +340,7 @@ def get(self, request, **kwargs): return HttpResponse(json.dumps({"ok": True}), content_type="application/json") -class CargoPublishApiView(APIView): +class CargoPublishApiView(CargoAuthMixin, APIView): """ View for Cargo's crate publish endpoint (PUT /api/v1/crates/new). @@ -337,7 +350,6 @@ class CargoPublishApiView(APIView): See: https://doc.rust-lang.org/cargo/reference/registry-web-api.html#publish """ - authentication_classes = [CargoTokenAuthentication] permission_classes = [IsAuthenticated] renderer_classes = [JSONRenderer] @@ -430,12 +442,11 @@ def put(self, request, **kwargs): ) -class CargoDownloadApiView(APIView): +class CargoDownloadApiView(CargoAuthMixin, APIView): """ View for Cargo's crate download, readme, yank, and unyank endpoints. """ - authentication_classes = [CargoTokenAuthentication] renderer_classes = [PlainTextRenderer, JSONRenderer] def get_permissions(self): diff --git a/pulp_rust/tests/functional/api/test_auth.py b/pulp_rust/tests/functional/api/test_auth.py index b75ebdf..95ed2ac 100644 --- a/pulp_rust/tests/functional/api/test_auth.py +++ b/pulp_rust/tests/functional/api/test_auth.py @@ -1,5 +1,7 @@ """Tests for Cargo token authentication on Cargo API endpoints.""" +import base64 +import uuid from urllib.parse import urljoin from pulp_rust.tests.functional.utils import ( @@ -267,3 +269,70 @@ def test_yank_requires_distribution_permission( # Now Alice can yank response = cargo_yank(base_url, "itoa", "1.0.0", headers=headers) assert response.status_code == 200 + + +# --- Non-Cargo-token authenticators (the instance's default auth stack) --- + + +def test_me_with_basic_auth_succeeds( + rust_repo_factory, + rust_distribution_factory, + cargo_registry_url, + gen_user, +): + """A non-Cargo credential (HTTP Basic) must authenticate too. + + The Cargo endpoints prepend the Cargo token to the instance's configured + authenticators instead of replacing them, so any deployment auth reaches them. + """ + repository = rust_repo_factory() + distribution = rust_distribution_factory(repository=repository.pulp_href) + base = cargo_registry_url(distribution.base_path) + + user = gen_user() + creds = base64.b64encode(f"{user.username}:{user.password}".encode()).decode() + + response = cargo_api_request( + "GET", urljoin(base, "me"), headers={"Authorization": f"Basic {creds}"} + ) + assert response.status_code == 200 + assert response.json()["ok"] is True + + +# --- config.json auth-required reflects the content guard --- + + +def test_config_json_auth_required_false_without_guard( + rust_repo_factory, + rust_distribution_factory, + cargo_registry_url, +): + """An unguarded distribution advertises auth-required: false.""" + repository = rust_repo_factory() + distribution = rust_distribution_factory(repository=repository.pulp_href) + config_url = urljoin(cargo_registry_url(distribution.base_path), "config.json") + + response = cargo_api_request("GET", config_url) + assert response.json()["auth-required"] is False + + +def test_config_json_auth_required_true_with_guard( + rust_repo_factory, + rust_distribution_factory, + cargo_registry_url, + pulpcore_bindings, + gen_object_with_cleanup, +): + """A guarded distribution advertises auth-required: true, so cargo sends its + credentials on the index and downloads, not only on the write API.""" + guard = gen_object_with_cleanup( + pulpcore_bindings.ContentguardsRbacApi, {"name": str(uuid.uuid4())} + ) + repository = rust_repo_factory() + distribution = rust_distribution_factory( + repository=repository.pulp_href, content_guard=guard.pulp_href + ) + config_url = urljoin(cargo_registry_url(distribution.base_path), "config.json") + + response = cargo_api_request("GET", config_url) + assert response.json()["auth-required"] is True From 862a11ecba37d746a2846688e1e7bd326686ba23 Mon Sep 17 00:00:00 2001 From: pkriko Date: Fri, 23 Oct 2026 10:55:11 +0200 Subject: [PATCH 2/2] Address review on Cargo endpoint authentication Skip token scope checks when the request is not authenticated with a Cargo token. Since the Cargo views now accept the instance's default authenticators, request.auth can be None (Basic, session) or another object, which made publish and yank fail with a server error. Also shorten the mixin docstring. Assisted By: Claude Opus 5.5 --- pulp_rust/app/views.py | 14 ++++++++------ pulp_rust/tests/functional/api/test_auth.py | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/pulp_rust/app/views.py b/pulp_rust/app/views.py index a27fb45..9f661fb 100644 --- a/pulp_rust/app/views.py +++ b/pulp_rust/app/views.py @@ -27,6 +27,7 @@ from pulp_rust.app.auth import CargoTokenAuthentication from pulp_rust.app.models import ( + RustCargoToken, RustContent, RustDistribution, RustPackageYank, @@ -86,6 +87,10 @@ def token_scope_error(token, action, distro): 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 not isinstance(token, RustCargoToken): + # Credentials other than a Cargo token carry no scopes. + return None + if token.actions and action not in token.actions: return cargo_error(f"this token is not scoped for the {action} action", status=403) @@ -328,13 +333,10 @@ def retrieve(self, request, repo): class CargoAuthMixin: - """Authenticate the Cargo token first, then the deployment's configured authenticators. + """Try the Cargo token first, then the instance's default authentication classes. - Cargo endpoints must not shadow ``DEFAULT_AUTHENTICATION_CLASSES``: hardcoding only - ``CargoTokenAuthentication`` locks them to ``crg_`` tokens and rejects every other - credential the instance accepts (Basic, session, SSO, OIDC/workload identity). - Prepending the Cargo token keeps ``cargo login`` working while letting any configured - authenticator (e.g. a CI bearer token) reach the view. + This keeps `cargo login` tokens working while still accepting the other credentials + the deployment is configured for (e.g. HTTP Basic). """ def get_authenticators(self): diff --git a/pulp_rust/tests/functional/api/test_auth.py b/pulp_rust/tests/functional/api/test_auth.py index 95ed2ac..8e37d37 100644 --- a/pulp_rust/tests/functional/api/test_auth.py +++ b/pulp_rust/tests/functional/api/test_auth.py @@ -299,6 +299,25 @@ def test_me_with_basic_auth_succeeds( assert response.json()["ok"] is True +def test_yank_with_basic_auth_succeeds( + gen_user, + rust_distro_api_client, + populated_repo, +): + """Token scopes only apply to Cargo tokens, so a Basic-authenticated yank goes through.""" + alice = gen_user() + rust_distro_api_client.add_role( + populated_repo["distribution"].pulp_href, + {"role": "rust.rustdistribution_publisher", "users": [alice.username]}, + ) + creds = base64.b64encode(f"{alice.username}:{alice.password}".encode()).decode() + + response = cargo_yank( + populated_repo["base_url"], "itoa", "1.0.0", headers={"Authorization": f"Basic {creds}"} + ) + assert response.status_code == 200 + + # --- config.json auth-required reflects the content guard ---