From b363eaf3c8bc311fa77f2f3e24defc55d7f5c0f7 Mon Sep 17 00:00:00 2001 From: Lubos Mjachky Date: Fri, 2 Feb 2024 12:27:44 +0100 Subject: [PATCH] Add role-based access control closes #331 --- CHANGES/331.feature | 1 + .../migrations/0007_add_model_permissions.py | 25 + pulp_ostree/app/models.py | 20 +- pulp_ostree/app/viewsets.py | 438 +++++++++++++++++- pulp_ostree/tests/functional/api/test_rbac.py | 80 ++++ 5 files changed, 550 insertions(+), 14 deletions(-) create mode 100644 CHANGES/331.feature create mode 100644 pulp_ostree/app/migrations/0007_add_model_permissions.py create mode 100644 pulp_ostree/tests/functional/api/test_rbac.py diff --git a/CHANGES/331.feature b/CHANGES/331.feature new file mode 100644 index 00000000..cb5b07e3 --- /dev/null +++ b/CHANGES/331.feature @@ -0,0 +1 @@ +Added role-based access control. diff --git a/pulp_ostree/app/migrations/0007_add_model_permissions.py b/pulp_ostree/app/migrations/0007_add_model_permissions.py new file mode 100644 index 00000000..de6a0981 --- /dev/null +++ b/pulp_ostree/app/migrations/0007_add_model_permissions.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.9 on 2024-02-02 12:14 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('ostree', '0006_alter_pointers_to_related_models_globally'), + ] + + operations = [ + migrations.AlterModelOptions( + name='ostreedistribution', + options={'default_related_name': '%(app_label)s_%(model_name)s', 'permissions': [('manage_roles_ostreedistribution', 'Can manage roles on ostree distributions')]}, + ), + migrations.AlterModelOptions( + name='ostreeremote', + options={'default_related_name': '%(app_label)s_%(model_name)s', 'permissions': [('manage_roles_ostreeremote', 'Can manage roles on ostree remotes')]}, + ), + migrations.AlterModelOptions( + name='ostreerepository', + options={'default_related_name': '%(app_label)s_%(model_name)s', 'permissions': [('sync_ostreerepository', 'Can start a sync task'), ('modify_ostreerepository', 'Can modify content of the repository'), ('manage_roles_ostreerepository', 'Can manage roles on ostree repositories'), ('repair_ostreerepository', 'Can repair repository versions'), ('import_commits_ostreerepository', 'Can import commits into a repository')]}, + ), + ] diff --git a/pulp_ostree/app/models.py b/pulp_ostree/app/models.py index 68a83352..b691edae 100755 --- a/pulp_ostree/app/models.py +++ b/pulp_ostree/app/models.py @@ -4,6 +4,7 @@ from django.contrib.postgres.fields import ArrayField from pulpcore.plugin.models import ( + AutoAddObjPermsMixin, Content, Remote, Repository, @@ -123,7 +124,7 @@ class Meta: unique_together = [["sha256", "relative_path"]] -class OstreeRemote(Remote): +class OstreeRemote(Remote, AutoAddObjPermsMixin): """A remote model for OSTree content.""" TYPE = "ostree" @@ -134,9 +135,12 @@ class OstreeRemote(Remote): class Meta: default_related_name = "%(app_label)s_%(model_name)s" + permissions = [ + ("manage_roles_ostreeremote", "Can manage roles on ostree remotes"), + ] -class OstreeRepository(Repository): +class OstreeRepository(Repository, AutoAddObjPermsMixin): """A repository model for OSTree content.""" TYPE = "ostree" @@ -155,6 +159,13 @@ class OstreeRepository(Repository): class Meta: default_related_name = "%(app_label)s_%(model_name)s" + permissions = [ + ("sync_ostreerepository", "Can start a sync task"), + ("modify_ostreerepository", "Can modify content of the repository"), + ("manage_roles_ostreerepository", "Can manage roles on ostree repositories"), + ("repair_ostreerepository", "Can repair repository versions"), + ("import_commits_ostreerepository", "Can import commits into a repository"), + ] def finalize_new_version(self, new_version): """Handle repository duplicates.""" @@ -162,10 +173,13 @@ def finalize_new_version(self, new_version): validate_duplicate_content(new_version) -class OstreeDistribution(Distribution): +class OstreeDistribution(Distribution, AutoAddObjPermsMixin): """A distribution model for OSTree content.""" TYPE = "ostree" class Meta: default_related_name = "%(app_label)s_%(model_name)s" + permissions = [ + ("manage_roles_ostreedistribution", "Can manage roles on ostree distributions"), + ] diff --git a/pulp_ostree/app/viewsets.py b/pulp_ostree/app/viewsets.py index 4147c2cf..3f02ab26 100755 --- a/pulp_ostree/app/viewsets.py +++ b/pulp_ostree/app/viewsets.py @@ -15,24 +15,187 @@ RepositorySyncURLSerializer, ) from pulpcore.plugin.tasking import dispatch +from pulpcore.plugin.util import get_objects_for_user from . import models, serializers, tasks -class OstreeRemoteViewSet(core.RemoteViewSet): +REPO_VIEW_PERM = "ostree.view_ostreerepository" + + +class OstreeRemoteViewSet(core.RemoteViewSet, core.RolesMixin): """A ViewSet class for OSTree remote repositories.""" endpoint_name = "ostree" queryset = models.OstreeRemote.objects.all() serializer_class = serializers.OstreeRemoteSerializer - - -class OstreeRepositoryViewSet(core.RepositoryViewSet, ModifyRepositoryActionMixin): + queryset_filtering_required_permission = "ostree.view_ostreeremote" + + DEFAULT_ACCESS_POLICY = { + "statements": [ + { + "action": ["list", "my_permissions"], + "principal": "authenticated", + "effect": "allow", + }, + { + "action": ["create"], + "principal": "authenticated", + "effect": "allow", + "condition": "has_model_perms:ostree.add_ostreeremote", + }, + { + "action": ["retrieve"], + "principal": "authenticated", + "effect": "allow", + "condition": "has_model_or_obj_perms:ostree.view_ostreeremote", + }, + { + "action": ["update", "partial_update", "set_label", "unset_label"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_model_or_obj_perms:ostree.change_ostreeremote", + ], + }, + { + "action": ["destroy"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_model_or_obj_perms:ostree.delete_ostreeremote", + ], + }, + { + "action": ["list_roles", "add_role", "remove_role"], + "principal": "authenticated", + "effect": "allow", + "condition": ["has_model_or_obj_perms:ostree.manage_roles_ostreeremote"], + }, + ], + "creation_hooks": [ + { + "function": "add_roles_for_object_creator", + "parameters": {"roles": "ostree.ostreeremote_owner"}, + }, + ], + "queryset_scoping": {"function": "scope_queryset"}, + } + LOCKED_ROLES = { + "ostree.ostreeremote_creator": ["ostree.add_ostreeremote"], + "ostree.ostreeremote_owner": [ + "ostree.view_ostreeremote", + "ostree.change_ostreeremote", + "ostree.delete_ostreeremote", + "ostree.manage_roles_ostreeremote", + ], + "ostree.ostreeremote_viewer": ["ostree.view_ostreeremote"], + } + + +class OstreeRepositoryViewSet(core.RepositoryViewSet, ModifyRepositoryActionMixin, core.RolesMixin): """A ViewSet class for OSTree repositories.""" endpoint_name = "ostree" queryset = models.OstreeRepository.objects.all() serializer_class = serializers.OstreeRepositorySerializer + queryset_filtering_required_permission = "ostree.view_ostreerepository" + + DEFAULT_ACCESS_POLICY = { + "statements": [ + { + "action": ["list", "my_permissions"], + "principal": "authenticated", + "effect": "allow", + }, + { + "action": ["create"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_model_perms:ostree.add_ostreerepository", + "has_remote_param_model_or_obj_perms:ostree.view_ostreeremote", + ], + }, + { + "action": ["retrieve"], + "principal": "authenticated", + "effect": "allow", + "condition": "has_model_or_obj_perms:ostree.view_ostreerepository", + }, + { + "action": ["destroy"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_model_or_obj_perms:ostree.delete_ostreerepository", + ], + }, + { + "action": ["update", "partial_update", "set_label", "unset_label"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_model_or_obj_perms:ostree.change_ostreerepository", + "has_remote_param_model_or_obj_perms:ostree.view_ostreeremote", + ], + }, + { + "action": ["sync"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_model_or_obj_perms:ostree.sync_ostreerepository", + "has_remote_param_model_or_obj_perms:ostree.view_ostreeremote", + "has_model_or_obj_perms:ostree.view_ostreerepository", + ], + }, + { + "action": ["import_all", "import_commits"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_model_or_obj_perms:ostree.import_commits_ostreerepository" + "has_model_or_obj_perms:ostree.view_ostreerepository", + ], + }, + { + "action": ["modify"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_model_or_obj_perms:ostree.modify_ostreerepository", + ], + }, + { + "action": ["list_roles", "add_role", "remove_role"], + "principal": "authenticated", + "effect": "allow", + "condition": ["has_model_or_obj_perms:ostree.manage_roles_ostreerepository"], + }, + ], + "creation_hooks": [ + { + "function": "add_roles_for_object_creator", + "parameters": {"roles": "ostree.ostreerepository_owner"}, + }, + ], + "queryset_scoping": {"function": "scope_queryset"}, + } + LOCKED_ROLES = { + "ostree.ostreerepository_creator": ["ostree.add_ostreerepository"], + "ostree.ostreerepository_owner": [ + "ostree.view_ostreerepository", + "ostree.change_ostreerepository", + "ostree.delete_ostreerepository", + "ostree.modify_ostreerepository", + "ostree.sync_ostreerepository", + "ostree.manage_roles_ostreerepository", + "ostree.repair_ostreerepository", + "ostree.import_commits_ostreerepository", + ], + "ostree.ostreerepository_viewer": ["ostree.view_ostreerepository"], + } @extend_schema( description="Trigger an asynchronous task to sync content.", @@ -177,13 +340,108 @@ class OstreeRepositoryVersionViewSet(core.RepositoryVersionViewSet): parent_viewset = OstreeRepositoryViewSet + DEFAULT_ACCESS_POLICY = { + "statements": [ + { + "action": ["list", "retrieve"], + "principal": "authenticated", + "effect": "allow", + "condition": "has_repository_model_or_obj_perms:ostree.view_ostreerepository", + }, + { + "action": ["destroy"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_repository_model_or_obj_perms:ostree.delete_ostreerepository", + ], + }, + { + "action": ["repair"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_repository_model_or_obj_perms:ostree.repair_ostreerepository", + ], + }, + ], + } -class OstreeDistributionViewSet(core.DistributionViewSet): + +class OstreeDistributionViewSet(core.DistributionViewSet, core.RolesMixin): """A ViewSet class for OSTree distributions.""" endpoint_name = "ostree" queryset = models.OstreeDistribution.objects.all() serializer_class = serializers.OstreeDistributionSerializer + queryset_filtering_required_permission = "ostree.view_ostreedistribution" + + DEFAULT_ACCESS_POLICY = { + "statements": [ + { + "action": ["list", "my_permissions"], + "principal": "authenticated", + "effect": "allow", + }, + { + "action": ["create"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_model_perms:ostree.add_ostreedistribution", + "has_repo_or_repo_ver_param_model_or_obj_perms:" "ostree.view_ostreerepository", + "has_publication_param_model_or_obj_perms:ostree.view_ostreepublication", + ], + }, + { + "action": ["retrieve"], + "principal": "authenticated", + "effect": "allow", + "condition": "has_model_or_obj_perms:ostree.view_ostreedistribution", + }, + { + "action": ["update", "partial_update", "set_label", "unset_label"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_model_or_obj_perms:ostree.change_ostreedistribution", + "has_repo_or_repo_ver_param_model_or_obj_perms:" "ostree.view_ostreerepository", + "has_publication_param_model_or_obj_perms:ostree.view_ostreepublication", + ], + }, + { + "action": ["destroy"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_model_or_obj_perms:ostree.delete_ostreedistribution", + ], + }, + { + "action": ["list_roles", "add_role", "remove_role"], + "principal": "authenticated", + "effect": "allow", + "condition": ["has_model_or_obj_perms:ostree.manage_roles_ostreedistribution"], + }, + ], + "creation_hooks": [ + { + "function": "add_roles_for_object_creator", + "parameters": {"roles": "ostree.ostreedistribution_owner"}, + }, + ], + "queryset_scoping": {"function": "scope_queryset"}, + } + LOCKED_ROLES = { + "ostree.ostreedistribution_creator": ["ostree.add_ostreedistribution"], + "ostree.ostreedistribution_owner": [ + "ostree.view_ostreedistribution", + "ostree.change_ostreedistribution", + "ostree.delete_ostreedistribution", + "ostree.manage_roles_ostreedistribution", + ], + "ostree.ostreedistribution_viewer": ["ostree.view_ostreedistribution"], + } class OstreeRefFilter(ContentFilter): @@ -196,7 +454,45 @@ class Meta: fields = {"name": NAME_FILTER_OPTIONS} -class OstreeRefViewSet(ReadOnlyContentViewSet): +class OstreeContentQuerySetMixin: + """ + A mixin that filters content units based on their object-level permissions. + """ + + def _scope_repos_by_repo_version(self, repo_version_href): + repo_version = core.NamedModelViewSet.get_resource(repo_version_href, RepositoryVersion) + repo = repo_version.repository.cast() + + has_model_perm = self.request.user.has_perm(REPO_VIEW_PERM) + has_object_perm = self.request.user.has_perm(REPO_VIEW_PERM, repo) + + if has_model_perm or has_object_perm: + return [repo] + else: + return [] + + def get_content_qs(self, qs): + """ + Get a filtered QuerySet based on the current request's scope. + + This method returns only content units a user is allowed to preview. The user with the + global import and mirror permissions (i.e., having the "ostree.view_ostreerepository") + can see orphaned content too. + """ + if self.request.user.has_perm(REPO_VIEW_PERM): + return qs + + if repo_version_href := self.request.query_params.get("repository_version"): + allowed_repos = self._scope_repos_by_repo_version(repo_version_href) + else: + allowed_repos = get_objects_for_user( + self.request.user, REPO_VIEW_PERM, models.OstreeRepository.objects.all() + ).only("pk") + + return qs.model.objects.filter(repositories__in=allowed_repos) + + +class OstreeRefViewSet(OstreeContentQuerySetMixin, ReadOnlyContentViewSet): """A ViewSet class for OSTree head commits.""" endpoint_name = "refs" @@ -204,6 +500,26 @@ class OstreeRefViewSet(ReadOnlyContentViewSet): serializer_class = serializers.OstreeRefSerializer filterset_class = OstreeRefFilter + DEFAULT_ACCESS_POLICY = { + "statements": [ + { + "action": ["list", "retrieve"], + "principal": "authenticated", + "effect": "allow", + }, + { + "action": ["create"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_required_repo_perms_on_upload:ostree.modify_ostreerepository", + "has_upload_param_model_or_obj_perms:core.change_upload", + ], + }, + ], + "queryset_scoping": {"function": "get_content_qs"}, + } + class OstreeCommitFilter(ContentFilter): """A filterset class for commits.""" @@ -213,7 +529,7 @@ class Meta: fields = {"checksum": ["exact"]} -class OstreeCommitViewSet(ReadOnlyContentViewSet): +class OstreeCommitViewSet(OstreeContentQuerySetMixin, ReadOnlyContentViewSet): """A ViewSet class for OSTree commits.""" endpoint_name = "commits" @@ -221,6 +537,26 @@ class OstreeCommitViewSet(ReadOnlyContentViewSet): serializer_class = serializers.OstreeCommitSerializer filterset_class = OstreeCommitFilter + DEFAULT_ACCESS_POLICY = { + "statements": [ + { + "action": ["list", "retrieve"], + "principal": "authenticated", + "effect": "allow", + }, + { + "action": ["create"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_required_repo_perms_on_upload:ostree.modify_ostreerepository", + "has_upload_param_model_or_obj_perms:core.change_upload", + ], + }, + ], + "queryset_scoping": {"function": "get_content_qs"}, + } + class OstreeObjectFilter(ContentFilter): """A filterset class for objects.""" @@ -230,7 +566,7 @@ class Meta: fields = {"checksum": ["exact"]} -class OstreeObjectViewSet(ReadOnlyContentViewSet): +class OstreeObjectViewSet(OstreeContentQuerySetMixin, ReadOnlyContentViewSet): """A ViewSet class for OSTree objects (e.g., dirtree, dirmeta, file).""" endpoint_name = "objects" @@ -238,26 +574,106 @@ class OstreeObjectViewSet(ReadOnlyContentViewSet): serializer_class = serializers.OstreeObjectSerializer filterset_class = OstreeObjectFilter + DEFAULT_ACCESS_POLICY = { + "statements": [ + { + "action": ["list", "retrieve"], + "principal": "authenticated", + "effect": "allow", + }, + { + "action": ["create"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_required_repo_perms_on_upload:ostree.modify_ostreerepository", + "has_upload_param_model_or_obj_perms:core.change_upload", + ], + }, + ], + "queryset_scoping": {"function": "get_content_qs"}, + } + -class OstreeContentViewSet(ReadOnlyContentViewSet): +class OstreeContentViewSet(OstreeContentQuerySetMixin, ReadOnlyContentViewSet): """A ViewSet class for uncategorized content units (e.g., static deltas).""" endpoint_name = "content" queryset = models.OstreeContent.objects.all() serializer_class = serializers.OstreeContentSerializer + DEFAULT_ACCESS_POLICY = { + "statements": [ + { + "action": ["list", "retrieve"], + "principal": "authenticated", + "effect": "allow", + }, + { + "action": ["create"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_required_repo_perms_on_upload:ostree.modify_ostreerepository", + "has_upload_param_model_or_obj_perms:core.change_upload", + ], + }, + ], + "queryset_scoping": {"function": "get_content_qs"}, + } -class OstreeConfigViewSet(ReadOnlyContentViewSet): + +class OstreeConfigViewSet(OstreeContentQuerySetMixin, ReadOnlyContentViewSet): """A ViewSet class for OSTree repository configurations.""" endpoint_name = "configs" queryset = models.OstreeConfig.objects.all() serializer_class = serializers.OstreeConfigSerializer + DEFAULT_ACCESS_POLICY = { + "statements": [ + { + "action": ["list", "retrieve"], + "principal": "authenticated", + "effect": "allow", + }, + { + "action": ["create"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_required_repo_perms_on_upload:ostree.modify_ostreerepository", + "has_upload_param_model_or_obj_perms:core.change_upload", + ], + }, + ], + "queryset_scoping": {"function": "get_content_qs"}, + } + -class OstreeSummaryViewSet(ReadOnlyContentViewSet): +class OstreeSummaryViewSet(OstreeContentQuerySetMixin, ReadOnlyContentViewSet): """A ViewSet class for OSTree repository summary files.""" endpoint_name = "summaries" queryset = models.OstreeSummary.objects.all() serializer_class = serializers.OstreeSummarySerializer + + DEFAULT_ACCESS_POLICY = { + "statements": [ + { + "action": ["list", "retrieve"], + "principal": "authenticated", + "effect": "allow", + }, + { + "action": ["create"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_required_repo_perms_on_upload:ostree.modify_ostreerepository", + "has_upload_param_model_or_obj_perms:core.change_upload", + ], + }, + ], + "queryset_scoping": {"function": "get_content_qs"}, + } diff --git a/pulp_ostree/tests/functional/api/test_rbac.py b/pulp_ostree/tests/functional/api/test_rbac.py new file mode 100644 index 00000000..58dee32d --- /dev/null +++ b/pulp_ostree/tests/functional/api/test_rbac.py @@ -0,0 +1,80 @@ +import pytest + +from pulpcore.client.pulp_ostree.exceptions import ApiException + + +@pytest.mark.parallel +def test_crud_remotes(gen_user, ostree_remote_factory, ostree_remotes_api_client, monitor_task): + """Verify if users with different permissions can(not) perform CRUD operations on remotes.""" + user_creator = gen_user(model_roles=["ostree.ostreeremote_creator"]) + user_viewer = gen_user(model_roles=["ostree.ostreeremote_viewer"]) + user_anon = gen_user() + + with user_anon, pytest.raises(ApiException): + ostree_remote_factory() + with user_viewer, pytest.raises(ApiException): + ostree_remote_factory() + with user_creator: + remote = ostree_remote_factory() + + with user_anon: + assert 0 == ostree_remotes_api_client.list(name=remote.name).count + with user_viewer: + assert 1 == ostree_remotes_api_client.list(name=remote.name).count + with user_creator: + assert 1 == ostree_remotes_api_client.list(name=remote.name).count + + with user_anon, pytest.raises(ApiException): + ostree_remotes_api_client.read(remote.pulp_href) + with user_viewer: + ostree_remotes_api_client.read(remote.pulp_href) + with user_creator: + ostree_remotes_api_client.read(remote.pulp_href) + + with user_anon, pytest.raises(ApiException): + ostree_remotes_api_client.partial_update(remote.pulp_href, {"url": "https://redhat.com"}) + with user_viewer, pytest.raises(ApiException): + ostree_remotes_api_client.partial_update(remote.pulp_href, {"url": "https://redhat.com"}) + with user_creator: + ostree_remotes_api_client.partial_update(remote.pulp_href, {"url": "https://redhat.com"}) + + with user_anon, pytest.raises(ApiException): + ostree_remotes_api_client.delete(remote.pulp_href) + with user_viewer, pytest.raises(ApiException): + ostree_remotes_api_client.delete(remote.pulp_href) + with user_creator: + monitor_task(ostree_remotes_api_client.delete(remote.pulp_href).task) + + +@pytest.mark.parallel +def test_ref_content_access(gen_user, sync_repo_version, ostree_content_refs_api_client): + """Verify if users with different access scopes can(not) preview refs.""" + user_creator = gen_user( + model_roles=["ostree.ostreerepository_creator", "ostree.ostreeremote_creator"] + ) + user_viewer = gen_user(model_roles=["ostree.ostreerepository_viewer"]) + user_anon = gen_user() + + with user_anon, pytest.raises(ApiException): + sync_repo_version() + with user_viewer, pytest.raises(ApiException): + sync_repo_version() + with user_creator: + version, _, _ = sync_repo_version() + + repo_version = version.pulp_href + with user_anon: + assert 0 == ostree_content_refs_api_client.list(repository_version=repo_version).count + with user_viewer: + assert 2 == ostree_content_refs_api_client.list(repository_version=repo_version).count + with user_creator: + assert 2 == ostree_content_refs_api_client.list(repository_version=repo_version).count + + ref = ostree_content_refs_api_client.list(repository_version=repo_version).results[0] + + with user_anon, pytest.raises(ApiException): + ostree_content_refs_api_client.read(ref.pulp_href) + with user_viewer: + ostree_content_refs_api_client.read(ref.pulp_href) + with user_creator: + ostree_content_refs_api_client.read(ref.pulp_href)