From 96992b59303cb9b1cd311074ee51485c57bd6896 Mon Sep 17 00:00:00 2001 From: James Tanner Date: Thu, 12 Oct 2023 10:17:45 -0400 Subject: [PATCH] Enable using users in addition to groups for RBAC. No-Issue Signed-off-by: James Tanner --- galaxy_ng/app/access_control/fields.py | 46 ++++++++ galaxy_ng/app/access_control/mixins.py | 51 +++++++++ galaxy_ng/app/api/v3/serializers/namespace.py | 11 +- galaxy_ng/app/models/namespace.py | 6 +- .../api/test_namespace_management.py | 105 ++++++++++++++++++ requirements/requirements.common.txt | 4 +- requirements/requirements.insights.txt | 4 +- requirements/requirements.standalone.txt | 4 +- setup.py | 2 +- 9 files changed, 223 insertions(+), 10 deletions(-) diff --git a/galaxy_ng/app/access_control/fields.py b/galaxy_ng/app/access_control/fields.py index d898dccf9c..1404deb850 100644 --- a/galaxy_ng/app/access_control/fields.py +++ b/galaxy_ng/app/access_control/fields.py @@ -1,4 +1,5 @@ from django.utils.translation import gettext_lazy as _ +from django.contrib.auth import get_user_model from rest_framework import serializers from rest_framework.exceptions import ValidationError @@ -10,6 +11,9 @@ from galaxy_ng.app.models import auth as auth_models +User = get_user_model() + + class GroupPermissionField(serializers.Field): def _validate_group(self, group_data): if 'object_roles' not in group_data: @@ -74,6 +78,48 @@ def to_internal_value(self, data): return internal +class UserPermissionField(serializers.Field): + + def _validate_user(self, user_data): + # FIXME - fill this in ... + pass + + def to_representation(self, value): + rep = [] + for user in value: + rep.append({ + 'id': user.id, + 'name': user.username, + 'object_roles': value[user] + }) + return rep + + def to_internal_value(self, data): + if not isinstance(data, list): + raise ValidationError(detail={ + 'users': _('Users must be a list of user objects') + }) + + internal = {} + for user_data in data: + self._validate_user(user_data) + user_filter = {} + for field in user_data: + if field in ('id', 'username'): + user_filter[field] = user_data[field] + + user = User.objects.filter(**user_filter).first() + if not user: + raise ValidationError(detail={'user': _('Invalid user username or ID')}) + + if 'object_permissions' in user_data: + internal[user] = user_data['object_permissions'] + if 'object_roles' in user_data: + internal[user] = user_data['object_roles'] + + return internal + + class MyPermissionsField(serializers.Serializer): def to_representation(self, original_obj): request = self.context.get('request', None) diff --git a/galaxy_ng/app/access_control/mixins.py b/galaxy_ng/app/access_control/mixins.py index 52e37a01e8..49c3582d60 100644 --- a/galaxy_ng/app/access_control/mixins.py +++ b/galaxy_ng/app/access_control/mixins.py @@ -8,6 +8,7 @@ assign_role, remove_role, get_groups_with_perms_attached_roles, + get_users_with_perms_attached_roles, ) from django_lifecycle import hook @@ -61,3 +62,53 @@ def _set_groups(self, groups): def set_object_groups(self): if self._groups: self._set_groups(self._groups) + + +class UserModelPermissionsMixin: + _users = None + + @property + def users(self): + return get_users_with_perms_attached_roles( + self, include_model_permissions=False, for_concrete_model=True) + + @users.setter + def users(self, users): + self._set_users(users) + + @transaction.atomic + def _set_users(self, users): + if self._state.adding: + self._users = users + else: + obj = self + + # If the model is a proxy model, get the original model since pulp + # doesn't allow us to assign permissions to proxied models. + if self._meta.proxy: + obj = self._meta.concrete_model.objects.get(pk=self.pk) + + current_users = get_users_with_perms_attached_roles( + obj, include_model_permissions=False) + for user in current_users: + for perm in current_users[user]: + remove_role(perm, user, obj) + + for user in users: + for role in users[user]: + try: + assign_role(role, user, obj) + except BadRequest: + raise ValidationError( + detail={ + 'users': _( + 'Role {role} does not exist or does not ' + 'have any permissions related to this object.' + ).format(role=role) + } + ) + + @hook('after_save') + def set_object_users(self): + if self._users: + self._set_users(self._users) diff --git a/galaxy_ng/app/api/v3/serializers/namespace.py b/galaxy_ng/app/api/v3/serializers/namespace.py index 0742d3debc..d76c3ba335 100644 --- a/galaxy_ng/app/api/v3/serializers/namespace.py +++ b/galaxy_ng/app/api/v3/serializers/namespace.py @@ -13,7 +13,11 @@ from galaxy_ng.app import models from galaxy_ng.app.tasks import dispatch_create_pulp_namespace_metadata -from galaxy_ng.app.access_control.fields import GroupPermissionField, MyPermissionsField +from galaxy_ng.app.access_control.fields import ( + GroupPermissionField, + UserPermissionField, + MyPermissionsField +) from galaxy_ng.app.api.base import RelatedFieldsBaseSerializer log = logging.getLogger(__name__) @@ -73,7 +77,8 @@ def validate_url(self, url): class NamespaceSerializer(serializers.ModelSerializer): links = NamespaceLinkSerializer(many=True, required=False) - groups = GroupPermissionField() + groups = GroupPermissionField(required=False) + users = UserPermissionField(required=False) related_fields = NamespaceRelatedFieldSerializer(source="*") avatar_url = fields.URLField(required=False, allow_blank=True) avatar_sha256 = serializers.SerializerMethodField() @@ -93,6 +98,7 @@ class Meta: 'description', 'links', 'groups', + 'users', 'resources', 'related_fields', 'metadata_sha256', @@ -178,6 +184,7 @@ class Meta: 'avatar_url', 'description', 'groups', + 'users', 'related_fields', 'metadata_sha256', 'avatar_sha256' diff --git a/galaxy_ng/app/models/namespace.py b/galaxy_ng/app/models/namespace.py index 2b7dbdcd38..b724d0d347 100644 --- a/galaxy_ng/app/models/namespace.py +++ b/galaxy_ng/app/models/namespace.py @@ -16,7 +16,11 @@ __all__ = ("Namespace", "NamespaceLink") -class Namespace(LifecycleModel, mixins.GroupModelPermissionsMixin): +class Namespace( + LifecycleModel, + mixins.GroupModelPermissionsMixin, + mixins.UserModelPermissionsMixin +): """ A model representing Ansible content namespace. diff --git a/galaxy_ng/tests/integration/api/test_namespace_management.py b/galaxy_ng/tests/integration/api/test_namespace_management.py index 7405af12ec..f47cf9ccb9 100644 --- a/galaxy_ng/tests/integration/api/test_namespace_management.py +++ b/galaxy_ng/tests/integration/api/test_namespace_management.py @@ -56,3 +56,108 @@ def test_namespace_create_and_delete(ansible_config, api_version): existing3 = get_all_namespaces(api_client=api_client, api_version=api_version) existing3 = dict((x['name'], x) for x in existing3) assert new_namespace not in existing3 + + +@pytest.mark.galaxyapi_smoke +@pytest.mark.namespace +@pytest.mark.all +@pytest.mark.parametrize( + "user_property", + [ + 'id', + 'username' + ] +) +def test_namespace_create_with_user(ansible_config, user_property): + config = ansible_config("partner_engineer") + api_client = get_client(config, request_token=True, require_auth=True) + api_prefix = config.get("api_prefix").rstrip("/") + + # find this client's user info... + me = api_client(f'{api_prefix}/_ui/v1/me/') + username = me['username'] + + new_namespace = generate_unused_namespace(api_client=api_client) + + # make a namespace with a user and without defining groups ... + object_roles = [ + 'galaxy.collection_namespace_owner', + 'galaxy.collection_publisher' + ] + payload = { + 'name': new_namespace, + 'users': [ + { + user_property: me.get(user_property), + 'object_roles': object_roles, + } + ] + } + resp = api_client(f'{api_prefix}/_ui/v1/my-namespaces/', args=payload, method='POST') + + # should have the right results ... + assert resp['name'] == new_namespace + assert resp['groups'] == [] + assert resp['users'] != [] + assert username in [x['name'] for x in resp['users']] + assert sorted(resp['users'][0]['object_roles']) == sorted(object_roles) + + +@pytest.mark.galaxyapi_smoke +@pytest.mark.namespace +@pytest.mark.all +@pytest.mark.parametrize( + "user_property", + [ + 'id', + 'username' + ] +) +def test_namespace_edit_with_user(ansible_config, user_property): + config = ansible_config("partner_engineer") + api_client = get_client(config, request_token=True, require_auth=True) + api_prefix = config.get("api_prefix").rstrip("/") + + # find this client's user info... + me = api_client(f'{api_prefix}/_ui/v1/me/') + username = me['username'] + + new_namespace = generate_unused_namespace(api_client=api_client) + + # make a namespace without users and without groups ... + payload = { + 'name': new_namespace, + } + resp = api_client(f'{api_prefix}/_ui/v1/my-namespaces/', args=payload, method='POST') + + # should have the right results ... + assert resp['name'] == new_namespace + assert resp['groups'] == [] + assert resp['users'] == [] + + # now edit the namespace to add the user + object_roles = [ + 'galaxy.collection_namespace_owner', + 'galaxy.collection_publisher' + ] + payload = { + 'name': new_namespace, + 'users': [ + { + user_property: me.get(user_property), + 'object_roles': object_roles, + } + ] + } + resp = api_client( + f'{api_prefix}/_ui/v1/my-namespaces/{new_namespace}/', + args=payload, + method='PUT' + ) + + # should have the right results ... + assert resp['name'] == new_namespace + assert resp['groups'] == [] + assert resp['users'] != [] + assert username in [x['name'] for x in resp['users']] + assert sorted(resp['users'][0]['object_roles']) == sorted(object_roles) diff --git a/requirements/requirements.common.txt b/requirements/requirements.common.txt index f8368aff01..bb1a67807f 100644 --- a/requirements/requirements.common.txt +++ b/requirements/requirements.common.txt @@ -314,13 +314,13 @@ psycopg[binary]==3.1.9 # via pulpcore psycopg-binary==3.1.9 # via psycopg -pulp-ansible==0.19.0 +pulp-ansible==0.20.1 # via galaxy-ng (setup.py) pulp-container==2.15.2 # via galaxy-ng (setup.py) pulp-glue==0.19.5 # via pulpcore -pulpcore==3.28.12 +pulpcore==3.28.17 # via # galaxy-ng (setup.py) # pulp-ansible diff --git a/requirements/requirements.insights.txt b/requirements/requirements.insights.txt index 56f3dec035..a70be64fd8 100644 --- a/requirements/requirements.insights.txt +++ b/requirements/requirements.insights.txt @@ -325,13 +325,13 @@ psycopg[binary]==3.1.9 # via pulpcore psycopg-binary==3.1.9 # via psycopg -pulp-ansible==0.19.0 +pulp-ansible==0.20.1 # via galaxy-ng (setup.py) pulp-container==2.15.2 # via galaxy-ng (setup.py) pulp-glue==0.19.5 # via pulpcore -pulpcore==3.28.12 +pulpcore==3.28.17 # via # galaxy-ng (setup.py) # pulp-ansible diff --git a/requirements/requirements.standalone.txt b/requirements/requirements.standalone.txt index 90fe78935d..d0739f5e60 100644 --- a/requirements/requirements.standalone.txt +++ b/requirements/requirements.standalone.txt @@ -314,13 +314,13 @@ psycopg[binary]==3.1.9 # via pulpcore psycopg-binary==3.1.9 # via psycopg -pulp-ansible==0.19.0 +pulp-ansible==0.20.1 # via galaxy-ng (setup.py) pulp-container==2.15.2 # via galaxy-ng (setup.py) pulp-glue==0.19.5 # via pulpcore -pulpcore==3.28.12 +pulpcore==3.28.17 # via # galaxy-ng (setup.py) # pulp-ansible diff --git a/setup.py b/setup.py index c7d0658bb3..ecfe74af94 100644 --- a/setup.py +++ b/setup.py @@ -113,7 +113,7 @@ def _format_pulp_requirement(plugin, specifier=None, ref=None, gh_namespace="pul requirements = [ "galaxy-importer>=0.4.13,<0.5.0", "pulpcore>=3.28.12,<3.29.0", - "pulp_ansible>=0.19.0,<0.20.0", + "pulp_ansible>=0.20.0,<0.21.0", "django-prometheus>=2.0.0", "drf-spectacular", "pulp-container>=2.15.0,<2.16.0",