Repository: readthedocs/readthedocs.org

Issue:
Privacy permissions never get unset
We are setting the "projects.view_project" permission on a user when they are a project admin, but nothing ever removes this permission when they are removed.



Discussion:

Resolving patch:
diff --git a/readthedocs/builds/models.py b/readthedocs/builds/models.py
--- a/readthedocs/builds/models.py
+++ b/readthedocs/builds/models.py
@@ -8,7 +8,6 @@
 from django.db import models
 from django.utils.translation import ugettext_lazy as _, ugettext
 
-from guardian.shortcuts import assign
 from taggit.managers import TaggableManager
 
 from readthedocs.privacy.loader import (VersionManager, RelatedProjectManager,
@@ -153,8 +152,6 @@ def save(self, *args, **kwargs):
         Add permissions to the Version for all owners on save.
         """
         obj = super(Version, self).save(*args, **kwargs)
-        for owner in self.project.users.all():
-            assign('view_version', owner, self)
         self.project.sync_supported_versions()
         return obj
 
diff --git a/readthedocs/projects/forms.py b/readthedocs/projects/forms.py
--- a/readthedocs/projects/forms.py
+++ b/readthedocs/projects/forms.py
@@ -10,8 +10,6 @@
 from django.utils.translation import ugettext_lazy as _
 from django.utils.safestring import mark_safe
 
-from guardian.shortcuts import assign
-
 from readthedocs.builds.constants import TAG
 from readthedocs.core.utils import trigger_build
 from readthedocs.redirects.models import Redirect
@@ -375,8 +373,6 @@ def clean_user(self):
 
     def save(self):
         self.project.users.add(self.user)
-        # Force update of permissions
-        assign('view_project', self.user, self.project)
         return self.user
 
 
diff --git a/readthedocs/projects/models.py b/readthedocs/projects/models.py
--- a/readthedocs/projects/models.py
+++ b/readthedocs/projects/models.py
@@ -10,10 +10,12 @@
 from django.core.exceptions import MultipleObjectsReturned
 from django.core.urlresolvers import reverse
 from django.db import models
+from django.db.models import signals
 from django.template.defaultfilters import slugify
 from django.utils.translation import ugettext_lazy as _
 
-from guardian.shortcuts import assign
+from guardian.shortcuts import assign_perm
+from guardian.shortcuts import remove_perm
 
 from readthedocs.builds.constants import LATEST
 from readthedocs.builds.constants import LATEST_VERBOSE_NAME
@@ -67,6 +69,9 @@ class Project(models.Model):
     modified_date = models.DateTimeField(_('Modified date'), auto_now=True)
 
     # Generally from conf.py
+
+    # Notice that adding/removing users is intercepted by set_user_permissions
+    # method.
     users = models.ManyToManyField(User, verbose_name=_('User'),
                                    related_name='projects')
     name = models.CharField(_('Name'), max_length=255)
@@ -283,8 +288,6 @@ def save(self, *args, **kwargs):
             if self.slug == '':
                 raise Exception(_("Model must have slug"))
         super(Project, self).save(*args, **kwargs)
-        for owner in self.users.all():
-            assign('view_project', owner, self)
         try:
             if self.default_branch:
                 latest = self.versions.get(slug=LATEST)
@@ -887,6 +890,36 @@ def add_comment(self, version_slug, page, content_hash, commit, user, text):
                                      hash=content_hash, commit=commit)
         return node.comments.create(user=user, text=text)
 
+    @classmethod
+    def set_user_permissions(cls, sender, instance, action, reverse, pk_set,
+                             **kwargs):
+        assert not reverse, (
+            "You must add users to the project via the `users` field "
+            "on the project instance.")
+
+        def perform_action(set_perm, users, project):
+            for user in users:
+                set_perm('view_project', user, project)
+                for version in project.versions.all():
+                    set_perm('view_version', user, version)
+
+        if action == 'post_add':
+            users = User.objects.filter(pk__in=pk_set)
+            perform_action(assign_perm, users, instance)
+
+        if action == 'pre_remove':
+            users = User.objects.filter(pk__in=pk_set)
+            perform_action(remove_perm, users, instance)
+
+        if action == 'pre_clear':
+            users = instance.users.all()
+            perform_action(remove_perm, users, instance)
+
+
+signals.m2m_changed.connect(
+    Project.set_user_permissions,
+    sender=Project._meta.get_field('users').rel.through)
+
 
 class ImportedFile(models.Model):
 


Test patch:
diff --git a/readthedocs/rtd_tests/tests/test_project.py b/readthedocs/rtd_tests/tests/test_project.py
--- a/readthedocs/rtd_tests/tests/test_project.py
+++ b/readthedocs/rtd_tests/tests/test_project.py
@@ -1,5 +1,7 @@
 import json
+from django.contrib.auth.models import User
 from django.test import TestCase
+from guardian.shortcuts import get_objects_for_user
 from readthedocs.builds.constants import LATEST
 from readthedocs.projects.models import Project
 from rest_framework.reverse import reverse
@@ -83,3 +85,28 @@ def test_has_epub_with_epub_build_disabled(self):
         self.pip.enable_epub_build = False
         with fake_paths_by_regex('\.epub$'):
             self.assertFalse(self.pip.has_epub(LATEST))
+
+    def test_users_relations_sets_permissions(self):
+        john = get(User)
+        maggy = get(User)
+
+        project = get(Project)
+
+        def get_projects(user):
+            return get_objects_for_user(user, 'projects.view_project')
+
+        self.assertTrue(project not in get_projects(john))
+
+        project.users.add(john)
+        self.assertTrue(project in get_projects(john))
+
+        project.users.remove(john)
+        self.assertTrue(project not in get_projects(john))
+
+        project.users.add(maggy)
+        self.assertTrue(project not in get_projects(john))
+        self.assertTrue(project in get_projects(maggy))
+
+        project.users = [john]
+        self.assertTrue(project in get_projects(john))
+        self.assertTrue(project not in get_projects(maggy))
diff --git a/readthedocs/rtd_tests/tests/test_project_views.py b/readthedocs/rtd_tests/tests/test_project_views.py
--- a/readthedocs/rtd_tests/tests/test_project_views.py
+++ b/readthedocs/rtd_tests/tests/test_project_views.py
@@ -2,6 +2,7 @@
 from django.contrib.messages import constants as message_const
 from django_dynamic_fixture import get
 from django_dynamic_fixture import new
+from guardian.shortcuts import get_objects_for_user
 from mock import patch
 
 from readthedocs.rtd_tests.base import WizardTestCase, MockBuildTestCase
@@ -221,3 +222,57 @@ def test_delete_project(self):
             self.assertFalse(Project.objects.filter(slug='pip').exists())
             remove_path_from_web.delay.assert_called_with(
                 path=project.doc_path)
+
+    def test_add_admin_user(self):
+        project = get(Project, slug='pip', users=[self.user])
+        anthony = get(User, username='anthony')
+
+        response = self.client.get('/dashboard/pip/users/')
+        self.assertEqual(response.status_code, 200)
+
+        response = self.client.post('/dashboard/pip/users/', {
+            'user': 'not-anthony',
+        })
+        self.assertEqual(response.status_code, 200)
+        self.assertTrue(response.context['form'].errors)
+
+        self.assertEqual(project.users.count(), 1)
+
+        response = self.client.post('/dashboard/pip/users/', {
+            'user': 'anthony',
+        })
+        self.assertEqual(response.status_code, 302)
+
+        self.assertEqual(project.users.count(), 2)
+        self.assertTrue(anthony in project.users.all())
+
+        # Permission was assigned correctly.
+        self.assertTrue(project in get_objects_for_user(
+            anthony, 'projects.view_project'))
+
+    def test_remove_admin_user(self):
+        anthony = get(User, username='anthony')
+        project = get(Project, slug='pip', users=[self.user, anthony])
+
+        # Just to be sure that anthony has the correct permissions.
+        self.assertTrue(project in get_objects_for_user(
+            anthony, 'projects.view_project'))
+
+        response = self.client.post('/dashboard/pip/users/delete/', {})
+        self.assertEqual(response.status_code, 404)
+
+        response = self.client.post('/dashboard/pip/users/delete/', {
+            'username': 'not-anthony',
+        })
+        self.assertEqual(response.status_code, 404)
+
+        response = self.client.post('/dashboard/pip/users/delete/', {
+            'username': 'anthony',
+        })
+        self.assertEqual(response.status_code, 302)
+
+        self.assertTrue(anthony not in project.users.all())
+
+        # Permission were removed correctly.
+        self.assertTrue(project not in get_objects_for_user(
+            anthony, 'projects.view_project'))
diff --git a/readthedocs/rtd_tests/tests/test_version_model.py b/readthedocs/rtd_tests/tests/test_version_model.py
new file mode 100644
--- /dev/null
+++ b/readthedocs/rtd_tests/tests/test_version_model.py
@@ -0,0 +1,42 @@
+from django.contrib.auth.models import User
+from django.test import TestCase
+from django_dynamic_fixture import get
+from guardian.shortcuts import get_objects_for_user
+
+from readthedocs.builds.models import Version
+from readthedocs.projects.models import Project
+
+
+class VersionPermissionTests(TestCase):
+    def test_version_save_sets_permission(self):
+        user = get(User)
+        project = get(Project)
+
+        versions = get_objects_for_user(user, 'builds.view_version')
+        self.assertEqual(len(versions), 0)
+
+        version = get(Version, project=project)
+        versions = get_objects_for_user(user, 'builds.view_version')
+        self.assertTrue(version not in versions)
+
+        # When user is added after version.
+        project.users.add(user)
+        versions = get_objects_for_user(user, 'builds.view_version')
+        self.assertTrue(version in versions)
+
+        # When user is moved for existing version.
+        project.users.remove(user)
+        versions = get_objects_for_user(user, 'builds.view_version')
+        self.assertTrue(version not in versions)
+
+        # When user is added before version.
+        project.users.add(user)
+        second = get(Version, project=project)
+        versions = get_objects_for_user(user, 'builds.view_version')
+        self.assertTrue(second in versions)
+
+        # When project users are reset.
+        project.users = []
+        second = get(Version, project=project)
+        versions = get_objects_for_user(user, 'builds.view_version')
+        self.assertTrue(second not in versions)