diff --git a/CHANGES/387.feature.md b/CHANGES/387.feature.md new file mode 100644 index 0000000..3e54dd3 --- /dev/null +++ b/CHANGES/387.feature.md @@ -0,0 +1,4 @@ +Added Role-Based Access Control (RBAC) to all npm viewsets and the npm publish API. +Users can now be assigned creator, owner, or viewer roles on repositories, remotes, +and distributions. The npm publish endpoint requires ``modify_npmrepository`` +permission on the backing repository. diff --git a/pulp_npm/app/__init__.py b/pulp_npm/app/__init__.py index e56b7bf..f8f424e 100644 --- a/pulp_npm/app/__init__.py +++ b/pulp_npm/app/__init__.py @@ -1,3 +1,7 @@ +from gettext import gettext as _ + +from django.db.models.signals import post_migrate + from pulpcore.plugin import PulpPluginAppConfig @@ -9,3 +13,54 @@ class PulpNpmPluginAppConfig(PulpPluginAppConfig): version = "0.10.0.dev" python_package_name = "pulp-npm" domain_compatible = True + + def ready(self): + super().ready() + post_migrate.connect( + _populate_npm_publish_access_policies, + sender=self, + dispatch_uid="populate_npm_publish_access_policies", + ) + + +def _populate_npm_publish_access_policies(sender, apps, verbosity, **kwargs): + """ + Create or update AccessPolicy records for plain APIViews that + pulpcore can't auto-discover (`NpmPublishView`, `NpmPingView`, `NpmWhoamiView`). + + On first run: creates the DB record from each view's `DEFAULT_ACCESS_POLICY`. + On subsequent runs: updates the record to match the code **unless** an admin + has customized it, in which case it's left alone. + """ + + from pulp_npm.app.npm_publish_api import NpmPingView, NpmPublishView, NpmWhoamiView + + try: + AccessPolicy = apps.get_model("core", "AccessPolicy") + except LookupError: + if verbosity >= 1: + print(_("AccessPolicy model does not exist. Skipping initialization.")) + return + + for viewset in (NpmPublishView, NpmPingView, NpmWhoamiView): + access_policy = getattr(viewset, "DEFAULT_ACCESS_POLICY", None) + if access_policy is None: + continue + viewset_name = viewset.urlpattern() + db_access_policy, created = AccessPolicy.objects.get_or_create( + viewset_name=viewset_name, defaults=access_policy + ) + if created: + if verbosity >= 1: + print(f"Access policy for {viewset_name} created.") + elif not db_access_policy.customized: + dirty = False + for key in ["statements", "creation_hooks", "queryset_scoping"]: + value = access_policy.get(key) + if getattr(db_access_policy, key, None) != value: + setattr(db_access_policy, key, value) + dirty = True + if dirty: + db_access_policy.save() + if verbosity >= 1: + print(f"Access policy for {viewset_name} updated.") diff --git a/pulp_npm/app/global_access_conditions.py b/pulp_npm/app/global_access_conditions.py new file mode 100644 index 0000000..b913790 --- /dev/null +++ b/pulp_npm/app/global_access_conditions.py @@ -0,0 +1,20 @@ +from django.conf import settings + + +def npm_has_repository_perm(request, view, action, perm="npm.view_npmrepository"): + """ + Check if the user has ``perm`` on the distribution's backing repository. + + Used by the npm publish API views where the distribution (and its + repository) is resolved from the URL path rather than from a viewset + querset. Returns ``True`` if the distribution has no repository. + """ + if request.user.has_perm(perm): + return True + if settings.DOMAIN_ENABLED: + if request.user.has_perm(perm, obj=request.pulp_domain): + return True + if repo := view.distribution.repository: + # Cast repo, need NpmRepository instead of Repository + return request.user.has_perm(perm, obj=repo.cast()) + return True diff --git a/pulp_npm/app/migrations/0006_add_rbac_permissions.py b/pulp_npm/app/migrations/0006_add_rbac_permissions.py new file mode 100644 index 0000000..94b6b6d --- /dev/null +++ b/pulp_npm/app/migrations/0006_add_rbac_permissions.py @@ -0,0 +1,38 @@ +from django.db import migrations + +class Migration(migrations.Migration): + dependencies = [ + ("npm", "0005_alter_package_version"), + ] + + operations = [ + migrations.AlterModelOptions( + name="npmremote", + options={ + "default_related_name": "%(app_label)s_%(model_name)s", + "permissions": [ + ("manage_roles_npmremote", "Can manage roles on npm remotes"), + ], + } + ), + migrations.AlterModelOptions( + name="npmrepository", + options={ + "default_related_name": "%(app_label)s_%(model_name)s", + "permissions": [ + ("sync_npmrepository", "Can start a sync task"), + ("modify_npmrepository", "Can modify content of the repository"), + ("manage_roles_npmrepository", "Can manage roles on npm repositories"), + ] + } + ), + migrations.AlterModelOptions( + name="npmdistribution", + options={ + "default_related_name": "%(app_label)s_%(model_name)s", + "permissions": [ + ("manage_roles_npmdistribution", "Can manage roles on npm distributions"), + ] + } + ), + ] diff --git a/pulp_npm/app/models.py b/pulp_npm/app/models.py index a50d064..db97bce 100644 --- a/pulp_npm/app/models.py +++ b/pulp_npm/app/models.py @@ -6,6 +6,7 @@ from django.db import models from pulpcore.plugin.models import ( + AutoAddObjPermsMixin, Content, Distribution, Remote, @@ -52,7 +53,7 @@ class Meta: unique_together = ("name", "version", "_pulp_domain") -class NpmRemote(Remote): +class NpmRemote(Remote, AutoAddObjPermsMixin): """ A Remote for NpmContent. @@ -71,9 +72,12 @@ def get_remote_artifact_content_type(self, relative_path=None): class Meta: default_related_name = "%(app_label)s_%(model_name)s" + permissions = [ + ("manage_roles_npmremote", "Can manage roles on npm remotes"), + ] -class NpmRepository(Repository): +class NpmRepository(Repository, AutoAddObjPermsMixin): """ A Repository for NpmContent. @@ -89,9 +93,14 @@ class NpmRepository(Repository): class Meta: default_related_name = "%(app_label)s_%(model_name)s" + permissions = [ + ("sync_npmrepository", "Can start a sync task"), + ("modify_npmrepository", "Can modify content of the repository"), + ("manage_roles_npmrepository", "Can manage roles on npm repositories"), + ] -class NpmDistribution(Distribution): +class NpmDistribution(Distribution, AutoAddObjPermsMixin): """ Distribution for "npm" content. """ @@ -100,6 +109,9 @@ class NpmDistribution(Distribution): class Meta: default_related_name = "%(app_label)s_%(model_name)s" + permissions = [ + ("manage_roles_npmdistribution", "Can manage roles on npm distributions"), + ] def content_handler(self, path): data = {} diff --git a/pulp_npm/app/npm_publish_api.py b/pulp_npm/app/npm_publish_api.py index 6de1bff..b13b527 100644 --- a/pulp_npm/app/npm_publish_api.py +++ b/pulp_npm/app/npm_publish_api.py @@ -11,6 +11,7 @@ from rest_framework.response import Response from rest_framework.views import APIView +from pulpcore.plugin.access_policy import AccessPolicyFromDB from pulpcore.plugin.models import Artifact, ContentArtifact from pulpcore.plugin.tasking import dispatch from pulpcore.plugin.util import get_domain @@ -42,16 +43,43 @@ class NpmPublishView(APIView): """Handle npm/yarn publish requests (``PUT /``).""" parser_classes = [JSONParser] + permission_classes = [AccessPolicyFromDB] + + def initial(self, request, *args, **kwargs): + self.action = request.method.lower() + super().initial(request, *args, **kwargs) + + DEFAULT_ACCESS_POLICY = { + "statements": [ + { + "action": ["put"], + "principal": "authenticated", + "effect": "allow", + "condition": "npm_has_repository_perm:npm.modify_npmrepository", + }, + ], + } + + @classmethod + def urlpattern(cls): + return "npm/publish" + + @property + def distribution(self): + if not hasattr(self, "_distribution"): + domain = get_domain() + self._distribution = get_object_or_404( + NpmDistribution, + base_path=self.kwargs["path"], + pulp_domain=domain, + ) + return self._distribution def put(self, request, path, package_name): package_name = _decode_package_name(package_name) domain = get_domain() - distribution = get_object_or_404( - NpmDistribution, - base_path=path, - pulp_domain=domain, - ) + distribution = self.distribution repository = distribution.repository if not repository: return Response( @@ -160,6 +188,27 @@ def _save_artifact(tarball_bytes, domain): class NpmPingView(APIView): """Handle ``GET /-/ping`` -- registry health check.""" + authentication_classes = [] + permission_classes = [AccessPolicyFromDB] + + def initial(self, request, *args, **kwargs): + self.action = request.method.lower() + super().initial(request, *args, **kwargs) + + DEFAULT_ACCESS_POLICY = { + "statements": [ + { + "action": ["get"], + "principal": "*", + "effect": "allow", + } + ] + } + + @classmethod + def urlpattern(cls): + return "npm/ping" + def get(self, request, **kwargs): return Response({}) @@ -167,6 +216,26 @@ def get(self, request, **kwargs): class NpmWhoamiView(APIView): """Handle ``GET /-/whoami`` -- return the authenticated user.""" + permission_classes = [AccessPolicyFromDB] + + def initial(self, request, *args, **kwargs): + self.action = request.method.lower() + super().initial(request, *args, **kwargs) + + DEFAULT_ACCESS_POLICY = { + "statements": [ + { + "action": ["get"], + "principal": "authenticated", + "effect": "allow", + }, + ], + } + + @classmethod + def urlpattern(cls): + return "npm/whoami" + def get(self, request, **kwargs): username = None if hasattr(request, "user") and request.user and request.user.is_authenticated: diff --git a/pulp_npm/app/settings.py b/pulp_npm/app/settings.py index e69de29..00a1969 100644 --- a/pulp_npm/app/settings.py +++ b/pulp_npm/app/settings.py @@ -0,0 +1,4 @@ +DRF_ACCESS_POLICY = { + "dynaconf_merge_unique": True, + "reusable_conditions": ["pulp_npm.app.global_access_conditions"], +} diff --git a/pulp_npm/app/viewsets.py b/pulp_npm/app/viewsets.py index e0cf132..1a73a3b 100644 --- a/pulp_npm/app/viewsets.py +++ b/pulp_npm/app/viewsets.py @@ -40,6 +40,34 @@ class NpmPackageViewSet(core.SingleArtifactContentUploadViewSet): queryset = models.Package.objects.all() serializer_class = serializers.NpmPackageSerializer filterset_class = NpmPackageFilter + queryset_filtering_required_permission = "npm.view_package" + + DEFAULT_ACCESS_POLICY = { + "statements": [ + { + "action": ["list", "retrieve"], + "principal": "authenticated", + "effect": "allow", + }, + { + "action": ["create"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_required_repo_perms_on_upload:npm.modify_npmrepository", + "has_required_repo_perms_on_upload:npm.view_npmrepository", + "has_upload_param_model_or_domain_or_obj_perms:core.change_upload", + ], + }, + { + "action": ["upload"], + "principal": "authenticated", + "effect": "allow", + "condition": "has_model_or_domain_perms:npm.add_package", + }, + ], + "queryset_scoping": {"function": "scope_queryset"}, + } @extend_schema( summary="Synchronous npm package upload", @@ -65,7 +93,7 @@ def upload(self, request): return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) -class NpmRemoteViewSet(core.RemoteViewSet): +class NpmRemoteViewSet(core.RemoteViewSet, core.RolesMixin): """ A ViewSet for NpmRemote. @@ -76,9 +104,74 @@ class NpmRemoteViewSet(core.RemoteViewSet): endpoint_name = "npm" queryset = models.NpmRemote.objects.all() serializer_class = serializers.NpmRemoteSerializer + queryset_filtering_required_permission = "npm.view_npmremote" + + DEFAULT_ACCESS_POLICY = { + "statements": [ + { + "action": ["list", "my_permissions"], + "principal": "authenticated", + "effect": "allow", + }, + { + "action": ["create"], + "principal": "authenticated", + "effect": "allow", + "condition": "has_model_or_domain_perms:npm.add_npmremote", + }, + { + "action": ["retrieve"], + "principal": "authenticated", + "effect": "allow", + "condition": "has_model_or_domain_or_obj_perms:npm.view_npmremote", + }, + { + "action": ["update", "partial_update", "set_label", "unset_label"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_model_or_domain_or_obj_perms:npm.change_npmremote", + "has_model_or_domain_or_obj_perms:npm.view_npmremote", + ], + }, + { + "action": ["destroy"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_model_or_domain_or_obj_perms:npm.delete_npmremote", + "has_model_or_domain_or_obj_perms:npm.view_npmremote", + ], + }, + { + "action": ["list_roles", "add_role", "remove_role"], + "principal": "authenticated", + "effect": "allow", + "condition": "has_model_or_domain_or_obj_perms:npm.manage_roles_npmremote", + }, + ], + "creation_hooks": [ + { + "function": "add_roles_for_object_creator", + "parameters": {"roles": "npm.npmremote_owner"}, + } + ], + "queryset_scoping": {"function": "scope_queryset"}, + } + LOCKED_ROLES = { + "npm.npmremote_creator": ["npm.add_npmremote"], + "npm.npmremote_owner": [ + "npm.view_npmremote", + "npm.change_npmremote", + "npm.delete_npmremote", + "npm.manage_roles_npmremote", + ], + "npm.npmremote_viewer": ["npm.view_npmremote"], + } -class NpmRepositoryViewSet(core.RepositoryViewSet, ModifyRepositoryActionMixin): + +class NpmRepositoryViewSet(core.RepositoryViewSet, ModifyRepositoryActionMixin, core.RolesMixin): """ A ViewSet for NpmRepository. @@ -89,6 +182,105 @@ class NpmRepositoryViewSet(core.RepositoryViewSet, ModifyRepositoryActionMixin): endpoint_name = "npm" queryset = models.NpmRepository.objects.all() serializer_class = serializers.NpmRepositorySerializer + queryset_filtering_required_permission = "npm.view_npmrepository" + + DEFAULT_ACCESS_POLICY = { + "statements": [ + { + "action": ["list", "my_permissions"], + "principal": "authenticated", + "effect": "allow", + }, + { + "action": ["create"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_model_or_domain_perms:npm.add_npmrepository", + "has_remote_param_model_or_domain_or_obj_perms:npm.view_npmremote", + ], + }, + { + "action": ["retrieve"], + "principal": "authenticated", + "effect": "allow", + "condition": "has_model_or_domain_or_obj_perms:npm.view_npmrepository", + }, + { + "action": ["update", "partial_update"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_model_or_domain_or_obj_perms:npm.change_npmrepository", + "has_model_or_domain_or_obj_perms:npm.view_npmrepository", + "has_remote_param_model_or_domain_or_obj_perms:npm.view_npmremote", + ], + }, + { + "action": ["set_label", "unset_label"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_model_or_domain_or_obj_perms:npm.change_npmrepository", + "has_model_or_domain_or_obj_perms:npm.view_npmrepository", + ], + }, + { + "action": ["destroy"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_model_or_domain_or_obj_perms:npm.delete_npmrepository", + "has_model_or_domain_or_obj_perms:npm.view_npmrepository", + ], + }, + { + "action": ["sync"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_model_or_domain_or_obj_perms:npm.sync_npmrepository", + "has_model_or_domain_or_obj_perms:npm.view_npmrepository", + "has_remote_param_model_or_domain_or_obj_perms:npm.view_npmremote", + ], + }, + { + "action": ["modify"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_model_or_domain_or_obj_perms:npm.modify_npmrepository", + "has_model_or_domain_or_obj_perms:npm.view_npmrepository", + ], + }, + { + "action": ["list_roles", "add_role", "remove_role"], + "principal": "authenticated", + "effect": "allow", + "condition": "has_model_or_domain_or_obj_perms:npm.manage_roles_npmrepository", + }, + ], + "creation_hooks": [ + { + "function": "add_roles_for_object_creator", + "parameters": {"roles": "npm.npmrepository_owner"}, + } + ], + "queryset_scoping": {"function": "scope_queryset"}, + } + + LOCKED_ROLES = { + "npm.npmrepository_creator": ["npm.add_npmrepository"], + "npm.npmrepository_owner": [ + "npm.view_npmrepository", + "npm.change_npmrepository", + "npm.delete_npmrepository", + "npm.sync_npmrepository", + "npm.modify_npmrepository", + "npm.manage_roles_npmrepository", + ], + "npm.npmrepository_viewer": ["npm.view_npmrepository"], + } # This decorator is necessary since a sync operation is asyncrounous and returns # the id and href of the sync task. @@ -125,8 +317,39 @@ class NpmRepositoryVersionViewSet(core.RepositoryVersionViewSet): parent_viewset = NpmRepositoryViewSet + DEFAULT_ACCESS_POLICY = { + "statements": [ + { + "action": ["list", "retrieve"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_repository_model_or_domain_or_obj_perms:npm.view_npmrepository", + ], + }, + { + "action": ["destroy"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_repository_model_or_domain_or_obj_perms:npm.delete_npmrepository", + "has_repository_model_or_domain_or_obj_perms:npm.view_npmrepository", + ], + }, + { + "action": ["repair"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_repository_model_or_domain_or_obj_perms:npm.modify_npmrepository", + "has_repository_model_or_domain_or_obj_perms:npm.view_npmrepository", + ], + }, + ], + } + -class NpmDistributionViewSet(core.DistributionViewSet): +class NpmDistributionViewSet(core.DistributionViewSet, core.RolesMixin): """ ViewSet for NPM Distributions. """ @@ -134,3 +357,83 @@ class NpmDistributionViewSet(core.DistributionViewSet): endpoint_name = "npm" queryset = models.NpmDistribution.objects.all() serializer_class = serializers.NpmDistributionSerializer + queryset_filtering_required_permission = "npm.view_npmdistribution" + + DEFAULT_ACCESS_POLICY = { + "statements": [ + { + "action": ["list", "my_permissions"], + "principal": "authenticated", + "effect": "allow", + }, + { + "action": ["create"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_model_or_domain_perms:npm.add_npmdistribution", + "has_repo_or_repo_ver_param_model_or_domain_or_obj_perms:" + "npm.view_npmrepository", + ], + }, + { + "action": ["retrieve"], + "principal": "authenticated", + "effect": "allow", + "condition": "has_model_or_domain_or_obj_perms:npm.view_npmdistribution", + }, + { + "action": ["update", "partial_update"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_model_or_domain_or_obj_perms:npm.change_npmdistribution", + "has_model_or_domain_or_obj_perms:npm.view_npmdistribution", + "has_repo_or_repo_ver_param_model_or_domain_or_obj_perms:" + "npm.view_npmrepository", + ], + }, + { + "action": ["set_label", "unset_label"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_model_or_domain_or_obj_perms:npm.change_npmdistribution", + "has_model_or_domain_or_obj_perms:npm.view_npmdistribution", + ], + }, + { + "action": ["destroy"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_model_or_domain_or_obj_perms:npm.delete_npmdistribution", + "has_model_or_domain_or_obj_perms:npm.view_npmdistribution", + ], + }, + { + "action": ["list_roles", "add_role", "remove_role"], + "principal": "authenticated", + "effect": "allow", + "condition": "has_model_or_domain_or_obj_perms:npm.manage_roles_npmdistribution", + }, + ], + "creation_hooks": [ + { + "function": "add_roles_for_object_creator", + "parameters": {"roles": "npm.npmdistribution_owner"}, + } + ], + "queryset_scoping": {"function": "scope_queryset"}, + } + + LOCKED_ROLES = { + "npm.npmdistribution_creator": ["npm.add_npmdistribution"], + "npm.npmdistribution_owner": [ + "npm.view_npmdistribution", + "npm.change_npmdistribution", + "npm.delete_npmdistribution", + "npm.manage_roles_npmdistribution", + ], + "npm.npmdistribution_viewer": ["npm.view_npmdistribution"], + } diff --git a/pulp_npm/pytest_plugin.py b/pulp_npm/pytest_plugin.py index 9e8aa9e..83cce76 100644 --- a/pulp_npm/pytest_plugin.py +++ b/pulp_npm/pytest_plugin.py @@ -1,3 +1,5 @@ +import json +import os import uuid from collections import defaultdict @@ -62,6 +64,38 @@ def _npm_distribution_factory(pulp_domain=None, **body): return _npm_distribution_factory +@pytest.fixture(scope="class") +def npm_fixtures_root(tmp_path_factory): + return tmp_path_factory.mktemp("npm_fixtures") + + +@pytest.fixture(scope="class") +def npm_fixture_server(npm_fixtures_root, gen_fixture_server): + return gen_fixture_server(npm_fixtures_root, None) + + +@pytest.fixture(scope="class") +def npm_package_url(npm_fixtures_root, npm_fixture_server): + pkg_name = "test-pkg" + pkg_version = "1.0.0" + + tarball_dir = npm_fixtures_root / pkg_name / "-" + tarball_dir.mkdir(parents=True) + tarball_path = tarball_dir / f"{pkg_name}-{pkg_version}.tgz" + tarball_path.write_bytes(os.urandom(1024)) + + tarball_url = npm_fixture_server.make_url(f"/{pkg_name}/-/{pkg_name}-{pkg_version}.tgz") + metadata = { + "name": pkg_name, + "version": pkg_version, + "dist": {"tarball": tarball_url}, + } + metadata_dir = npm_fixtures_root / pkg_name + (metadata_dir / pkg_version).write_text(json.dumps(metadata)) + + return npm_fixture_server.make_url(f"/{pkg_name}/{pkg_version}") + + @pytest.fixture(scope="function") def npm_repo(npm_repository_factory): return npm_repository_factory() diff --git a/pulp_npm/tests/functional/api/test_rbac.py b/pulp_npm/tests/functional/api/test_rbac.py new file mode 100644 index 0000000..5a7edd7 --- /dev/null +++ b/pulp_npm/tests/functional/api/test_rbac.py @@ -0,0 +1,517 @@ +import uuid + +import pytest + + +@pytest.fixture +def gen_users(gen_user): + """Create three users with different role levels. + + - alice: viewer roles (read and list, cannot create/update/delete) + - bob: creator roles (can create. As creator gets auto-assigned owner role) + - charlie: no roles at all + + gen_user is a fixture from pulpcore that creates a Pulp user + and returns a context manager for impersonating them in API calls. + """ + + def _gen_users(role_names=None): + if role_names is None: + role_names = [] + if isinstance(role_names, str): + role_names = [role_names] + viewer_roles = [f"npm.{role}_viewer" for role in role_names] + creator_roles = [f"npm.{role}_creator" for role in role_names] + alice = gen_user(model_roles=viewer_roles) + bob = gen_user(model_roles=creator_roles) + charlie = gen_user() + return alice, bob, charlie + + return _gen_users + + +@pytest.fixture +def try_action(npm_bindings, monitor_task): + """Perform an API action as a given user and assert the expected HTTP status""" + + def _try_action(user, client, action, outcome, *args, **kwargs): + # Use the _with_http_info variant so we can get info on body, status_code and headers. + action_api = getattr(client, f"{action}_with_http_info") + try: + with user: + response = action_api(*args, **kwargs) + if isinstance(response, tuple): + data, status_code, _ = response + else: + data = response.data + status_code = response.status_code + if isinstance(data, npm_bindings.module.AsyncOperationResponse): + data = monitor_task(data.task) + except npm_bindings.module.ApiException as e: + assert e.status == outcome, f"{e}" + else: + assert status_code == outcome, ( + f"User performed {action} when they shouldn't have been able to" + ) + return data + + return _try_action + + +# Repository CRUD +@pytest.mark.parallel +def test_basic_actions(gen_users, npm_bindings, try_action, npm_repository_factory): + """Test list, read, create, update and delete on repositories""" + alice, bob, charlie = gen_users("npmrepository") + + # Create: alice (viewer) can't, bob (creator) can, charlie (no role) can't + try_action( + alice, + npm_bindings.RepositoriesNpmApi, + "create", + 403, + {"name": str(uuid.uuid4())}, + ) + + repo = try_action( + bob, + npm_bindings.RepositoriesNpmApi, + "create", + 201, + {"name": str(uuid.uuid4())}, + ) + + try_action( + charlie, + npm_bindings.RepositoriesNpmApi, + "create", + 403, + {"name": str(uuid.uuid4())}, + ) + + # List: alice (viewer) sees bob's repo, bob (owner) sees it, charlie sees nothing + a_list = try_action(alice, npm_bindings.RepositoriesNpmApi, "list", 200) + assert a_list.count >= 1 + b_list = try_action(bob, npm_bindings.RepositoriesNpmApi, "list", 200) + assert b_list.count >= 1 + c_list = try_action(charlie, npm_bindings.RepositoriesNpmApi, "list", 200) + assert c_list.count == 0 + + # Read: alice (viewer) can see bob's repo, charlie cannot (404, invisible) + try_action(alice, npm_bindings.RepositoriesNpmApi, "read", 200, repo.pulp_href) + try_action(bob, npm_bindings.RepositoriesNpmApi, "read", 200, repo.pulp_href) + try_action(charlie, npm_bindings.RepositoriesNpmApi, "read", 404, repo.pulp_href) + + # Update: only bob (owner) can + update_args = [repo.pulp_href, {"name": str(uuid.uuid4())}] + try_action(alice, npm_bindings.RepositoriesNpmApi, "partial_update", 403, *update_args) + try_action(bob, npm_bindings.RepositoriesNpmApi, "partial_update", 202, *update_args) + try_action(charlie, npm_bindings.RepositoriesNpmApi, "partial_update", 404, *update_args) + + # Delete: only bob (owner) can + try_action(alice, npm_bindings.RepositoriesNpmApi, "delete", 403, repo.pulp_href) + try_action(charlie, npm_bindings.RepositoriesNpmApi, "delete", 404, repo.pulp_href) + try_action(bob, npm_bindings.RepositoriesNpmApi, "delete", 202, repo.pulp_href) + + +# Repository sync and modify +@pytest.mark.parallel +def test_repository_sync( + gen_users, + npm_bindings, + npm_repository_factory, + npm_remote_factory, + try_action, +): + """Test sync and modif yactions require appropriate permissions""" + alice, bob, charlie = gen_users(["npmrepository", "npmremote"]) + + with bob: + bob_remote = npm_remote_factory(url="https://registry.npmjs.org/") + repo = npm_repository_factory(remote=bob_remote.pulp_href) + + body = {"remote": bob_remote.pulp_href} + try_action(alice, npm_bindings.RepositoriesNpmApi, "sync", 403, repo.pulp_href, body) + try_action(charlie, npm_bindings.RepositoriesNpmApi, "sync", 404, repo.pulp_href, body) + + # Modify repository, add/remove content + try_action(alice, npm_bindings.RepositoriesNpmApi, "modify", 403, repo.pulp_href, {}) + try_action(bob, npm_bindings.RepositoriesNpmApi, "modify", 202, repo.pulp_href, {}) + try_action(charlie, npm_bindings.RepositoriesNpmApi, "modify", 404, repo.pulp_href, {}) + + +# Remote CRUD + + +@pytest.mark.parallel +def test_remote_actions(gen_users, npm_bindings, try_action): + """Test CRUD on remotes with role-based access.""" + alice, bob, charlie = gen_users("npmremote") + + a_list = try_action(alice, npm_bindings.RemotesNpmApi, "list", 200) + assert a_list.count >= 0 + b_list = try_action(bob, npm_bindings.RemotesNpmApi, "list", 200) + c_list = try_action(charlie, npm_bindings.RemotesNpmApi, "list", 200) + assert (b_list.count, c_list.count) == (0, 0) + + remote_body = { + "name": str(uuid.uuid4()), + "url": "https://registry.npmjs.org/", + } + try_action(alice, npm_bindings.RemotesNpmApi, "create", 403, remote_body) + remote = try_action(bob, npm_bindings.RemotesNpmApi, "create", 201, remote_body) + try_action(charlie, npm_bindings.RemotesNpmApi, "create", 403, remote_body) + + try_action(alice, npm_bindings.RemotesNpmApi, "read", 200, remote.pulp_href) + try_action(bob, npm_bindings.RemotesNpmApi, "read", 200, remote.pulp_href) + try_action(charlie, npm_bindings.RemotesNpmApi, "read", 404, remote.pulp_href) + + update_args = [remote.pulp_href, {"name": str(uuid.uuid4())}] + try_action(alice, npm_bindings.RemotesNpmApi, "partial_update", 403, *update_args) + try_action(bob, npm_bindings.RemotesNpmApi, "partial_update", 202, *update_args) + try_action(charlie, npm_bindings.RemotesNpmApi, "partial_update", 404, *update_args) + + try_action(alice, npm_bindings.RemotesNpmApi, "delete", 403, remote.pulp_href) + try_action(charlie, npm_bindings.RemotesNpmApi, "delete", 404, remote.pulp_href) + try_action(bob, npm_bindings.RemotesNpmApi, "delete", 202, remote.pulp_href) + + +# Distribution CRUD + + +@pytest.mark.parallel +def test_distribution_actions( + gen_users, + npm_bindings, + npm_repository_factory, + try_action, +): + """Test CRUD on distributions with role-based access.""" + alice, bob, charlie = gen_users(["npmdistribution", "npmrepository"]) + + with bob: + repo = npm_repository_factory() + + try_action( + alice, + npm_bindings.DistributionsNpmApi, + "create", + 403, + { + "name": str(uuid.uuid4()), + "base_path": str(uuid.uuid4()), + "repository": repo.pulp_href, + }, + ) + distro = try_action( + bob, + npm_bindings.DistributionsNpmApi, + "create", + 202, + { + "name": str(uuid.uuid4()), + "base_path": str(uuid.uuid4()), + "repository": repo.pulp_href, + }, + ) + distro_href = distro.created_resources[0] + try_action( + charlie, + npm_bindings.DistributionsNpmApi, + "create", + 403, + { + "name": str(uuid.uuid4()), + "base_path": str(uuid.uuid4()), + "repository": repo.pulp_href, + }, + ) + + try_action(alice, npm_bindings.DistributionsNpmApi, "read", 200, distro_href) + try_action(bob, npm_bindings.DistributionsNpmApi, "read", 200, distro_href) + try_action(charlie, npm_bindings.DistributionsNpmApi, "read", 404, distro_href) + + update_args = [distro_href, {"name": str(uuid.uuid4())}] + try_action(alice, npm_bindings.DistributionsNpmApi, "partial_update", 403, *update_args) + try_action(bob, npm_bindings.DistributionsNpmApi, "partial_update", 202, *update_args) + try_action(charlie, npm_bindings.DistributionsNpmApi, "partial_update", 404, *update_args) + + try_action(alice, npm_bindings.DistributionsNpmApi, "delete", 403, distro_href) + try_action(charlie, npm_bindings.DistributionsNpmApi, "delete", 404, distro_href) + try_action(bob, npm_bindings.DistributionsNpmApi, "delete", 202, distro_href) + + +# Cross-object checks +@pytest.mark.parallel +def test_cross_object_permissions( + gen_users, + npm_bindings, + npm_repository_factory, + npm_remote_factory, + try_action, +): + """Test that creating objects referencing others requires view permission on them.""" + _alice, bob, _charlie = gen_users(["npmrepository", "npmremote", "npmdistribution"]) + + admin_repo = npm_repository_factory() + admin_remote = npm_remote_factory(url="https://registry.npmjs.org/") + + with bob: + bob_remote = npm_remote_factory(url="https://registry.npmjs.org/") + bob_repo = npm_repository_factory() + + # Bob can't create distribution pointing to admin's repo (no view perm) + try_action( + bob, + npm_bindings.DistributionsNpmApi, + "create", + 403, + { + "name": str(uuid.uuid4()), + "base_path": str(uuid.uuid4()), + "repository": admin_repo.pulp_href, + }, + ) + + # Bob can create distribution pointing to his own repo + try_action( + bob, + npm_bindings.DistributionsNpmApi, + "create", + 202, + { + "name": str(uuid.uuid4()), + "base_path": str(uuid.uuid4()), + "repository": bob_repo.pulp_href, + }, + ) + + # Bob can't create repo referencing admin's remote + try_action( + bob, + npm_bindings.RepositoriesNpmApi, + "create", + 403, + {"name": str(uuid.uuid4()), "remote": admin_remote.pulp_href}, + ) + + # Bob can create repo referencing his own remote + try_action( + bob, + npm_bindings.RepositoriesNpmApi, + "create", + 201, + {"name": str(uuid.uuid4()), "remote": bob_remote.pulp_href}, + ) + + +# Role management +@pytest.mark.parallel +def test_role_management( + gen_users, + npm_bindings, + npm_repository_factory, + try_action, +): + """Test list_roles, add_role, remove_role, and my_permissions.""" + alice, bob, charlie = gen_users("npmrepository") + with bob: + href = npm_repository_factory().pulp_href + + # my_permissions -- alice has no object-level role, bob is owner + aperm = try_action(alice, npm_bindings.RepositoriesNpmApi, "my_permissions", 200, href) + assert aperm.permissions == [] + bperm = try_action(bob, npm_bindings.RepositoriesNpmApi, "my_permissions", 200, href) + assert len(bperm.permissions) > 0 + try_action(charlie, npm_bindings.RepositoriesNpmApi, "my_permissions", 404, href) + + # list_roles / add_role / remove_role + try_action(alice, npm_bindings.RepositoriesNpmApi, "list_roles", 403, href) + try_action(bob, npm_bindings.RepositoriesNpmApi, "list_roles", 200, href) + + nested_role = { + "users": [charlie.username], + "role": "npm.npmrepository_viewer", + } + try_action(alice, npm_bindings.RepositoriesNpmApi, "add_role", 403, href, nested_role) + try_action(bob, npm_bindings.RepositoriesNpmApi, "add_role", 201, href, nested_role) + + # charlie can now see the repo + try_action(charlie, npm_bindings.RepositoriesNpmApi, "read", 200, href) + + # remove_role + try_action( + alice, + npm_bindings.RepositoriesNpmApi, + "remove_role", + 403, + href, + nested_role, + ) + try_action( + bob, + npm_bindings.RepositoriesNpmApi, + "remove_role", + 201, + href, + nested_role, + ) + + # charlie can no longer see the repo + try_action(charlie, npm_bindings.RepositoriesNpmApi, "read", 404, href) + + +# Repository version permission delegation +@pytest.mark.parallel +def test_repository_version_actions( + gen_users, + npm_bindings, + npm_repository_factory, + try_action, +): + """Test that repository version permissions delegate to the parent repository.""" + alice, bob, charlie = gen_users("npmrepository") + with bob: + repo = npm_repository_factory() + + # Create a version by running modify + try_action(bob, npm_bindings.RepositoriesNpmApi, "modify", 202, repo.pulp_href, {}) + repo = npm_bindings.RepositoriesNpmApi.read(repo.pulp_href) + + ver_href = repo.latest_version_href + + # List versions + a_vers = try_action(alice, npm_bindings.RepositoriesNpmVersionsApi, "list", 200, repo.pulp_href) + assert a_vers.count >= 1 + b_vers = try_action(bob, npm_bindings.RepositoriesNpmVersionsApi, "list", 200, repo.pulp_href) + assert b_vers.count >= 1 + try_action( + charlie, + npm_bindings.RepositoriesNpmVersionsApi, + "list", + 403, + repo.pulp_href, + ) + + # Retrieve specific version + try_action(alice, npm_bindings.RepositoriesNpmVersionsApi, "read", 200, ver_href) + try_action(bob, npm_bindings.RepositoriesNpmVersionsApi, "read", 200, ver_href) + try_action(charlie, npm_bindings.RepositoriesNpmVersionsApi, "read", 403, ver_href) + + # Destroy -- permission checks only + try_action(alice, npm_bindings.RepositoriesNpmVersionsApi, "delete", 403, ver_href) + try_action(charlie, npm_bindings.RepositoriesNpmVersionsApi, "delete", 403, ver_href) + + +# Content visibility scoping + + +@pytest.mark.parallel +def test_content_viewset_permissions( + gen_users, + npm_bindings, + npm_repository_factory, + npm_remote_factory, + npm_package_url, + try_action, + monitor_task, +): + """Test that content listing is scoped by repository view permissions.""" + alice, bob, charlie = gen_users("npmrepository") + + # Admin creates repo with content via sync (local fixture server, no external deps) + remote = npm_remote_factory(url=npm_package_url) + repo = npm_repository_factory(remote=remote.pulp_href) + monitor_task(npm_bindings.RepositoriesNpmApi.sync(repo.pulp_href, {}).task) + + # Verify content was actually synced + repo = npm_bindings.RepositoriesNpmApi.read(repo.pulp_href) + ver = npm_bindings.RepositoriesNpmVersionsApi.read(repo.latest_version_href) + assert ver.content_summary.present["npm.package"]["count"] > 0 + + # alice (model-level npmrepository_viewer) sees content + a_list = try_action(alice, npm_bindings.ContentPackagesApi, "list", 200) + assert a_list.count > 0 + + # bob (model-level npmrepository_creator, no viewer) sees nothing + b_list = try_action(bob, npm_bindings.ContentPackagesApi, "list", 200) + assert b_list.count == 0 + + # charlie (no roles) sees nothing + c_list = try_action(charlie, npm_bindings.ContentPackagesApi, "list", 200) + assert c_list.count == 0 + + # Grant charlie object-level viewer role on the repo + npm_bindings.RepositoriesNpmApi.add_role( + repo.pulp_href, + {"users": [charlie.username], "role": "npm.npmrepository_viewer"}, + ) + + # Now charlie can see the content + c_list2 = try_action(charlie, npm_bindings.ContentPackagesApi, "list", 200) + assert c_list2.count > 0 + + +# Object-level role scoping +@pytest.mark.parallel +def test_object_level_roles( + gen_users, + npm_bindings, + npm_repository_factory, + try_action, +): + """Test that object-level roles grant access to specific objects only.""" + _alice, bob, charlie = gen_users("npmrepository") + + with bob: + repo_a = npm_repository_factory() + repo_b = npm_repository_factory() + + # charlie can't see either repo + try_action(charlie, npm_bindings.RepositoriesNpmApi, "read", 404, repo_a.pulp_href) + try_action(charlie, npm_bindings.RepositoriesNpmApi, "read", 404, repo_b.pulp_href) + + # Grant charlie viewer on repo_a only + with bob: + npm_bindings.RepositoriesNpmApi.add_role( + repo_a.pulp_href, + {"users": [charlie.username], "role": "npm.npmrepository_viewer"}, + ) + + # charlie can see repo_a but not repo_b + try_action(charlie, npm_bindings.RepositoriesNpmApi, "read", 200, repo_a.pulp_href) + try_action(charlie, npm_bindings.RepositoriesNpmApi, "read", 404, repo_b.pulp_href) + + c_list = try_action(charlie, npm_bindings.RepositoriesNpmApi, "list", 200) + assert c_list.count == 1 + + +@pytest.mark.parallel +def test_ping_unauthenticated( + npm_distribution_factory, + pulp_settings, +): + """Test that /-/ping is accessible without authentication via access policy.""" + import asyncio + import os + + import aiohttp + + distro = npm_distribution_factory() + protocol = os.environ.get("API_PROTOCOL", "https") + host = os.environ.get("API_HOST", "pulp") + port = os.environ.get("API_PORT", "443") + base = f"{protocol}://{host}:{port}" + domain = "default" if pulp_settings.DOMAIN_ENABLED else None + if domain: + url = f"{base}/npm/{domain}/{distro.base_path}/-/ping" + else: + url = f"{base}/npm/{distro.base_path}/-/ping" + + async def _get_no_auth(): + async with aiohttp.ClientSession() as session: + async with session.get(url, ssl=False) as resp: + return resp.status + + status_code = asyncio.run(_get_no_auth()) + assert status_code == 200