Implement ObjectPermissionManager

This commit is contained in:
Jeremy Stretch 2020-05-11 14:32:10 -04:00
parent 06aca2e1d5
commit 63f842c7db
5 changed files with 85 additions and 54 deletions

View File

@ -194,12 +194,13 @@ class SiteListView(ObjectPermissionRequiredMixin, ObjectListView):
table = tables.SiteTable table = tables.SiteTable
class SiteView(PermissionRequiredMixin, View): class SiteView(ObjectPermissionRequiredMixin, View):
permission_required = 'dcim.view_site' permission_required = 'dcim.view_site'
queryset = Site.objects.prefetch_related('region', 'tenant__group')
def get(self, request, slug): def get(self, request, slug):
site = get_object_or_404(Site.objects.prefetch_related('region', 'tenant__group'), slug=slug) site = get_object_or_404(self.queryset, slug=slug)
stats = { stats = {
'rack_count': Rack.objects.filter(site=site).count(), 'rack_count': Rack.objects.filter(site=site).count(),
'device_count': Device.objects.filter(site=site).count(), 'device_count': Device.objects.filter(site=site).count(),
@ -219,7 +220,7 @@ class SiteView(PermissionRequiredMixin, View):
}) })
class SiteCreateView(PermissionRequiredMixin, ObjectEditView): class SiteCreateView(ObjectPermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_site' permission_required = 'dcim.add_site'
queryset = Site.objects.all() queryset = Site.objects.all()
model_form = forms.SiteForm model_form = forms.SiteForm
@ -231,7 +232,7 @@ class SiteEditView(SiteCreateView):
permission_required = 'dcim.change_site' permission_required = 'dcim.change_site'
class SiteDeleteView(PermissionRequiredMixin, ObjectDeleteView): class SiteDeleteView(ObjectPermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_site' permission_required = 'dcim.delete_site'
queryset = Site.objects.all() queryset = Site.objects.all()
default_return_url = 'dcim:site_list' default_return_url = 'dcim:site_list'

View File

@ -14,22 +14,14 @@ class ObjectPermissionRequiredMixin(AccessMixin):
if self.request.user.has_perm(self.permission_required): if self.request.user.has_perm(self.permission_required):
return True return True
# If not, check for an object-level permission # If not, check for object-level permissions
app, codename = self.permission_required.split('.') app, codename = self.permission_required.split('.')
action, model_name = codename.split('_') action, model_name = codename.split('_')
model = self.queryset.model model = self.queryset.model
obj_permissions = ObjectPermission.objects.filter( attrs = ObjectPermission.objects.get_attr_constraints(self.request.user, model, action)
Q(users=self.request.user) | Q(groups__user=self.request.user), if attrs:
model=ContentType.objects.get_for_model(model),
**{f'can_{action}': True}
)
if obj_permissions:
# Update the view's QuerySet to filter only the permitted objects # Update the view's QuerySet to filter only the permitted objects
# TODO: Do this more efficiently self.queryset = self.queryset.filter(**attrs)
for perm in obj_permissions:
self.queryset = self.queryset.filter(**perm.attrs)
return True return True
return False return False

View File

@ -7,6 +7,7 @@ from django.contrib.postgres.fields import JSONField
from django.core.exceptions import FieldError, ValidationError from django.core.exceptions import FieldError, ValidationError
from django.core.validators import MinLengthValidator from django.core.validators import MinLengthValidator
from django.db import models from django.db import models
from django.db.models import Q
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.utils import timezone from django.utils import timezone
@ -194,6 +195,38 @@ class Token(models.Model):
return True return True
class ObjectPermissionManager(models.Manager):
def get_attr_constraints(self, user, model, action):
"""
Compile all ObjectPermission attributes applicable to a specific combination of user, model, and action. Returns
a dictionary that can be passed directly to .filter() on a QuerySet.
"""
assert action in ['view', 'add', 'change', 'delete'], f"Invalid action: {action}"
qs = self.get_queryset().filter(
Q(users=user) | Q(groups__user=user),
model=ContentType.objects.get_for_model(model),
**{f'can_{action}': True}
)
attrs = {}
for perm in qs:
attrs.update(perm.attrs)
return attrs
def validate_queryset(self, queryset, user, action):
"""
Check that the specified user has permission to perform the specified action on all objects in the QuerySet.
"""
assert action in ['view', 'add', 'change', 'delete'], f"Invalid action: {action}"
model = queryset.model
attrs = self.get_attr_constraints(user, model, action)
return queryset.count() == model.objects.filter(**attrs).count()
class ObjectPermission(models.Model): class ObjectPermission(models.Model):
""" """
A mapping of view, add, change, and/or delete permission for users and/or groups to an arbitrary set of objects A mapping of view, add, change, and/or delete permission for users and/or groups to an arbitrary set of objects
@ -229,6 +262,8 @@ class ObjectPermission(models.Model):
default=False default=False
) )
objects = ObjectPermissionManager()
class Meta: class Meta:
unique_together = ('model', 'attrs') unique_together = ('model', 'attrs')

View File

@ -56,21 +56,12 @@ class ObjectPermissionBackend(ModelBackend):
if model._meta.model_name != model_name: if model._meta.model_name != model_name:
raise ValueError(f"Invalid permission {perm} for model {model}") raise ValueError(f"Invalid permission {perm} for model {model}")
# Retrieve user's permissions for this model # Attempt to retrieve the model from the database using the
# This can probably be cached # attributes defined in the ObjectPermission. If we have a
obj_permissions = ObjectPermission.objects.filter( # match, assert that the user has permission.
Q(users=user_obj) | Q(groups__user=user_obj), attrs = ObjectPermission.objects.get_attr_constraints(user_obj, obj, action)
model=ContentType.objects.get_for_model(obj), if model.objects.filter(pk=obj.pk, **attrs).exists():
**{f'can_{action}': True} return True
)
for perm in obj_permissions:
# Attempt to retrieve the model from the database using the
# attributes defined in the ObjectPermission. If we have a
# match, assert that the user has permission.
if model.objects.filter(pk=obj.pk, **perm.attrs).exists():
return True
class RemoteUserBackend(ViewExemptModelBackend, RemoteUserBackend_): class RemoteUserBackend(ViewExemptModelBackend, RemoteUserBackend_):

View File

@ -4,7 +4,7 @@ from copy import deepcopy
from django.contrib import messages from django.contrib import messages
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldDoesNotExist, ValidationError from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
from django.db import transaction, IntegrityError from django.db import transaction, IntegrityError
from django.db.models import ManyToManyField, ProtectedError from django.db.models import ManyToManyField, ProtectedError
from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
@ -23,6 +23,7 @@ from django_tables2 import RequestConfig
from extras.models import CustomField, CustomFieldValue, ExportTemplate from extras.models import CustomField, CustomFieldValue, ExportTemplate
from extras.querysets import CustomFieldQueryset from extras.querysets import CustomFieldQueryset
from users.models import ObjectPermission
from utilities.exceptions import AbortTransaction from utilities.exceptions import AbortTransaction
from utilities.forms import BootstrapMixin, CSVDataField, TableConfigForm from utilities.forms import BootstrapMixin, CSVDataField, TableConfigForm
from utilities.utils import csv_format, prepare_cloned_fields from utilities.utils import csv_format, prepare_cloned_fields
@ -262,32 +263,43 @@ class ObjectEditView(GetReturnURLMixin, View):
if form.is_valid(): if form.is_valid():
logger.debug("Form validation was successful") logger.debug("Form validation was successful")
obj = form.save() try:
msg = '{} {}'.format( with transaction.atomic():
'Created' if not form.instance.pk else 'Modified', obj = form.save()
self.queryset.model._meta.verbose_name
)
logger.info(f"{msg} {obj} (PK: {obj.pk})")
if hasattr(obj, 'get_absolute_url'):
msg = '{} <a href="{}">{}</a>'.format(msg, obj.get_absolute_url(), escape(obj))
else:
msg = '{} {}'.format(msg, escape(obj))
messages.success(request, mark_safe(msg))
if '_addanother' in request.POST: # Check that the new object conforms with any assigned object-level permissions
self.queryset.get(pk=obj.pk)
# If the object has clone_fields, pre-populate a new instance of the form msg = '{} {}'.format(
if hasattr(obj, 'clone_fields'): 'Created' if not form.instance.pk else 'Modified',
url = '{}?{}'.format(request.path, prepare_cloned_fields(obj)) self.queryset.model._meta.verbose_name
return redirect(url) )
logger.info(f"{msg} {obj} (PK: {obj.pk})")
if hasattr(obj, 'get_absolute_url'):
msg = '{} <a href="{}">{}</a>'.format(msg, obj.get_absolute_url(), escape(obj))
else:
msg = '{} {}'.format(msg, escape(obj))
messages.success(request, mark_safe(msg))
return redirect(request.get_full_path()) if '_addanother' in request.POST:
return_url = form.cleaned_data.get('return_url') # If the object has clone_fields, pre-populate a new instance of the form
if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()): if hasattr(obj, 'clone_fields'):
return redirect(return_url) url = '{}?{}'.format(request.path, prepare_cloned_fields(obj))
else: return redirect(url)
return redirect(self.get_return_url(request, obj))
return redirect(request.get_full_path())
return_url = form.cleaned_data.get('return_url')
if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
return redirect(return_url)
else:
return redirect(self.get_return_url(request, obj))
except ObjectDoesNotExist:
logger.debug("Object save failed due to object-level permissions violation")
# TODO: Link user to personal permissions view
form.add_error(None, "Object save failed due to object-level permissions violation")
else: else:
logger.debug("Form validation failed") logger.debug("Form validation failed")