add support for fine-grained field permissions for user model
authorScott Baker <smbaker@gmail.com>
Fri, 3 Oct 2014 07:32:37 +0000 (00:32 -0700)
committerScott Baker <smbaker@gmail.com>
Fri, 3 Oct 2014 07:32:37 +0000 (00:32 -0700)
planetstack/core/admin.py
planetstack/core/models/__init__.py
planetstack/core/models/plcorebase.py
planetstack/core/models/user.py

index 94e8453..f4d6f8f 100644 (file)
@@ -8,7 +8,7 @@ from django import forms
 from django.utils.safestring import mark_safe
 from django.contrib.auth.admin import UserAdmin
 from django.contrib.admin.widgets import FilteredSelectMultiple
-from django.contrib.auth.forms import ReadOnlyPasswordHashField
+from django.contrib.auth.forms import ReadOnlyPasswordHashField, AdminPasswordChangeForm
 from django.contrib.auth.signals import user_logged_in
 from django.utils import timezone
 from django.contrib.contenttypes import generic
@@ -16,6 +16,14 @@ from suit.widgets import LinkedSelect
 from django.core.exceptions import PermissionDenied
 from django.core.urlresolvers import reverse, NoReverseMatch
 
+# this block of stuff is needed for UserAdmin
+from django.db import transaction
+from django.utils.decorators import method_decorator
+from django.views.decorators.csrf import csrf_protect
+from django.views.decorators.debug import sensitive_post_parameters
+csrf_protect_m = method_decorator(csrf_protect)
+sensitive_post_parameters_m = method_decorator(sensitive_post_parameters())
+
 import django_evolution
 
 def backend_icon(obj): # backend_status, enacted, updated):
@@ -43,7 +51,8 @@ class PlainTextWidget(forms.HiddenInput):
             value = ''
         return mark_safe(str(value) + super(PlainTextWidget, self).render(name, value, attrs))
 
-class ReadOnlyAwareAdmin(admin.ModelAdmin):
+class PermissionCheckingAdmin(admin.ModelAdmin):
+    # call save_by_user and delete_by_user instead of save and delete
 
     def has_add_permission(self, request, obj=None):
         return (not self.__user_is_readonly(request))
@@ -53,13 +62,42 @@ class ReadOnlyAwareAdmin(admin.ModelAdmin):
 
     def save_model(self, request, obj, form, change):
         if self.__user_is_readonly(request):
+            # this 'if' might be redundant if save_by_user is implemented right
             raise PermissionDenied
-            #pass
-        else:
-            return super(ReadOnlyAwareAdmin, self).save_model(request, obj, form, change)
+
+        obj.caller = request.user
+        # update openstack connection to use this site/tenant
+        obj.save_by_user(request.user)
+
+    def delete_model(self, request, obj):
+        obj.delete_by_user(request.user)
+
+    def save_formset(self, request, form, formset, change):
+        instances = formset.save(commit=False)
+        for instance in instances:
+            instance.save_by_user(request.user)
+
+        # BUG in django 1.7? Objects are not deleted by formset.save if
+        # commit is False. So let's delete them ourselves.
+        #
+        # code from forms/models.py save_existing_objects()
+        try:
+            forms_to_delete = formset.deleted_forms\r
+        except AttributeError:\r
+            forms_to_delete = []
+        if formset.initial_forms:
+            for form in formset.initial_forms:
+                obj = form.instance
+                if form in forms_to_delete:
+                    if obj.pk is None:
+                        continue
+                    formset.deleted_objects.append(obj)
+                    obj.delete()
+
+        formset.save_m2m()
 
     def get_actions(self,request):
-        actions = super(ReadOnlyAwareAdmin,self).get_actions(request)
+        actions = super(PermissionCheckingAdmin,self).get_actions(request)
 
         if self.__user_is_readonly(request):
             if 'delete_selected' in actions:
@@ -85,13 +123,13 @@ class ReadOnlyAwareAdmin(admin.ModelAdmin):
                 self.inlines = self.inlines_save
 
         try:
-            return super(ReadOnlyAwareAdmin, self).change_view(request, object_id, extra_context=extra_context)
+            return super(PermissionCheckingAdmin, self).change_view(request, object_id, extra_context=extra_context)
         except PermissionDenied:
             pass
         if request.method == 'POST':
             raise PermissionDenied
         request.readonly = True
-        return super(ReadOnlyAwareAdmin, self).change_view(request, object_id, extra_context=extra_context)
+        return super(PermissionCheckingAdmin, self).change_view(request, object_id, extra_context=extra_context)
 
     def __user_is_readonly(self, request):
         return request.user.isReadOnlyUser()
@@ -103,6 +141,11 @@ class ReadOnlyAwareAdmin(admin.ModelAdmin):
         return mark_safe(backend_icon(obj))
     backend_status_icon.short_description = ""
 
+class ReadOnlyAwareAdmin(PermissionCheckingAdmin):
+    pass
+
+class PlanetStackBaseAdmin(ReadOnlyAwareAdmin):
+    save_on_top = False
 
 class SingletonAdmin (ReadOnlyAwareAdmin):
     def has_add_permission(self, request):
@@ -115,7 +158,6 @@ class SingletonAdmin (ReadOnlyAwareAdmin):
         else:
             return True
 
-
 class PlStackTabularInline(admin.TabularInline):
     def __init__(self, *args, **kwargs):
         super(PlStackTabularInline, self).__init__(*args, **kwargs)
@@ -404,41 +446,6 @@ class ImageDeploymentsInline(PlStackTabularInline):
     fields = ['backend_status_icon', 'image', 'deployment', 'glance_image_id']
     readonly_fields = ['backend_status_icon', 'glance_image_id']
 
-class PlanetStackBaseAdmin(ReadOnlyAwareAdmin):
-    save_on_top = False
-
-    def save_model(self, request, obj, form, change):
-        obj.caller = request.user
-        # update openstack connection to use this site/tenant
-        obj.save_by_user(request.user)
-
-    def delete_model(self, request, obj):
-        obj.delete_by_user(request.user)
-
-    def save_formset(self, request, form, formset, change):
-        instances = formset.save(commit=False)
-        for instance in instances:
-            instance.save_by_user(request.user)
-
-        # BUG in django 1.7? Objects are not deleted by formset.save if
-        # commit is False. So let's delete them ourselves.
-        #
-        # code from forms/models.py save_existing_objects()
-        try:
-            forms_to_delete = formset.deleted_forms\r
-        except AttributeError:\r
-            forms_to_delete = []
-        if formset.initial_forms:
-            for form in formset.initial_forms:
-                obj = form.instance
-                if form in forms_to_delete:
-                    if obj.pk is None:
-                        continue
-                    formset.deleted_objects.append(obj)
-                    obj.delete()
-
-        formset.save_m2m()
-
 class SliceRoleAdmin(PlanetStackBaseAdmin):
     model = SliceRole
     pass
@@ -1004,13 +1011,17 @@ class UserDashboardViewInline(PlStackTabularInline):
     suit_classes = 'suit-tab suit-tab-dashboards'
     fields = ['user', 'dashboardView', 'order']
 
-class UserAdmin(UserAdmin):
+class UserAdmin(PlanetStackBaseAdmin):
     class Meta:
         app_label = "core"
 
+    add_form_template = 'admin/auth/user/add_form.html'
+    change_user_password_template = None
+
     # The forms to add and change user instances
     form = UserChangeForm
     add_form = UserCreationForm
+    change_password_form = AdminPasswordChangeForm
 
     # The fields to be used in displaying the User model.
     # These override the definitions on the base UserAdmin
@@ -1019,7 +1030,7 @@ class UserAdmin(UserAdmin):
     list_filter = ('site',)
     inlines = [SlicePrivilegeInline,SitePrivilegeInline,DeploymentPrivilegeInline,UserDashboardViewInline]
 
-    fieldListLoginDetails = ['email','site','password','is_active','is_readonly','is_admin','public_key']
+    fieldListLoginDetails = ['backend_status_text', 'email','site','password','is_active','is_readonly','is_admin','public_key']
     fieldListContactInfo = ['firstname','lastname','phone','timezone']
 
     fieldsets = (
@@ -1054,61 +1065,135 @@ class UserAdmin(UserAdmin):
 
         return super(UserAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs)
 
-    def has_add_permission(self, request, obj=None):
-        return (not self.__user_is_readonly(request))
-
-    def has_delete_permission(self, request, obj=None):
-        return (not self.__user_is_readonly(request))
-
-    def get_actions(self,request):
-        actions = super(UserAdmin,self).get_actions(request)
-
-        if self.__user_is_readonly(request):
-            if 'delete_selected' in actions:
-                del actions['delete_selected']
-
-        return actions
-
-    def change_view(self,request,object_id, extra_context=None):
-
-        if self.__user_is_readonly(request):
-            if not hasattr(self, "readonly_save"):
-                # save the original readonly fields\r
-                self.readonly_save = self.readonly_fields\r
-                self.inlines_save = self.inlines
-            if hasattr(self, "user_readonly_fields"):
-                self.readonly_fields=self.user_readonly_fields
-            if hasattr(self, "user_readonly_inlines"):
-                self.inlines = self.user_readonly_inlines
-        else:
-            if hasattr(self, "readonly_save"):\r
-                # restore the original readonly fields\r
-                self.readonly_fields = self.readonly_save\r
-                self.inlines = self.inlines_save
-
-        try:
-            return super(UserAdmin, self).change_view(request, object_id, extra_context=extra_context)
-        except PermissionDenied:
-            pass
-        if request.method == 'POST':
-            raise PermissionDenied
-        request.readonly = True
-        return super(UserAdmin, self).change_view(request, object_id, extra_context=extra_context)
-
-    def __user_is_readonly(self, request):
-        #groups = [x.name for x in request.user.groups.all() ]
-        #return "readonly" in groups
-        return request.user.isReadOnlyUser()
-
     def queryset(self, request):
         return User.select_by_user(request.user)
 
-    def backend_status_text(self, obj):
-        return mark_safe(backend_text(obj))
+    # ------------------------------------------------------------------------
+    # stuff copied from ModelAdmin.UserAdmin
+    # ------------------------------------------------------------------------
+    def get_fieldsets(self, request, obj=None):
+        if not obj:\r
+            return self.add_fieldsets\r
+        return super(UserAdmin, self).get_fieldsets(request, obj)
+
+    def get_form(self, request, obj=None, **kwargs):
+        """\r
+        Use special form during user creation\r
+        """\r
+        defaults = {}\r
+        if obj is None:\r
+            defaults['form'] = self.add_form\r
+        defaults.update(kwargs)\r
+        return super(UserAdmin, self).get_form(request, obj, **defaults)\r
+\r
+    def get_urls(self):\r
+        from django.conf.urls import patterns\r
+        return patterns('',\r
+            (r'^(\d+)/password/$',\r
+             self.admin_site.admin_view(self.user_change_password))\r
+        ) + super(UserAdmin, self).get_urls()\r
+\r
+    def lookup_allowed(self, lookup, value):\r
+        # See #20078: we don't want to allow any lookups involving passwords.\r
+        if lookup.startswith('password'):\r
+            return False\r
+        return super(UserAdmin, self).lookup_allowed(lookup, value)\r
+\r
+    @sensitive_post_parameters_m\r
+    @csrf_protect_m\r
+    @transaction.atomic\r
+    def add_view(self, request, form_url='', extra_context=None):\r
+        # It's an error for a user to have add permission but NOT change\r
+        # permission for users. If we allowed such users to add users, they\r
+        # could create superusers, which would mean they would essentially have\r
+        # the permission to change users. To avoid the problem entirely, we\r
+        # disallow users from adding users if they don't have change\r
+        # permission.\r
+        if not self.has_change_permission(request):\r
+            if self.has_add_permission(request) and settings.DEBUG:\r
+                # Raise Http404 in debug mode so that the user gets a helpful\r
+                # error message.\r
+                raise Http404(\r
+                    'Your user does not have the "Change user" permission. In '\r
+                    'order to add users, Django requires that your user '\r
+                    'account have both the "Add user" and "Change user" '\r
+                    'permissions set.')\r
+            raise PermissionDenied\r
+        if extra_context is None:\r
+            extra_context = {}\r
+        username_field = self.model._meta.get_field(self.model.USERNAME_FIELD)\r
+        defaults = {\r
+            'auto_populated_fields': (),\r
+            'username_help_text': username_field.help_text,\r
+        }\r
+        extra_context.update(defaults)\r
+        return super(UserAdmin, self).add_view(request, form_url,\r
+                                               extra_context)\r
+\r
+    @sensitive_post_parameters_m\r
+    def user_change_password(self, request, id, form_url=''):\r
+        if not self.has_change_permission(request):\r
+            raise PermissionDenied\r
+        user = get_object_or_404(self.get_queryset(request), pk=id)\r
+        if request.method == 'POST':\r
+            form = self.change_password_form(user, request.POST)\r
+            if form.is_valid():\r
+                form.save()\r
+                change_message = self.construct_change_message(request, form, None)\r
+                self.log_change(request, user, change_message)\r
+                msg = ugettext('Password changed successfully.')\r
+                messages.success(request, msg)\r
+                update_session_auth_hash(request, form.user)\r
+                return HttpResponseRedirect('..')\r
+        else:\r
+            form = self.change_password_form(user)\r
+\r
+        fieldsets = [(None, {'fields': list(form.base_fields)})]\r
+        adminForm = admin.helpers.AdminForm(form, fieldsets, {})\r
+\r
+        context = {\r
+            'title': _('Change password: %s') % escape(user.get_username()),\r
+            'adminForm': adminForm,\r
+            'form_url': form_url,\r
+            'form': form,\r
+            'is_popup': (IS_POPUP_VAR in request.POST or\r
+                         IS_POPUP_VAR in request.GET),\r
+            'add': True,\r
+            'change': False,\r
+            'has_delete_permission': False,\r
+            'has_change_permission': True,\r
+            'has_absolute_url': False,\r
+            'opts': self.model._meta,\r
+            'original': user,\r
+            'save_as': False,\r
+            'show_save': True,\r
+        }\r
+        context.update(admin.site.each_context())\r
+        return TemplateResponse(request,\r
+            self.change_user_password_template or\r
+            'admin/auth/user/change_password.html',\r
+            context, current_app=self.admin_site.name)\r
+\r
+    def response_add(self, request, obj, post_url_continue=None):\r
+        """\r
+        Determines the HttpResponse for the add_view stage. It mostly defers to\r
+        its superclass implementation but is customized because the User model\r
+        has a slightly different workflow.\r
+        """\r
+        # We should allow further modification of the user just added i.e. the\r
+        # 'Save' button should behave like the 'Save and continue editing'\r
+        # button except in two scenarios:\r
+        # * The user has pressed the 'Save and add another' button\r
+        # * We are adding a user in a popup\r
+        if '_addanother' not in request.POST and IS_POPUP_VAR not in request.POST:\r
+            request.POST['_continue'] = 1\r
+        return super(UserAdmin, self).response_add(request, obj,\r
+                                                   post_url_continue)
+
+    # ------------------------------------------------------------------------
+    # end stuff copied from ModelAdmin.UserAdmin
+    # ------------------------------------------------------------------------
 
-    def backend_status_icon(self, obj):
-        return mark_safe(backend_icon(obj))
-    backend_status_icon.short_description = ""
 
 class DashboardViewAdmin(PlanetStackBaseAdmin):
     fieldsets = [('Dashboard View Details',
index f3991dd..2070e16 100644 (file)
@@ -1,4 +1,4 @@
-from .plcorebase import PlCoreBase,PlCoreBaseManager,PlCoreBaseDeletionManager
+from .plcorebase import PlCoreBase,PlCoreBaseManager,PlCoreBaseDeletionManager,DiffModelMixIn
 from .project import Project
 from .singletonmodel import SingletonModel
 from .service import Service
index b9692c6..51049a4 100644 (file)
@@ -48,7 +48,43 @@ class PlCoreBaseManager(models.Manager):
     def get_query_set(self):
         return self.get_queryset()
 
-class PlCoreBase(models.Model):
+class DiffModelMixIn:
+    # Provides useful methods for computing which objects in a model have
+    # changed. Make sure to do self._initial = self._dict in the __init__
+    # method.
+
+    # This is broken out of PlCoreBase into a Mixin so the User model can
+    # also make use of it.
+
+    @property
+    def _dict(self):
+        return model_to_dict(self, fields=[field.name for field in
+                             self._meta.fields])
+
+    @property
+    def diff(self):
+        d1 = self._initial
+        d2 = self._dict
+        diffs = [(k, (v, d2[k])) for k, v in d1.items() if v != d2[k]]
+        return dict(diffs)
+
+    @property
+    def has_changed(self):
+        return bool(self.diff)
+
+    @property
+    def changed_fields(self):
+        return self.diff.keys()
+
+    @property
+    def has_field_changed(self, field_name):
+        return field_name in self.diff.keys()
+
+    def get_field_diff(self, field_name):
+        return self.diff.get(field_name, None)
+
+
+class PlCoreBase(models.Model, DiffModelMixIn):
     objects = PlCoreBaseManager()
     deleted_objects = PlCoreBaseDeletionManager()
 
@@ -69,27 +105,9 @@ class PlCoreBase(models.Model):
 
     def __init__(self, *args, **kwargs):
         super(PlCoreBase, self).__init__(*args, **kwargs)
-        self.__initial = self._dict
+        self._initial = self._dict # for DiffModelMixIn
         self.silent = False
 
-    @property
-    def diff(self):
-        d1 = self.__initial
-        d2 = self._dict
-        diffs = [(k, (v, d2[k])) for k, v in d1.items() if v != d2[k]]
-        return dict(diffs)
-
-    @property
-    def has_changed(self):
-        return bool(self.diff)
-
-    @property
-    def changed_fields(self):
-        return self.diff.keys()
-
-    def get_field_diff(self, field_name):
-        return self.diff.get(field_name, None)
-
     def can_update(self, user):
         if user.is_readonly:
             return False
@@ -97,6 +115,11 @@ class PlCoreBase(models.Model):
             return True
         return False
 
+    def can_update_field(self, user, fieldName):
+        # Give us the opportunity to implement fine-grained permission checking.
+        # Default to True, and let can_update() permit or deny the whole object.
+        return True
+
     def delete(self, *args, **kwds):
         # so we have something to give the observer
         purge = kwds.get('purge',False)
@@ -131,6 +154,11 @@ class PlCoreBase(models.Model):
     def save_by_user(self, user, *args, **kwds):
         if not self.can_update(user):
             raise PermissionDenied("You do not have permission to update %s objects" % self.__class__.__name__)
+
+        for fieldName in self.changed_fields:
+            if not self.can_update_field(user, fieldName):
+                raise PermissionDenied("You do not have permission to update field %s in object %s" % (fieldName, self.__class__.__name__))
+
         self.save(*args, **kwds)
 
     def delete_by_user(self, user, *args, **kwds):
@@ -138,10 +166,6 @@ class PlCoreBase(models.Model):
             raise PermissionDenied("You do not have permission to delete %s objects" % self.__class__.__name__)
         self.delete(*args, **kwds)
 
-    @property
-    def _dict(self):
-        return model_to_dict(self, fields=[field.name for field in
-                             self._meta.fields])
 
 
 
index 9a62e34..9b54da9 100644 (file)
@@ -3,7 +3,7 @@ import datetime
 from collections import defaultdict
 from django.db import models
 from django.db.models import F, Q
-from core.models import PlCoreBase,Site, DashboardView
+from core.models import PlCoreBase,Site, DashboardView, DiffModelMixIn
 from core.models.site import Deployment
 from django.contrib.auth.models import AbstractBaseUser, BaseUserManager
 from timezones.fields import TimeZoneField
@@ -11,6 +11,7 @@ from operator import itemgetter, attrgetter
 from django.core.mail import EmailMultiAlternatives
 from core.middleware import get_request
 import model_policy
+from django.core.exceptions import PermissionDenied
 
 # Create your models here.
 class UserManager(BaseUserManager):
@@ -55,7 +56,7 @@ class DeletedUserManager(UserManager):
     def get_query_set(self):
         return self.get_queryset()
 
-class User(AbstractBaseUser):
+class User(AbstractBaseUser, DiffModelMixIn):
 
     class Meta:
         app_label = "core"
@@ -99,6 +100,10 @@ class User(AbstractBaseUser):
     USERNAME_FIELD = 'email'
     REQUIRED_FIELDS = ['firstname', 'lastname']
 
+    def __init__(self, *args, **kwargs):
+        super(User, self).__init__(*args, **kwargs)
+        self._initial = self._dict # for DiffModelMixIn
+
     def isReadOnlyUser(self):
         return self.is_readonly
 
@@ -182,6 +187,8 @@ class User(AbstractBaseUser):
         self.username = self.email
         super(User, self).save(*args, **kwds)
 
+        self._initial = self._dict
+
     def send_temporary_password(self):
         password = User.objects.make_random_password()
         self.set_password(password)\r
@@ -193,6 +200,41 @@ class User(AbstractBaseUser):
         msg.attach_alternative(html_content, "text/html")\r
         msg.send()
 
+    def can_update_field(self, user, fieldName):
+        from core.models import SitePrivilege
+        if (user.is_admin):
+            # admin can update anything
+            return True
+
+        # fields that a site PI can update
+        if fieldName in ["is_active", "is_readonly"]:
+            site_privs = SitePrivilege.objects.filter(user=user, site=self.site)
+            for site_priv in site_privs:
+                if site_priv.role.role == 'pi':
+                    return True
+
+        # fields that a user cannot update in his/her own record
+        if fieldName in ["is_admin", "is_active", "site", "is_staff", "is_readonly"]:
+            return False
+
+        return True
+
+    def can_update(self, user):
+        from core.models import SitePrivilege
+        if user.is_readonly:
+            return False
+        if user.is_admin:
+            return True
+        if (user.id == self.id):
+            return True
+        # site pis can update
+        site_privs = SitePrivilege.objects.filter(user=user, site=self.site)
+        for site_priv in site_privs:
+            if site_priv.role.role == 'pi':
+                return True
+
+        return False
+
     @staticmethod
     def select_by_user(user):
         if user.is_admin:
@@ -208,6 +250,21 @@ class User(AbstractBaseUser):
             qs = User.objects.filter(Q(site__in=sites) | Q(id__in=user_ids))
         return qs
 
+    def save_by_user(self, user, *args, **kwds):
+        if not self.can_update(user):
+            raise PermissionDenied("You do not have permission to update %s objects" % self.__class__.__name__)
+
+        for fieldName in self.changed_fields:
+            if not self.can_update_field(user, fieldName):
+                raise PermissionDenied("You do not have permission to update field %s in object %s" % (fieldName, self.__class__.__name__))
+
+        self.save(*args, **kwds)
+
+    def delete_by_user(self, user, *args, **kwds):
+        if not self.can_update(user):
+            raise PermissionDenied("You do not have permission to delete %s objects" % self.__class__.__name__)
+        self.delete(*args, **kwds)
+
 class UserDashboardView(PlCoreBase):
      user = models.ForeignKey(User, related_name="dashboardViews")
      dashboardView = models.ForeignKey(DashboardView, related_name="dashboardViews")