diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 6a7343bd30c6f680fa04bae4f8769bd3bdbb14eb..6f2f75d972385e5fedb73344878fad907e843ae4 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -2,9 +2,22 @@ image: python:3.6 stages: + - test - release include: - project: 'infra/platform' ref: master file: '/templates/python.ci.yml' + + +test: + stage: test + script: + - pip install .[dev] + - python -m unittest djangoldp_community.tests.runner + except: + - master + - tags + tags: + - test \ No newline at end of file diff --git a/djangoldp_community/migrations/0003_auto_20210218_1145.py b/djangoldp_community/migrations/0003_auto_20210218_1145.py new file mode 100644 index 0000000000000000000000000000000000000000..7817aa6ef098ec7771c84e1dc5782da8c291f48e --- /dev/null +++ b/djangoldp_community/migrations/0003_auto_20210218_1145.py @@ -0,0 +1,38 @@ +# Generated by Django 2.2.18 on 2021-02-18 10:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('djangoldp_community', '0002_auto_20210217_1205'), + ] + + operations = [ + migrations.AlterModelOptions( + name='community', + options={'default_permissions': ['add', 'change', 'delete', 'view', 'control'], 'ordering': ['slug'], 'verbose_name': 'community', 'verbose_name_plural': 'communities'}, + ), + migrations.AlterModelOptions( + name='communitycircle', + options={'default_permissions': ['add', 'change', 'delete', 'view', 'control'], 'verbose_name': 'community circle', 'verbose_name_plural': 'community circles'}, + ), + migrations.AlterModelOptions( + name='communityjoboffer', + options={'default_permissions': ['add', 'change', 'delete', 'view', 'control'], 'verbose_name': 'community job offer', 'verbose_name_plural': 'community job offers'}, + ), + migrations.AlterModelOptions( + name='communitymember', + options={'default_permissions': ['add', 'change', 'delete', 'view', 'control'], 'verbose_name': 'community member', 'verbose_name_plural': 'community members'}, + ), + migrations.AlterModelOptions( + name='communityproject', + options={'default_permissions': ['add', 'change', 'delete', 'view', 'control'], 'verbose_name': 'community project', 'verbose_name_plural': 'community projects'}, + ), + migrations.AlterField( + model_name='community', + name='name', + field=models.CharField(blank=True, help_text="Changing a community's name is highly discouraged", max_length=255), + ), + ] diff --git a/djangoldp_community/models.py b/djangoldp_community/models.py index c7e95fa3f7be4f7dfbe603378b80d19cc1f1d8a1..ca0e4ecb446e0f7075bfb9d47ea21a64fe4a88c3 100644 --- a/djangoldp_community/models.py +++ b/djangoldp_community/models.py @@ -8,7 +8,9 @@ from djangoldp.models import Model from django.utils.text import slugify from django.utils.translation import ugettext_lazy as _ -from djangoldp_community.permissions import CommunityPermissions +from djangoldp_community.permissions import CommunityPermissions, CommunityCirclePermissions, \ + CommunityProjectPermissions +from djangoldp_community.views import CommunityMembersViewset from djangoldp_circle.models import Circle from djangoldp_project.models import Project @@ -27,8 +29,9 @@ class Community(Model): verbose_name = _('community') verbose_name_plural = _("communities") permission_classes = [CommunityPermissions] - anonymous_perms = [] - authenticated_perms = [] + anonymous_perms = ['view'] + authenticated_perms = ['inherit', 'add'] + superuser_perms = ['view'] lookup_field = 'slug' container_path = "/communities/" ordering = ['slug'] @@ -43,9 +46,11 @@ class CommunityMember(Model): class Meta(Model.Meta): verbose_name = _('community member') verbose_name_plural = _("community members") + view_set = CommunityMembersViewset permission_classes = [CommunityPermissions] - anonymous_perms = [] - authenticated_perms = [] + anonymous_perms = ['view', 'add'] + authenticated_perms = ['inherit'] + superuser_perms = ['view', 'add'] container_path = "community-members/" serializer_fields = ['@id', 'community', 'user', 'is_admin'] rdf_type = "as:items" @@ -57,9 +62,10 @@ class CommunityCircle(Model): class Meta(Model.Meta): verbose_name = _('community circle') verbose_name_plural = _("community circles") - permission_classes = [CommunityPermissions] + permission_classes = [CommunityPermissions, CommunityCirclePermissions] anonymous_perms = [] authenticated_perms = [] + superuser_perms = [] container_path = "community-circles/" serializer_fields = ['@id', 'community', 'circle'] rdf_type = "as:items" @@ -71,9 +77,10 @@ class CommunityProject(Model): class Meta(Model.Meta): verbose_name = _('community project') verbose_name_plural = _("community projects") - permission_classes = [CommunityPermissions] + permission_classes = [CommunityPermissions, CommunityProjectPermissions] anonymous_perms = [] authenticated_perms = [] + superuser_perms = [] container_path = "community-projects/" serializer_fields = ['@id', 'community', 'project'] rdf_type = "as:items" @@ -88,6 +95,7 @@ class CommunityJobOffer(Model): permission_classes = [CommunityPermissions] anonymous_perms = [] authenticated_perms = [] + superuser_perms = [] container_path = "community-joboffers/" serializer_fields = ['@id', 'community', 'joboffer'] rdf_type = "as:items" diff --git a/djangoldp_community/permissions.py b/djangoldp_community/permissions.py index 95cff43c7823cdc34181eb2706367f5890819e63..075d6e7052eea4436904aae1d3aafba315353a2b 100644 --- a/djangoldp_community/permissions.py +++ b/djangoldp_community/permissions.py @@ -1,97 +1,65 @@ from djangoldp.permissions import LDPPermissions -from django.db.models.base import ModelBase +from djangoldp.utils import is_authenticated_user +from djangoldp_project.permissions import ProjectPermissions +from djangoldp_circle.permissions import CirclePermissions class CommunityPermissions(LDPPermissions): filter_backends = [] - def user_permissions(self, user, model, obj, community=None): - # Get guardians permissions, over everything else - perms = set(super().user_permissions(user, model, obj)) + def _get_community_from_obj(self, obj): + from djangoldp_community.models import Community + if isinstance(obj, Community): + return obj + + if not hasattr(obj, 'community') or not isinstance(obj.community, Community): + raise KeyError('Object ' + str(obj) + ' must have a ForeignKey "community" to model Community to use CommunityPermissions') - # Communities affiliations list are public - perms = perms.union({'view'}) + return obj.community + def get_object_permissions(self, request, view, obj): from djangoldp_community.models import Community, CommunityMember - if community and isinstance(community, Community): - if model == CommunityMember and community.allow_self_registration: - perms = perms.union({'add'}) + perms = set(super().get_object_permissions(request, view, obj)) - # Get the user membership, also manage the anonymous user - try: - membership = community.members.get(user=user) - except: - membership = None + community = self._get_community_from_obj(obj) - if membership: - # Any member can add a job offer, circle or project to the community - if not model == Community and not model == CommunityMember: - perms = perms.union({'add'}) + if is_authenticated_user(request.user) and community.members.filter(user=request.user).exists(): + # Any member can add a job offer, circle or project to the community + if not isinstance(obj, Community) and not isinstance(obj, CommunityMember): + perms = perms.union({'add'}) - if membership.is_admin: - # Admins can add members - perms = perms.union({'add'}) + member = community.members.get(user=request.user) + if member.is_admin: + # Admins can add members + perms = perms.union({'add', 'change'}) - if not isinstance(obj, Community) and not obj.is_admin: + if isinstance(obj, CommunityMember): + if (obj.user == request.user and community.members.filter(is_admin=True).count() > 1) \ + or not obj.is_admin: # Admins can't delete community or other admins, but have super-powers on everything else + # I can't delete myself if I am the last member perms = perms.union({'delete'}) + elif not isinstance(obj, Community): + perms = perms.union({'delete'}) + elif isinstance(obj, CommunityMember) and obj.user == request.user: + perms = perms.union({'delete'}) - return list(perms) - - - def get_model_or_obj(self, request, view, origin=None): - - from djangoldp.models import Model - if self.is_a_container(request._request.path): - try: - # Container have a parent, follow its perms - obj = Model.resolve_parent(request.path) - model = view.parent_model - except: - # Container have no parent - obj = None - model = view.model - else: - # Is an object - obj = Model.resolve_id(request._request.path) - model = view.model - - if origin: - # Always follow origins - if isinstance(origin, ModelBase): - # Have an origin, is a model - model = origin - if not isinstance(origin, ModelBase): - # Have an origin, is an object - obj = origin - - from djangoldp_community.models import Community - # Get the related community, from origin or from the object itself - if isinstance(origin, Community): - community = origin - else: - community = getattr(obj, 'community', obj) - - return community, model, obj + return perms - def filter_user_perms(self, context, origin, permissions): - # Only used on Model.get_permissions to translate permissions to LDP - community, model, obj = self.get_model_or_obj(context['request'], context['view'], origin) - return [perm for perm in permissions if perm in self.user_permissions(context['request'].user, model, obj, community)] +class CommunityCirclePermissions(CirclePermissions): + filter_backends = [] + def get_object_permissions(self, request, view, obj): + obj = obj.circle + return set(super().get_object_permissions(request, view, obj)) - def has_permission(self, request, view): - # get permissions required - community, model, obj = self.get_model_or_obj(request, view) - perms = self.get_permissions(request.method, model) - user_perms = self.user_permissions(request.user, model, obj, community) - # compare them with the permissions I have - for perm in perms: - if not perm.split('.')[-1].split('_')[0] in user_perms: - return False +class CommunityProjectPermissions(ProjectPermissions): + filter_backends = [] - return True + def get_object_permissions(self, request, view, obj): + obj = obj.project + return set(super().get_object_permissions(request, view, obj)) diff --git a/djangoldp_community/tests/__init__.py b/djangoldp_community/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/djangoldp_community/tests/models.py b/djangoldp_community/tests/models.py new file mode 100644 index 0000000000000000000000000000000000000000..7935695475e7d30f8e68e003f0b675493c6c9f4c --- /dev/null +++ b/djangoldp_community/tests/models.py @@ -0,0 +1,13 @@ +from django.contrib.auth.models import AbstractUser + +from djangoldp.models import Model + + +# we have a custom user Model for running in the test envrionment only +class User(AbstractUser, Model): + + class Meta(AbstractUser.Meta, Model.Meta): + serializer_fields = ['@id', 'username', 'first_name', 'last_name', 'email'] + anonymous_perms = ['view', 'add'] + authenticated_perms = ['inherit', 'change'] + owner_perms = ['inherit'] diff --git a/djangoldp_community/tests/runner.py b/djangoldp_community/tests/runner.py new file mode 100644 index 0000000000000000000000000000000000000000..00d80354e89eff186140fdf3fb4a57c80b93bffa --- /dev/null +++ b/djangoldp_community/tests/runner.py @@ -0,0 +1,47 @@ +import sys +import yaml + +import django +from django.conf import settings as django_settings +from djangoldp.conf.ldpsettings import LDPSettings +from djangoldp.tests.settings_default import yaml_config + +# this is where we configure the server settings that we will run our tests with +config = { + # add the packages to the reference list + 'ldppackages': ['modeltranslation', 'djangoldp_circle', 'djangoldp_project', 'djangoldp_conversation', 'djangoldp_community', + 'djangoldp_skill', 'djangoldp_joboffer', 'djangoldp_community.tests'], + + # required values for server + 'server': { + 'AUTH_USER_MODEL': 'tests.User', + 'REST_FRAMEWORK': { + 'DEFAULT_PAGINATION_CLASS': 'djangoldp.pagination.LDPPagination', + 'PAGE_SIZE': 5 + }, + # map the config of the core settings (avoid asserts to fail) + 'SITE_URL': 'http://happy-dev.fr', + 'BASE_URL': 'http://happy-dev.fr', + 'SEND_BACKLINKS': False, + 'JABBER_DEFAULT_HOST': None, + 'PERMISSIONS_CACHE': False, + 'ANONYMOUS_USER_NAME': None, + 'SERIALIZER_CACHE': False + } +} +ldpsettings = LDPSettings(config) +ldpsettings.config = yaml.safe_load(yaml_config) + +django_settings.configure(ldpsettings) + +django.setup() +from django.test.runner import DiscoverRunner + +test_runner = DiscoverRunner(verbosity=1) + +# this is where we link our test classes to the runner +failures = test_runner.run_tests([ + 'djangoldp_community.tests.tests_permissions', +]) +if failures: + sys.exit(failures) diff --git a/djangoldp_community/tests/tests_permissions.py b/djangoldp_community/tests/tests_permissions.py new file mode 100644 index 0000000000000000000000000000000000000000..1fe158f1dd0b2c6fd0f33bef8671f18cda162f34 --- /dev/null +++ b/djangoldp_community/tests/tests_permissions.py @@ -0,0 +1,249 @@ +import uuid +import json +from datetime import datetime, timedelta + +from djangoldp.serializers import LDListMixin, LDPSerializer +from rest_framework.test import APITestCase, APIClient + +from djangoldp_community.models import Community, CommunityMember, CommunityCircle, CommunityProject, CommunityJobOffer +from djangoldp_community.tests.models import User + + +class PermissionsTestCase(APITestCase): + # Django runs setUp automatically before every test + def setUp(self): + # we set up a client, that allows us + self.client = APIClient() + LDListMixin.to_representation_cache.reset() + LDPSerializer.to_representation_cache.reset() + + # we have custom set up functions for things that we don't want to run before *every* test, e.g. often we want to + # set up an authenticated user, but sometimes we want to run a test with an anonymous user + def setUpLoggedInUser(self, is_superuser=False): + self.user = User(email='test@mactest.co.uk', first_name='Test', last_name='Mactest', username='test', + password='glass onion', is_superuser=is_superuser) + self.user.save() + # this means that our user is now logged in (as if they had typed username and password) + self.client.force_authenticate(user=self.user) + + # we write functions like this for convenience - we can reuse between tests + def _get_random_user(self): + return User.objects.create(email='{}@test.co.uk'.format(str(uuid.uuid4())), first_name='Test', + last_name='Test', + username=str(uuid.uuid4())) + + def _get_random_community(self): + return Community.objects.create(name='Test', slug=str(uuid.uuid4())) + + def _get_community_member(self, user, community, is_admin=False): + return CommunityMember.objects.create(user=user, community=community, is_admin=is_admin) + + ''' + list communities - public + list community members - public + create community - authenticated + update, delete, control community - community admin only + create, update, delete, control community member - community admin only + Admins can't remove admins (or themselves if they're the last admin) + community projects - apply Project permissions (same for JobOffers and Circles) + ''' + # only authenticated users can create communities + def test_post_community_anonymous(self): + response = self.client.post('/communities/', data=json.dumps({}), content_type='application/ld+json') + self.assertEqual(response.status_code, 403) + + def test_post_community_authenticated(self): + self.setUpLoggedInUser() + response = self.client.post('/communities/', data=json.dumps({}), content_type='application/ld+json') + self.assertEqual(response.status_code, 201) + + # only community admins can update communities + def test_update_community_is_admin(self): + self.setUpLoggedInUser() + community = self._get_random_community() + self._get_community_member(user=self.user, community=community, is_admin=True) + response = self.client.patch('/communities/{}/'.format(community.slug), data=json.dumps({}), + content_type='application/ld+json') + self.assertEqual(response.status_code, 200) + + def test_update_community_is_member(self): + self.setUpLoggedInUser(is_superuser=False) + community = self._get_random_community() + self._get_community_member(user=self.user, community=community, is_admin=False) + response = self.client.patch('/communities/{}/'.format(community.slug), data=json.dumps({}), + content_type='application/ld+json') + self.assertEqual(response.status_code, 403) + + def test_update_community_is_auth_super_user(self): + self.setUpLoggedInUser(is_superuser=True) + community = self._get_random_community() + self._get_community_member(user=self.user, community=community, is_admin=False) + response = self.client.patch('/communities/{}/'.format(community.slug), data=json.dumps({}), + content_type='application/ld+json') + self.assertEqual(response.status_code, 403) + + # only community admins can delete communities + def test_delete_community_is_admin(self): + self.setUpLoggedInUser(is_superuser=False) + community = self._get_random_community() + self._get_community_member(user=self.user, community=community, is_admin=True) + response = self.client.delete('/communities/{}/'.format(community.slug)) + self.assertEqual(response.status_code, 403) + + def test_delete_community_is_member(self): + self.setUpLoggedInUser(is_superuser=False) + community = self._get_random_community() + self._get_community_member(user=self.user, community=community, is_admin=False) + response = self.client.delete('/communities/{}/'.format(community.slug)) + self.assertEqual(response.status_code, 403) + + def test_delete_community_is_auth_super_user(self): + self.setUpLoggedInUser(is_superuser=True) + community = self._get_random_community() + self._get_community_member(user=self.user, community=community, is_admin=False) + response = self.client.delete('/communities/{}/'.format(community.slug)) + self.assertEqual(response.status_code, 403) + + # TODO: https://git.startinblox.com/djangoldp-packages/djangoldp/issues/363 + ''' + def test_get_community_is_member(self): + self.setUpLoggedInUser(is_superuser=False) + community = self._get_random_community() + me = self._get_community_member(user=self.user, community=community, is_admin=False) + + response = self.client.get('/communities/{}/members/'.format(community.slug)) + self.assertEqual(response.status_code, 200) + self.assertNotIn({'mode': {'@type': 'add'}}, response.data['permissions']) + self.assertEqual(len(response.data['permissions']), 1) + ''' + + # only community admins can do any operation on community members + def test_add_community_member_is_admin(self): + self.setUpLoggedInUser(is_superuser=False) + another_user = self._get_random_user() + + community = self._get_random_community() + me = self._get_community_member(user=self.user, community=community, is_admin=True) + + body = { + 'http://happy-dev.fr/owl#community': community.urlid, + 'http://happy-dev.fr/owl#user': another_user.urlid + } + + response = self.client.post('/communities/{}/members/'.format(community.slug), + body=json.dumps(body), content_type='application/ld+json') + self.assertEqual(response.status_code, 201) + + def test_add_community_member_is_member(self): + self.setUpLoggedInUser(is_superuser=False) + another_user = self._get_random_user() + + community = self._get_random_community() + me = self._get_community_member(user=self.user, community=community, is_admin=False) + + body = { + 'http://happy-dev.fr/owl#community': community.urlid, + 'http://happy-dev.fr/owl#user': another_user.urlid + } + + response = self.client.post('/communities/{}/members/', body=json.dumps(body), content_type='application/ld+json') + self.assertEqual(response.status_code, 404) + + # TODO: https://git.startinblox.com/djangoldp-packages/djangoldp-community/issues/3 + ''' + def test_add_community_member_is_admin_no_parent(self): + self.setUpLoggedInUser(is_superuser=False) + another_user = self._get_random_user() + + community = self._get_random_community() + me = self._get_community_member(user=self.user, community=community, is_admin=True) + + body = { + 'http://happy-dev.fr/owl#community': community.urlid, + 'http://happy-dev.fr/owl#user': another_user.urlid + } + + response = self.client.post('/community-members/', body=json.dumps(body), content_type='application/ld+json') + self.assertEqual(response.status_code, 201) + ''' + + def test_delete_community_member_is_admin(self): + self.setUpLoggedInUser(is_superuser=False) + another_user = self._get_random_user() + + community = self._get_random_community() + me = self._get_community_member(user=self.user, community=community, is_admin=True) + member = self._get_community_member(user=another_user, community=community, is_admin=False) + + response = self.client.delete('/community-members/{}/'.format(member.pk)) + self.assertEqual(response.status_code, 204) + + def test_delete_community_member_is_member(self): + self.setUpLoggedInUser(is_superuser=False) + another_user = self._get_random_user() + + community = self._get_random_community() + me = self._get_community_member(user=self.user, community=community, is_admin=False) + member = self._get_community_member(user=another_user, community=community, is_admin=False) + + response = self.client.delete('/community-members/{}/'.format(member.pk)) + self.assertEqual(response.status_code, 403) + + def test_delete_community_member_is_auth_super_user(self): + self.setUpLoggedInUser(is_superuser=True) + another_user = self._get_random_user() + + community = self._get_random_community() + me = self._get_community_member(user=self.user, community=community, is_admin=False) + member = self._get_community_member(user=another_user, community=community, is_admin=False) + + response = self.client.delete('/community-members/{}/'.format(member.pk)) + self.assertEqual(response.status_code, 403) + + # community admins cannot remove other admins + def test_delete_community_admin_is_admin(self): + self.setUpLoggedInUser(is_superuser=False) + another_user = self._get_random_user() + + community = self._get_random_community() + me = self._get_community_member(user=self.user, community=community, is_admin=True) + member = self._get_community_member(user=another_user, community=community, is_admin=True) + + response = self.client.delete('/community-members/{}/'.format(member.pk)) + self.assertEqual(response.status_code, 403) + + # community admins can remove themselves + def test_delete_self_is_admin(self): + self.setUpLoggedInUser(is_superuser=False) + another_user = self._get_random_user() + + community = self._get_random_community() + me = self._get_community_member(user=self.user, community=community, is_admin=True) + member = self._get_community_member(user=another_user, community=community, is_admin=True) + + response = self.client.delete('/community-members/{}/'.format(me.pk)) + self.assertEqual(response.status_code, 204) + + # community admins cannot remove themselves if they are the last admin + def test_delete_self_is_last_admin(self): + self.setUpLoggedInUser(is_superuser=False) + another_user = self._get_random_user() + + community = self._get_random_community() + me = self._get_community_member(user=self.user, community=community, is_admin=True) + member = self._get_community_member(user=another_user, community=community, is_admin=False) + + response = self.client.delete('/community-members/{}/'.format(me.pk)) + self.assertEqual(response.status_code, 403) + + # regular users can remove themselves + def test_delete_self(self): + self.setUpLoggedInUser(is_superuser=True) + another_user = self._get_random_user() + + community = self._get_random_community() + me = self._get_community_member(user=self.user, community=community, is_admin=False) + member = self._get_community_member(user=another_user, community=community, is_admin=True) + + response = self.client.delete('/community-members/{}/'.format(me.pk)) + self.assertEqual(response.status_code, 204) diff --git a/djangoldp_community/views.py b/djangoldp_community/views.py index 7cde7fa7b10dedfa207ef43ec73806a4471dd237..6e324579fe964b1188c2f0e1da4b296b465e51ad 100644 --- a/djangoldp_community/views.py +++ b/djangoldp_community/views.py @@ -1,12 +1,38 @@ +from django.http import Http404 from djangoldp.views import LDPViewSet +from djangoldp.utils import is_authenticated_user + + +class CommunityMembersViewset(LDPViewSet): + + def get_parent(self): + raise NotImplementedError("get_parent not implemented in CommunityMembersViewSet") + + def is_safe_create(self, user, validated_data, *args, **kwargs): + from djangoldp_community.models import Community + + try: + if 'community' in validated_data.keys(): + community = Community.objects.get(urlid=validated_data['community']['urlid']) + else: + community = self.get_parent() + + if community.allow_self_registration or \ + (is_authenticated_user(user) and community.members.filter(user=user, is_admin=True).exists()): + return True + except Community.DoesNotExist: + return True + except (KeyError, AttributeError): + raise Http404('community not specified with urlid') + + return False + class OpenCommunitiesViewset(LDPViewSet): def get_queryset(self): queryset = super().get_queryset().exclude(allow_self_registration=False) # invalidate cache for every open communities, unless that if /open-communities/ is loaded before /communities/xyz/, the last one will get wrong permission nodes - from djangoldp.permissions import LDPPermissions from djangoldp.serializers import LDListMixin, LDPSerializer - LDPPermissions.invalidate_cache() LDListMixin.to_representation_cache.reset() for result in queryset: if(result.urlid): diff --git a/setup.cfg b/setup.cfg index a631829ccc5b58fe0c3c39838b9eea36190b3f43..8729cad5e88331f3e5b9df3e158acbcb89ba0e45 100644 --- a/setup.cfg +++ b/setup.cfg @@ -10,11 +10,12 @@ license = MIT [options] packages = find: install_requires = - djangoldp~=2.0 + djangoldp~=2.1 djangoldp_account~=2.0 - djangoldp_circle~=2.0 - djangoldp_project~=2.0 + djangoldp_circle~=2.1 + djangoldp_project~=2.1 djangoldp_joboffer~=2.0 + djangoldp_conversation~=2.0 [semantic_release] version_source = tag