diff --git a/pulp_rust/app/views.py b/pulp_rust/app/views.py index 482039c..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) @@ -322,12 +327,23 @@ 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: + """Try the Cargo token first, then the instance's default authentication classes. + + 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): + return [CargoTokenAuthentication(), *super().get_authenticators()] + + +class CargoMeApiView(CargoAuthMixin, APIView): """ Auth verification endpoint for ``cargo login``. @@ -335,7 +351,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] @@ -343,7 +358,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). @@ -353,7 +368,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] @@ -449,12 +463,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..8e37d37 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,89 @@ 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 + + +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 --- + + +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