From 6624fc607602502bd40f7124cc98bb050d96c01c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 8 May 2020 17:30:25 -0400 Subject: [PATCH 001/101] Initial work on #554 (WIP) --- netbox/netbox/settings.py | 1 + netbox/users/admin.py | 9 ++- .../users/migrations/0007_objectpermission.py | 36 +++++++++++ netbox/users/models.py | 55 +++++++++++++++- netbox/users/tests/test_permissions.py | 62 +++++++++++++++++++ netbox/utilities/auth_backends.py | 42 +++++++++++++ 6 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 netbox/users/migrations/0007_objectpermission.py create mode 100644 netbox/users/tests/test_permissions.py diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index bf660696b0..5c48ee6204 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -335,6 +335,7 @@ def _setting(name, default=None): AUTHENTICATION_BACKENDS = [ REMOTE_AUTH_BACKEND, 'utilities.auth_backends.ViewExemptModelBackend', + 'utilities.auth_backends.ObjectPermissionBackend', ] # Internationalization diff --git a/netbox/users/admin.py b/netbox/users/admin.py index 42e6517120..fcaeb4ef07 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -3,7 +3,7 @@ from django.contrib.auth.admin import UserAdmin as UserAdmin_ from django.contrib.auth.models import User -from .models import Token, UserConfig +from .models import ObjectPermission, Token, UserConfig # Unregister the built-in UserAdmin so that we can use our custom admin view below admin.site.unregister(User) @@ -43,3 +43,10 @@ class TokenAdmin(admin.ModelAdmin): list_display = [ 'key', 'user', 'created', 'expires', 'write_enabled', 'description' ] + + +@admin.register(ObjectPermission) +class ObjectPermissionAdmin(admin.ModelAdmin): + list_display = [ + 'model', 'can_view', 'can_add', 'can_change', 'can_delete' + ] diff --git a/netbox/users/migrations/0007_objectpermission.py b/netbox/users/migrations/0007_objectpermission.py new file mode 100644 index 0000000000..d805c3379e --- /dev/null +++ b/netbox/users/migrations/0007_objectpermission.py @@ -0,0 +1,36 @@ +# Generated by Django 3.0.6 on 2020-05-08 20:18 + +from django.conf import settings +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('auth', '0011_update_proxy_permissions'), + ('contenttypes', '0002_remove_content_type_name'), + ('users', '0006_create_userconfigs'), + ] + + operations = [ + migrations.CreateModel( + name='ObjectPermission', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('attrs', django.contrib.postgres.fields.jsonb.JSONField()), + ('can_view', models.BooleanField(default=False)), + ('can_add', models.BooleanField(default=False)), + ('can_change', models.BooleanField(default=False)), + ('can_delete', models.BooleanField(default=False)), + ('groups', models.ManyToManyField(blank=True, related_name='object_permissions', to='auth.Group')), + ('model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ('users', models.ManyToManyField(blank=True, related_name='object_permissions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('model', 'attrs')}, + }, + ), + ] diff --git a/netbox/users/models.py b/netbox/users/models.py index ea5762232c..f2002ae959 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -1,8 +1,10 @@ import binascii import os -from django.contrib.auth.models import User +from django.contrib.auth.models import Group, User +from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import JSONField +from django.core.exceptions import FieldError, ValidationError from django.core.validators import MinLengthValidator from django.db import models from django.db.models.signals import post_save @@ -190,3 +192,54 @@ def is_expired(self): if self.expires is None or timezone.now() < self.expires: return False return True + + +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 + identified by ORM query parameters. + """ + users = models.ManyToManyField( + to=User, + blank=True, + related_name='object_permissions' + ) + groups = models.ManyToManyField( + to=Group, + blank=True, + related_name='object_permissions' + ) + model = models.ForeignKey( + to=ContentType, + on_delete=models.CASCADE + ) + attrs = JSONField( + verbose_name='Attributes' + ) + can_view = models.BooleanField( + default=False + ) + can_add = models.BooleanField( + default=False + ) + can_change = models.BooleanField( + default=False + ) + can_delete = models.BooleanField( + default=False + ) + + class Meta: + unique_together = ('model', 'attrs') + + def clean(self): + + # Validate the specified model attributes by attempting to execute a query. We don't care whether the query + # returns anything; we just want to make sure the specified attributes are valid. + model = self.model.model_class() + try: + model.objects.filter(**self.attrs).exists() + except FieldError as e: + raise ValidationError({ + 'attrs': f'Invalid attributes for {model}: {e}' + }) diff --git a/netbox/users/tests/test_permissions.py b/netbox/users/tests/test_permissions.py new file mode 100644 index 0000000000..f73fd8f435 --- /dev/null +++ b/netbox/users/tests/test_permissions.py @@ -0,0 +1,62 @@ +from django.contrib.contenttypes.models import ContentType +from django.contrib.auth.models import Permission, User +from django.test import TestCase, override_settings + +from dcim.models import Site +from tenancy.models import Tenant +from users.models import ObjectPermission + + +class UserConfigTest(TestCase): + + def setUp(self): + + self.user = User.objects.create_user(username='testuser') + + @classmethod + def setUpTestData(cls): + + tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1') + Site.objects.bulk_create(( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2', tenant=tenant), + Site(name='Site 3', slug='site-3'), + )) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_permission_view_object(self): + + # Sanity check to ensure the user has no model-level permission + self.assertFalse(self.user.has_perm('dcim.view_site')) + + # The permission check for a specific object should fail. + sites = Site.objects.all() + self.assertFalse(self.user.has_perm('dcim.view_site', sites[0])) + + # Create and assign a new ObjectPermission specifying the first site by name. + ct = ContentType.objects.get_for_model(sites[0]) + object_perm = ObjectPermission( + model=ct, + attrs={'name': 'Site 1'}, + can_view=True + ) + object_perm.save() + self.user.object_permissions.add(object_perm) + + # The test user should have permission to view only the first site. + self.assertTrue(self.user.has_perm('dcim.view_site', sites[0])) + self.assertFalse(self.user.has_perm('dcim.view_site', sites[1])) + + # Create a second ObjectPermission matching sites by assigned tenant. + object_perm = ObjectPermission( + model=ct, + attrs={'tenant__name': 'Tenant 1'}, + can_view=True + ) + object_perm.save() + self.user.object_permissions.add(object_perm) + + # The user should now able to view the first two sites, but not the third. + self.assertTrue(self.user.has_perm('dcim.view_site', sites[0])) + self.assertTrue(self.user.has_perm('dcim.view_site', sites[1])) + self.assertFalse(self.user.has_perm('dcim.view_site', sites[2])) diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py index 6342bad2b6..0d20fe02f7 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -3,6 +3,10 @@ from django.conf import settings from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as RemoteUserBackend_ from django.contrib.auth.models import Group, Permission +from django.contrib.contenttypes.models import ContentType +from django.db.models import Q + +from users.models import ObjectPermission class ViewExemptModelBackend(ModelBackend): @@ -31,6 +35,44 @@ def has_perm(self, user_obj, perm, obj=None): return super().has_perm(user_obj, perm, obj) +class ObjectPermissionBackend(ModelBackend): + """ + Evaluates permission of a user to access or modify a specific object based on the assignment of ObjectPermissions + either to the user directly or to a group of which the user is a member. Model-level permissions supersede this + check: For example, if a user has the dcim.view_site model-level permission assigned, the ViewExemptModelBackend + will grant permission before this backend is evaluated for permission to view a specific site. + """ + def has_perm(self, user_obj, perm, obj=None): + + # This backend only checks for permissions on specific objects + if obj is None: + return + + app, codename = perm.split('.') + action, model_name = codename.split('_') + model = obj._meta.model + + # Check that the requested permission applies to the specified object + if model._meta.model_name != model_name: + raise ValueError(f"Invalid permission {perm} for model {model}") + + # Retrieve user's permissions for this model + # This can probably be cached + obj_permissions = ObjectPermission.objects.filter( + Q(users=user_obj) | Q(groups__user=user_obj), + model=ContentType.objects.get_for_model(obj), + **{f'can_{action}': 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_): """ Custom implementation of Django's RemoteUserBackend which provides configuration hooks for basic customization. From 4b5d64939df2b187306e58dcf313915968dbb3b8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 11 May 2020 11:51:11 -0400 Subject: [PATCH 002/101] Introduced ObjectPermissionRequiredMixin --- netbox/dcim/views.py | 3 ++- netbox/netbox/authentication.py | 40 +++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 netbox/netbox/authentication.py diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index cd1b4edf49..5afa462952 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -21,6 +21,7 @@ from extras.views import ObjectConfigContextView from ipam.models import Prefix, VLAN from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable +from netbox.authentication import ObjectPermissionRequiredMixin from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator from utilities.utils import csv_format @@ -185,7 +186,7 @@ class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Sites # -class SiteListView(PermissionRequiredMixin, ObjectListView): +class SiteListView(ObjectPermissionRequiredMixin, ObjectListView): permission_required = 'dcim.view_site' queryset = Site.objects.prefetch_related('region', 'tenant') filterset = filters.SiteFilterSet diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py new file mode 100644 index 0000000000..58fd4380a8 --- /dev/null +++ b/netbox/netbox/authentication.py @@ -0,0 +1,40 @@ +from django.contrib.auth.mixins import AccessMixin +from django.contrib.contenttypes.models import ContentType +from django.db.models import Q + +from users.models import ObjectPermission + + +class ObjectPermissionRequiredMixin(AccessMixin): + permission_required = None + + def has_permission(self): + + # First, check whether the user has a model-level permission assigned + if self.request.user.has_perm(self.permission_required): + return True + + # If not, check for an object-level permission + app, codename = self.permission_required.split('.') + action, model_name = codename.split('_') + model = self.queryset.model + obj_permissions = ObjectPermission.objects.filter( + Q(users=self.request.user) | Q(groups__user=self.request.user), + 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 + # TODO: Do this more efficiently + for perm in obj_permissions: + self.queryset = self.queryset.filter(**perm.attrs) + + return True + + return False + + def dispatch(self, request, *args, **kwargs): + if not self.has_permission(): + return self.handle_no_permission() + return super().dispatch(request, *args, **kwargs) From 63f842c7db791e68221e888e0c16403a0281ff93 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 11 May 2020 14:32:10 -0400 Subject: [PATCH 003/101] Implement ObjectPermissionManager --- netbox/dcim/views.py | 9 ++--- netbox/netbox/authentication.py | 16 +++------ netbox/users/models.py | 35 +++++++++++++++++++ netbox/utilities/auth_backends.py | 21 ++++------- netbox/utilities/views.py | 58 +++++++++++++++++++------------ 5 files changed, 85 insertions(+), 54 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index b3f97995ca..03e375d351 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -194,12 +194,13 @@ class SiteListView(ObjectPermissionRequiredMixin, ObjectListView): table = tables.SiteTable -class SiteView(PermissionRequiredMixin, View): +class SiteView(ObjectPermissionRequiredMixin, View): permission_required = 'dcim.view_site' + queryset = Site.objects.prefetch_related('region', 'tenant__group') 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 = { 'rack_count': Rack.objects.filter(site=site).count(), 'device_count': Device.objects.filter(site=site).count(), @@ -219,7 +220,7 @@ def get(self, request, slug): }) -class SiteCreateView(PermissionRequiredMixin, ObjectEditView): +class SiteCreateView(ObjectPermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_site' queryset = Site.objects.all() model_form = forms.SiteForm @@ -231,7 +232,7 @@ class SiteEditView(SiteCreateView): permission_required = 'dcim.change_site' -class SiteDeleteView(PermissionRequiredMixin, ObjectDeleteView): +class SiteDeleteView(ObjectPermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_site' queryset = Site.objects.all() default_return_url = 'dcim:site_list' diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index 58fd4380a8..850189a837 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -14,22 +14,14 @@ def has_permission(self): if self.request.user.has_perm(self.permission_required): return True - # If not, check for an object-level permission + # If not, check for object-level permissions app, codename = self.permission_required.split('.') action, model_name = codename.split('_') model = self.queryset.model - obj_permissions = ObjectPermission.objects.filter( - Q(users=self.request.user) | Q(groups__user=self.request.user), - model=ContentType.objects.get_for_model(model), - **{f'can_{action}': True} - ) - if obj_permissions: - + attrs = ObjectPermission.objects.get_attr_constraints(self.request.user, model, action) + if attrs: # Update the view's QuerySet to filter only the permitted objects - # TODO: Do this more efficiently - for perm in obj_permissions: - self.queryset = self.queryset.filter(**perm.attrs) - + self.queryset = self.queryset.filter(**attrs) return True return False diff --git a/netbox/users/models.py b/netbox/users/models.py index f2002ae959..bb2093f054 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -7,6 +7,7 @@ from django.core.exceptions import FieldError, ValidationError from django.core.validators import MinLengthValidator from django.db import models +from django.db.models import Q from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone @@ -194,6 +195,38 @@ def is_expired(self): 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): """ 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 ) + objects = ObjectPermissionManager() + class Meta: unique_together = ('model', 'attrs') diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py index 0d20fe02f7..7deb9b0de2 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -56,21 +56,12 @@ def has_perm(self, user_obj, perm, obj=None): if model._meta.model_name != model_name: raise ValueError(f"Invalid permission {perm} for model {model}") - # Retrieve user's permissions for this model - # This can probably be cached - obj_permissions = ObjectPermission.objects.filter( - Q(users=user_obj) | Q(groups__user=user_obj), - model=ContentType.objects.get_for_model(obj), - **{f'can_{action}': 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 + # 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. + attrs = ObjectPermission.objects.get_attr_constraints(user_obj, obj, action) + if model.objects.filter(pk=obj.pk, **attrs).exists(): + return True class RemoteUserBackend(ViewExemptModelBackend, RemoteUserBackend_): diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 076f2ad146..d9eace90b5 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -4,7 +4,7 @@ from django.contrib import messages 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.models import ManyToManyField, ProtectedError from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea @@ -23,6 +23,7 @@ from extras.models import CustomField, CustomFieldValue, ExportTemplate from extras.querysets import CustomFieldQueryset +from users.models import ObjectPermission from utilities.exceptions import AbortTransaction from utilities.forms import BootstrapMixin, CSVDataField, TableConfigForm from utilities.utils import csv_format, prepare_cloned_fields @@ -262,32 +263,43 @@ def post(self, request, *args, **kwargs): if form.is_valid(): logger.debug("Form validation was successful") - obj = form.save() - msg = '{} {}'.format( - 'Created' if not form.instance.pk else 'Modified', - self.queryset.model._meta.verbose_name - ) - logger.info(f"{msg} {obj} (PK: {obj.pk})") - if hasattr(obj, 'get_absolute_url'): - msg = '{} {}'.format(msg, obj.get_absolute_url(), escape(obj)) - else: - msg = '{} {}'.format(msg, escape(obj)) - messages.success(request, mark_safe(msg)) + try: + with transaction.atomic(): + obj = form.save() - 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 - if hasattr(obj, 'clone_fields'): - url = '{}?{}'.format(request.path, prepare_cloned_fields(obj)) - return redirect(url) + msg = '{} {}'.format( + 'Created' if not form.instance.pk else 'Modified', + self.queryset.model._meta.verbose_name + ) + logger.info(f"{msg} {obj} (PK: {obj.pk})") + if hasattr(obj, 'get_absolute_url'): + msg = '{} {}'.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 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)) + # If the object has clone_fields, pre-populate a new instance of the form + if hasattr(obj, 'clone_fields'): + url = '{}?{}'.format(request.path, prepare_cloned_fields(obj)) + return redirect(url) + + 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: logger.debug("Form validation failed") From daa2c6ff215cdef5f9f99b74cd12838a1a8a5a9b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 11 May 2020 17:19:11 -0400 Subject: [PATCH 004/101] Always pass obj=None to ModelBackend --- netbox/utilities/auth_backends.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py index 7deb9b0de2..65154a6f86 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -3,8 +3,6 @@ from django.conf import settings from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as RemoteUserBackend_ from django.contrib.auth.models import Group, Permission -from django.contrib.contenttypes.models import ContentType -from django.db.models import Q from users.models import ObjectPermission @@ -26,13 +24,16 @@ def has_perm(self, user_obj, perm, obj=None): '*' in settings.EXEMPT_VIEW_PERMISSIONS ) or ( # This specific model is exempt from view permission enforcement - '{}.{}'.format(app, model) in settings.EXEMPT_VIEW_PERMISSIONS + '.'.join((app, model)) in settings.EXEMPT_VIEW_PERMISSIONS ): return True except ValueError: pass - return super().has_perm(user_obj, perm, obj) + # Fall back to ModelBackend's default behavior, with one exception: Set obj to None. Model-level permissions + # override object-level permissions, so if a user has the model-level permission we can ignore any specified + # object. (By default, ModelBackend will return False if an object is specified.) + return super().has_perm(user_obj, perm, None) class ObjectPermissionBackend(ModelBackend): @@ -56,9 +57,8 @@ def has_perm(self, user_obj, perm, obj=None): if model._meta.model_name != model_name: raise ValueError(f"Invalid permission {perm} for model {model}") - # 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. + # 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. attrs = ObjectPermission.objects.get_attr_constraints(user_obj, obj, action) if model.objects.filter(pk=obj.pk, **attrs).exists(): return True From c90f680284838475b1ed8dec45d33a4c10f47c22 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 12 May 2020 15:42:44 -0400 Subject: [PATCH 005/101] Cache object-level permissions on the User instance for evaluation --- netbox/netbox/authentication.py | 48 +++++++++++++++++++++---------- netbox/users/models.py | 17 ++++------- netbox/utilities/auth_backends.py | 43 +++++++++++++++++++++++---- 3 files changed, 76 insertions(+), 32 deletions(-) diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index 850189a837..0b896969b0 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -1,32 +1,50 @@ from django.contrib.auth.mixins import AccessMixin -from django.contrib.contenttypes.models import ContentType -from django.db.models import Q +from django.core.exceptions import ImproperlyConfigured from users.models import ObjectPermission class ObjectPermissionRequiredMixin(AccessMixin): + """ + Similar to Django's built-in PermissionRequiredMixin, but extended to check for both model-level and object-level + permission assignments. If the user has only object-level permissions assigned, the view's queryset is filtered + to return only those objects on which the user is permitted to perform the specified action. + """ permission_required = None def has_permission(self): + # First, check whether the user is granted the requested permissions from any backend. + if not self.request.user.has_perm(self.permission_required): + return False - # First, check whether the user has a model-level permission assigned - if self.request.user.has_perm(self.permission_required): + # Next, determine whether the permission is model-level or object-level. Model-level permissions grant the + # specified action to *all* objects, so no further action is needed. + if self.permission_required in self.request.user._perm_cache: return True - # If not, check for object-level permissions - app, codename = self.permission_required.split('.') - action, model_name = codename.split('_') - model = self.queryset.model - attrs = ObjectPermission.objects.get_attr_constraints(self.request.user, model, action) - if attrs: - # Update the view's QuerySet to filter only the permitted objects - self.queryset = self.queryset.filter(**attrs) - return True - - return False + # If the permission is granted only at the object level, filter the view's queryset to return only objects + # on which the user is permitted to perform the specified action. + if self.permission_required in self.request.user._obj_perm_cache: + attrs = ObjectPermission.objects.get_attr_constraints(self.request.user, self.permission_required) + if attrs: + # Update the view's QuerySet to filter only the permitted objects + self.queryset = self.queryset.filter(**attrs) + return True def dispatch(self, request, *args, **kwargs): + if self.permission_required is None: + raise ImproperlyConfigured( + '{0} is missing the permission_required attribute. Define {0}.permission_required, or override ' + '{0}.get_permission_required().'.format(self.__class__.__name__) + ) + + if not hasattr(self, 'queryset'): + raise ImproperlyConfigured( + '{} has no queryset defined. ObjectPermissionRequiredMixin may only be used on views which define ' + 'a base queryset'.format(self.__class__.__name__) + ) + if not self.has_permission(): return self.handle_no_permission() + return super().dispatch(request, *args, **kwargs) diff --git a/netbox/users/models.py b/netbox/users/models.py index bb2093f054..452e91c219 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -197,16 +197,19 @@ def is_expired(self): class ObjectPermissionManager(models.Manager): - def get_attr_constraints(self, user, model, action): + def get_attr_constraints(self, user, perm): """ 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. """ + app_label, codename = perm.split('.') + action, model_name = codename.split('_') assert action in ['view', 'add', 'change', 'delete'], f"Invalid action: {action}" + content_type = ContentType.objects.get(app_label=app_label, model=model_name) qs = self.get_queryset().filter( Q(users=user) | Q(groups__user=user), - model=ContentType.objects.get_for_model(model), + model=content_type, **{f'can_{action}': True} ) @@ -216,16 +219,6 @@ def get_attr_constraints(self, user, model, action): 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): """ diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py index 65154a6f86..f4290e917d 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -3,6 +3,8 @@ from django.conf import settings from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as RemoteUserBackend_ from django.contrib.auth.models import Group, Permission +from django.contrib.contenttypes.models import ContentType +from django.db.models import Q from users.models import ObjectPermission @@ -43,23 +45,54 @@ class ObjectPermissionBackend(ModelBackend): check: For example, if a user has the dcim.view_site model-level permission assigned, the ViewExemptModelBackend will grant permission before this backend is evaluated for permission to view a specific site. """ + def _get_all_permissions(self, user_obj): + """ + Retrieve all ObjectPermissions assigned to this User (either directly or through a Group) and return the model- + level equivalent codenames. + """ + perm_names = set() + for obj_perm in ObjectPermission.objects.filter( + Q(users=user_obj) | Q(groups__user=user_obj) + ).prefetch_related('model'): + for action in ['view', 'add', 'change', 'delete']: + if getattr(obj_perm, f"can_{action}"): + perm_names.add(f"{obj_perm.model.app_label}.{action}_{obj_perm.model.model}") + return perm_names + + def get_all_permissions(self, user_obj, obj=None): + """ + Get all model-level permissions assigned by this backend. Permissions are cached on the User instance. + """ + if not user_obj.is_active or user_obj.is_anonymous: + return set() + if not hasattr(user_obj, '_obj_perm_cache'): + user_obj._obj_perm_cache = self._get_all_permissions(user_obj) + return user_obj._obj_perm_cache + def has_perm(self, user_obj, perm, obj=None): - # This backend only checks for permissions on specific objects + # If no object is specified, look for any matching ObjectPermissions. If one or more are found, this indicates + # that the user has permission to perform the requested action on at least *some* objects, but not necessarily + # on all of them. if obj is None: + return perm in self.get_all_permissions(user_obj) + + attrs = ObjectPermission.objects.get_attr_constraints(user_obj, perm) + + # No ObjectPermissions found for this combination of user, model, and action + if not attrs: return - app, codename = perm.split('.') - action, model_name = codename.split('_') model = obj._meta.model # Check that the requested permission applies to the specified object - if model._meta.model_name != model_name: + app_label, codename = perm.split('.') + action, model_name = codename.split('_') + if model._meta.label_lower != '.'.join((app_label, model_name)): raise ValueError(f"Invalid permission {perm} for model {model}") # 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. - attrs = ObjectPermission.objects.get_attr_constraints(user_obj, obj, action) if model.objects.filter(pk=obj.pk, **attrs).exists(): return True From a275a30dcae507d42a1da0c319c44d73691e1de3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 12 May 2020 16:07:07 -0400 Subject: [PATCH 006/101] Reimplement the ViewExemptModelBackend to explicitly cache all exempted view permissions on the User instance --- netbox/utilities/auth_backends.py | 43 ++++++++++++++----------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py index f4290e917d..49dd8d0aa5 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -3,7 +3,6 @@ from django.conf import settings from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as RemoteUserBackend_ from django.contrib.auth.models import Group, Permission -from django.contrib.contenttypes.models import ContentType from django.db.models import Q from users.models import ObjectPermission @@ -14,28 +13,26 @@ class ViewExemptModelBackend(ModelBackend): Custom implementation of Django's stock ModelBackend which allows for the exemption of arbitrary models from view permission enforcement. """ - def has_perm(self, user_obj, perm, obj=None): - - # If this is a view permission, check whether the model has been exempted from enforcement - try: - app, codename = perm.split('.') - action, model = codename.split('_') - if action == 'view': - if ( - # All models are exempt from view permission enforcement - '*' in settings.EXEMPT_VIEW_PERMISSIONS - ) or ( - # This specific model is exempt from view permission enforcement - '.'.join((app, model)) in settings.EXEMPT_VIEW_PERMISSIONS - ): - return True - except ValueError: - pass - - # Fall back to ModelBackend's default behavior, with one exception: Set obj to None. Model-level permissions - # override object-level permissions, so if a user has the model-level permission we can ignore any specified - # object. (By default, ModelBackend will return False if an object is specified.) - return super().has_perm(user_obj, perm, None) + def _get_user_permissions(self, user_obj): + + if not settings.EXEMPT_VIEW_PERMISSIONS: + # No view permissions have been exempted from enforcement, so fall back to the built-in logic. + return super()._get_user_permissions(user_obj) + + if '*' in settings.EXEMPT_VIEW_PERMISSIONS: + # All view permissions have been exempted from enforcement, so include all view permissions when fetching + # User permissions. + return Permission.objects.filter( + Q(user=user_obj) | Q(codename__startswith='view_') + ) + + # Return all Permissions that are either assigned to the user or that are view permissions listed in + # EXEMPT_VIEW_PERMISSIONS. + qs_filter = Q(user=user_obj) + for model in settings.EXEMPT_VIEW_PERMISSIONS: + app, name = model.split('.') + qs_filter |= Q(content_type__app_label=app, codename=f'view_{name}') + return Permission.objects.filter(qs_filter) class ObjectPermissionBackend(ModelBackend): From 94d0ebbd7df8f45c7206edadeac02fa9fcfb9266 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 12 May 2020 16:40:04 -0400 Subject: [PATCH 007/101] Fix ObjectPermission attribute consolidation --- netbox/netbox/authentication.py | 2 +- netbox/users/models.py | 4 ++-- netbox/users/tests/test_permissions.py | 8 ++++---- netbox/utilities/auth_backends.py | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index 0b896969b0..2854d4cb95 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -28,7 +28,7 @@ def has_permission(self): attrs = ObjectPermission.objects.get_attr_constraints(self.request.user, self.permission_required) if attrs: # Update the view's QuerySet to filter only the permitted objects - self.queryset = self.queryset.filter(**attrs) + self.queryset = self.queryset.filter(attrs) return True def dispatch(self, request, *args, **kwargs): diff --git a/netbox/users/models.py b/netbox/users/models.py index 452e91c219..70e7254e64 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -213,9 +213,9 @@ def get_attr_constraints(self, user, perm): **{f'can_{action}': True} ) - attrs = {} + attrs = Q() for perm in qs: - attrs.update(perm.attrs) + attrs |= Q(**perm.attrs) return attrs diff --git a/netbox/users/tests/test_permissions.py b/netbox/users/tests/test_permissions.py index f73fd8f435..487543bd3f 100644 --- a/netbox/users/tests/test_permissions.py +++ b/netbox/users/tests/test_permissions.py @@ -1,5 +1,5 @@ from django.contrib.contenttypes.models import ContentType -from django.contrib.auth.models import Permission, User +from django.contrib.auth.models import User from django.test import TestCase, override_settings from dcim.models import Site @@ -7,7 +7,7 @@ from users.models import ObjectPermission -class UserConfigTest(TestCase): +class ObjectPermissionTest(TestCase): def setUp(self): @@ -41,7 +41,7 @@ def test_permission_view_object(self): can_view=True ) object_perm.save() - self.user.object_permissions.add(object_perm) + object_perm.users.add(self.user) # The test user should have permission to view only the first site. self.assertTrue(self.user.has_perm('dcim.view_site', sites[0])) @@ -54,7 +54,7 @@ def test_permission_view_object(self): can_view=True ) object_perm.save() - self.user.object_permissions.add(object_perm) + object_perm.users.add(self.user) # The user should now able to view the first two sites, but not the third. self.assertTrue(self.user.has_perm('dcim.view_site', sites[0])) diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py index 49dd8d0aa5..9e56fd16c9 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -90,7 +90,7 @@ def has_perm(self, user_obj, perm, obj=None): # 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, **attrs).exists(): + if model.objects.filter(attrs, pk=obj.pk).exists(): return True From be5962fb3a409b12fcc768fdff7c0aec17739e27 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 12 May 2020 17:00:03 -0400 Subject: [PATCH 008/101] ObjectPermissionRequiredMixin should exempt superusers --- netbox/netbox/authentication.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index 2854d4cb95..d85b2f1248 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -19,7 +19,7 @@ def has_permission(self): # Next, determine whether the permission is model-level or object-level. Model-level permissions grant the # specified action to *all* objects, so no further action is needed. - if self.permission_required in self.request.user._perm_cache: + if self.request.user.is_superuser or self.permission_required in self.request.user._perm_cache: return True # If the permission is granted only at the object level, filter the view's queryset to return only objects From f54fb67efc621a5f0198dc7ac525e44476a5381a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 14 May 2020 13:49:13 -0400 Subject: [PATCH 009/101] Add object-level support to TokenPermissions --- netbox/netbox/api.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/netbox/netbox/api.py b/netbox/netbox/api.py index 0e04719f98..a67a5d60ae 100644 --- a/netbox/netbox/api.py +++ b/netbox/netbox/api.py @@ -2,7 +2,7 @@ from django.db.models import QuerySet from rest_framework import authentication, exceptions from rest_framework.pagination import LimitOffsetPagination -from rest_framework.permissions import DjangoModelPermissions, SAFE_METHODS +from rest_framework.permissions import DjangoObjectPermissions, SAFE_METHODS from rest_framework.renderers import BrowsableAPIRenderer from rest_framework.utils import formatting @@ -51,7 +51,7 @@ def authenticate_credentials(self, key): return token.user, token -class TokenPermissions(DjangoModelPermissions): +class TokenPermissions(DjangoObjectPermissions): """ Custom permissions handler which extends the built-in DjangoModelPermissions to validate a Token's write ability for unsafe requests (POST/PUT/PATCH/DELETE). @@ -74,15 +74,29 @@ def __init__(self): super().__init__() + def _verify_write_permission(self, request): + # If token authentication is in use, verify that the token allows write operations (for unsafe methods). + if request.method in SAFE_METHODS: + return True + if isinstance(request.auth, Token) and request.auth.write_enabled: + return True + def has_permission(self, request, view): - # If token authentication is in use, verify that the token allows write operations (for unsafe methods). - if request.method not in SAFE_METHODS and isinstance(request.auth, Token): - if not request.auth.write_enabled: - return False + # Enforce Token write ability + if not self._verify_write_permission(request): + return False return super().has_permission(request, view) + def has_object_permission(self, request, view, obj): + + # Enforce Token write ability + if not self._verify_write_permission(request): + return False + + return super().has_object_permission(request, view, obj) + # # Pagination From 73895b1c88fdfe4f15de9045884ceee05cae6b52 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 14 May 2020 17:44:15 -0400 Subject: [PATCH 010/101] Bypass permission caching for anonymous users --- netbox/utilities/auth_backends.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py index 9e56fd16c9..46ec69458d 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -34,6 +34,28 @@ def _get_user_permissions(self, user_obj): qs_filter |= Q(content_type__app_label=app, codename=f'view_{name}') return Permission.objects.filter(qs_filter) + def has_perm(self, user_obj, perm, obj=None): + + # Authenticated users need to have the view permissions cached for assessment + if user_obj.is_authenticated: + return super().has_perm(user_obj, perm, obj) + + # If this is a view permission, check whether the model has been exempted from enforcement + try: + app, codename = perm.split('.') + action, model = codename.split('_') + if action == 'view': + if ( + # All models are exempt from view permission enforcement + '*' in settings.EXEMPT_VIEW_PERMISSIONS + ) or ( + # This specific model is exempt from view permission enforcement + '{}.{}'.format(app, model) in settings.EXEMPT_VIEW_PERMISSIONS + ): + return True + except ValueError: + pass + class ObjectPermissionBackend(ModelBackend): """ From aeb32104a46c32797380c80e2549e4583377d58d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 14 May 2020 17:44:46 -0400 Subject: [PATCH 011/101] Enforce object-level permissions for API views --- netbox/utilities/api.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 2050556697..405c268787 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -6,15 +6,15 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist from django.db.models import ManyToManyField, ProtectedError -from django.http import Http404 from django.urls import reverse from rest_framework.exceptions import APIException from rest_framework.permissions import BasePermission from rest_framework.relations import PrimaryKeyRelatedField, RelatedField from rest_framework.response import Response from rest_framework.serializers import Field, ModelSerializer, ValidationError -from rest_framework.viewsets import ModelViewSet as _ModelViewSet, ViewSet +from rest_framework.viewsets import ModelViewSet as _ModelViewSet +from users.models import ObjectPermission from .utils import dict_to_filter_params, dynamic_import @@ -323,6 +323,22 @@ def get_serializer_class(self): logger.debug(f"Using serializer {self.serializer_class}") return self.serializer_class + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + + if not request.user.is_authenticated or request.user.is_superuser: + return + + permission_required = 'dcim.view_site' + + # Enforce object-level permissions + if permission_required not in self.request.user._perm_cache: + attrs = ObjectPermission.objects.get_attr_constraints(self.request.user, permission_required) + if attrs: + # Update the view's QuerySet to filter only the permitted objects + self.queryset = self.queryset.filter(attrs) + return True + def dispatch(self, request, *args, **kwargs): logger = logging.getLogger('netbox.api.views.ModelViewSet') From 64f60228ecb85e0dd2d96ec796e84bf833d880ad Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 20 May 2020 13:35:54 -0400 Subject: [PATCH 012/101] Add web UI view tests for object-level permissions --- netbox/ipam/views.py | 14 +- netbox/netbox/tests/test_authentication.py | 221 ++++++++++++++++++++- 2 files changed, 227 insertions(+), 8 deletions(-) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 92eb5b8237..0c7d0770fa 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -8,6 +8,7 @@ from django_tables2 import RequestConfig from dcim.models import Device, Interface +from netbox.authentication import ObjectPermissionRequiredMixin from utilities.paginator import EnhancedPaginator from utilities.views import ( BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, @@ -440,7 +441,7 @@ class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Prefixes # -class PrefixListView(PermissionRequiredMixin, ObjectListView): +class PrefixListView(ObjectPermissionRequiredMixin, ObjectListView): permission_required = 'ipam.view_prefix' queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') filterset = filters.PrefixFilterSet @@ -454,14 +455,13 @@ def alter_queryset(self, request): return self.queryset.annotate_depth(limit=limit) -class PrefixView(PermissionRequiredMixin, View): +class PrefixView(ObjectPermissionRequiredMixin, View): permission_required = 'ipam.view_prefix' + queryset = Prefix.objects.prefetch_related('vrf', 'site__region', 'tenant__group', 'vlan__group', 'role') def get(self, request, pk): - prefix = get_object_or_404(Prefix.objects.prefetch_related( - 'vrf', 'site__region', 'tenant__group', 'vlan__group', 'role' - ), pk=pk) + prefix = get_object_or_404(self.queryset, pk=pk) try: aggregate = Aggregate.objects.get(prefix__net_contains_or_equals=str(prefix.prefix)) @@ -586,7 +586,7 @@ def get(self, request, pk): }) -class PrefixCreateView(PermissionRequiredMixin, ObjectEditView): +class PrefixCreateView(ObjectPermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_prefix' queryset = Prefix.objects.all() model_form = forms.PrefixForm @@ -598,7 +598,7 @@ class PrefixEditView(PrefixCreateView): permission_required = 'ipam.change_prefix' -class PrefixDeleteView(PermissionRequiredMixin, ObjectDeleteView): +class PrefixDeleteView(ObjectPermissionRequiredMixin, ObjectDeleteView): permission_required = 'ipam.delete_prefix' queryset = Prefix.objects.all() template_name = 'ipam/prefix_delete.html' diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 42cddb082e..59e4dcde42 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -1,8 +1,16 @@ from django.conf import settings from django.contrib.auth.models import Group, User -from django.test import Client, TestCase +from django.contrib.contenttypes.models import ContentType +from django.test import Client from django.test.utils import override_settings from django.urls import reverse +from netaddr import IPNetwork + +from dcim.models import Site +from ipam.choices import PrefixStatusChoices +from ipam.models import Prefix +from users.models import ObjectPermission +from utilities.testing.testcases import TestCase class ExternalAuthenticationTestCase(TestCase): @@ -157,3 +165,214 @@ def test_remote_auth_default_permissions(self): new_user = User.objects.get(username='remoteuser2') self.assertEqual(int(self.client.session.get('_auth_user_id')), new_user.pk, msg='Authentication failed') self.assertTrue(new_user.has_perms(['dcim.add_site', 'dcim.change_site'])) + + +class ObjectPermissionTestCase(TestCase): + + @classmethod + def setUpTestData(cls): + + cls.sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + ) + Site.objects.bulk_create(cls.sites) + + cls.prefixes = ( + Prefix(prefix=IPNetwork('10.0.0.0/24'), site=cls.sites[0]), + Prefix(prefix=IPNetwork('10.0.1.0/24'), site=cls.sites[0]), + Prefix(prefix=IPNetwork('10.0.2.0/24'), site=cls.sites[0]), + Prefix(prefix=IPNetwork('10.0.3.0/24'), site=cls.sites[1]), + Prefix(prefix=IPNetwork('10.0.4.0/24'), site=cls.sites[1]), + Prefix(prefix=IPNetwork('10.0.5.0/24'), site=cls.sites[1]), + Prefix(prefix=IPNetwork('10.0.6.0/24'), site=cls.sites[2]), + Prefix(prefix=IPNetwork('10.0.7.0/24'), site=cls.sites[2]), + Prefix(prefix=IPNetwork('10.0.8.0/24'), site=cls.sites[2]), + ) + Prefix.objects.bulk_create(cls.prefixes) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_ui_get_object(self): + + # Assign object permission + obj_perm = ObjectPermission( + model=ContentType.objects.get_for_model(Prefix), + attrs={ + 'site__name': 'Site 1', + }, + can_view=True + ) + obj_perm.save() + obj_perm.users.add(self.user) + + # Retrieve permitted object + response = self.client.get(self.prefixes[0].get_absolute_url()) + self.assertHttpStatus(response, 200) + + # Attempt to retrieve non-permitted object + response = self.client.get(self.prefixes[3].get_absolute_url()) + self.assertHttpStatus(response, 404) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_ui_list_objects(self): + + # Attempt to list objects without permission + response = self.client.get(reverse('ipam:prefix_list')) + self.assertHttpStatus(response, 403) + + # Assign object permission + obj_perm = ObjectPermission( + model=ContentType.objects.get_for_model(Prefix), + attrs={ + 'site__name': 'Site 1', + }, + can_view=True + ) + obj_perm.save() + obj_perm.users.add(self.user) + + # Retrieve all objects. Only permitted objects should be returned. + response = self.client.get(reverse('ipam:prefix_list')) + self.assertHttpStatus(response, 200) + self.assertIn(str(self.prefixes[0].prefix), str(response.content)) + self.assertNotIn(str(self.prefixes[3].prefix), str(response.content)) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_ui_create_object(self): + initial_count = Prefix.objects.count() + form_data = { + 'prefix': '10.0.9.0/24', + 'site': self.sites[1].pk, + 'status': PrefixStatusChoices.STATUS_ACTIVE, + } + + # Attempt to create an object without permission + request = { + 'path': reverse('ipam:prefix_add'), + 'data': form_data, + 'follow': False, # Do not follow 302 redirects + } + response = self.client.post(**request) + self.assertHttpStatus(response, 403) + self.assertEqual(initial_count, Prefix.objects.count()) + + # Assign object permission + obj_perm = ObjectPermission( + model=ContentType.objects.get_for_model(Prefix), + attrs={ + 'site__name': 'Site 1', + }, + can_view=True, + can_add=True + ) + obj_perm.save() + obj_perm.users.add(self.user) + + # Attempt to create a non-permitted object + request = { + 'path': reverse('ipam:prefix_add'), + 'data': form_data, + 'follow': True, # Follow 302 redirects + } + response = self.client.post(**request) + self.assertHttpStatus(response, 200) + self.assertEqual(initial_count, Prefix.objects.count()) + + # Create a permitted object + form_data['site'] = self.sites[0].pk + request = { + 'path': reverse('ipam:prefix_add'), + 'data': form_data, + 'follow': True, # Follow 302 redirects + } + response = self.client.post(**request) + self.assertHttpStatus(response, 200) + self.assertEqual(initial_count + 1, Prefix.objects.count()) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_ui_edit_object(self): + form_data = { + 'prefix': '10.0.9.0/24', + 'site': self.sites[0].pk, + 'status': PrefixStatusChoices.STATUS_RESERVED, + } + + # Attempt to edit an object without permission + request = { + 'path': reverse('ipam:prefix_edit', kwargs={'pk': self.prefixes[0].pk}), + 'data': form_data, + 'follow': False, # Do not follow 302 redirects + } + response = self.client.post(**request) + self.assertHttpStatus(response, 403) + + # Assign object permission + obj_perm = ObjectPermission( + model=ContentType.objects.get_for_model(Prefix), + attrs={ + 'site__name': 'Site 1', + }, + can_view=True, + can_change=True + ) + obj_perm.save() + obj_perm.users.add(self.user) + + # Attempt to edit a non-permitted object + request = { + 'path': reverse('ipam:prefix_edit', kwargs={'pk': self.prefixes[3].pk}), + 'data': form_data, + 'follow': True, # Follow 302 redirects + } + response = self.client.post(**request) + self.assertHttpStatus(response, 404) + + # Edit a permitted object + request = { + 'path': reverse('ipam:prefix_edit', kwargs={'pk': self.prefixes[0].pk}), + 'data': form_data, + 'follow': True, # Follow 302 redirects + } + response = self.client.post(**request) + self.assertHttpStatus(response, 200) + prefix = Prefix.objects.get(pk=self.prefixes[0].pk) + self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_RESERVED) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_ui_delete_object(self): + form_data = { + 'confirm': True + } + + # Assign object permission + obj_perm = ObjectPermission( + model=ContentType.objects.get_for_model(Prefix), + attrs={ + 'site__name': 'Site 1', + }, + can_view=True, + can_delete=True + ) + obj_perm.save() + obj_perm.users.add(self.user) + + # Delete permitted object + request = { + 'path': reverse('ipam:prefix_delete', kwargs={'pk': self.prefixes[0].pk}), + 'data': form_data, + 'follow': True, # Follow 302 redirects + } + response = self.client.post(**request) + self.assertHttpStatus(response, 200) + self.assertFalse(Prefix.objects.filter(pk=self.prefixes[0].pk).exists()) + + # Attempt to delete non-permitted object + request = { + 'path': reverse('ipam:prefix_delete', kwargs={'pk': self.prefixes[3].pk}), + 'data': form_data, + 'follow': True, # Follow 302 redirects + } + response = self.client.post(**request) + self.assertHttpStatus(response, 404) + self.assertTrue(Prefix.objects.filter(pk=self.prefixes[3].pk).exists()) From 8eb4d0a36be636e03d728a23391dc57fc130b387 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 20 May 2020 16:27:56 -0400 Subject: [PATCH 013/101] Remove ViewExemptBackend; use same for model- and object-level permissions --- netbox/netbox/authentication.py | 25 ++-- netbox/netbox/settings.py | 3 +- netbox/netbox/tests/test_authentication.py | 43 +++--- netbox/utilities/auth_backends.py | 152 +++++++++------------ 4 files changed, 98 insertions(+), 125 deletions(-) diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index d85b2f1248..2e68e6ef12 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -13,23 +13,28 @@ class ObjectPermissionRequiredMixin(AccessMixin): permission_required = None def has_permission(self): - # First, check whether the user is granted the requested permissions from any backend. - if not self.request.user.has_perm(self.permission_required): + user = self.request.user + + # First, check that the user is granted the required permission at either the model or object level. + if not user.has_perm(self.permission_required): return False - # Next, determine whether the permission is model-level or object-level. Model-level permissions grant the + # Superusers implicitly have all permissions + if user.is_superuser: + return True + + # Determine whether the permission is model-level or object-level. Model-level permissions grant the # specified action to *all* objects, so no further action is needed. - if self.request.user.is_superuser or self.permission_required in self.request.user._perm_cache: + if self.permission_required in {*user._user_perm_cache, *user._group_perm_cache}: return True # If the permission is granted only at the object level, filter the view's queryset to return only objects # on which the user is permitted to perform the specified action. - if self.permission_required in self.request.user._obj_perm_cache: - attrs = ObjectPermission.objects.get_attr_constraints(self.request.user, self.permission_required) - if attrs: - # Update the view's QuerySet to filter only the permitted objects - self.queryset = self.queryset.filter(attrs) - return True + attrs = ObjectPermission.objects.get_attr_constraints(user, self.permission_required) + if attrs: + # Update the view's QuerySet to filter only the permitted objects + self.queryset = self.queryset.filter(attrs) + return True def dispatch(self, request, *args, **kwargs): if self.permission_required is None: diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 5c48ee6204..d265cc58cd 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -333,8 +333,7 @@ def _setting(name, default=None): # Set up authentication backends AUTHENTICATION_BACKENDS = [ - REMOTE_AUTH_BACKEND, - 'utilities.auth_backends.ViewExemptModelBackend', + # REMOTE_AUTH_BACKEND, 'utilities.auth_backends.ObjectPermissionBackend', ] diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 59e4dcde42..18bf251d4e 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -5,11 +5,12 @@ from django.test.utils import override_settings from django.urls import reverse from netaddr import IPNetwork +from rest_framework.test import APIClient from dcim.models import Site from ipam.choices import PrefixStatusChoices from ipam.models import Prefix -from users.models import ObjectPermission +from users.models import ObjectPermission, Token from utilities.testing.testcases import TestCase @@ -167,7 +168,7 @@ def test_remote_auth_default_permissions(self): self.assertTrue(new_user.has_perms(['dcim.add_site', 'dcim.change_site'])) -class ObjectPermissionTestCase(TestCase): +class ObjectPermissionViewTestCase(TestCase): @classmethod def setUpTestData(cls): @@ -193,14 +194,16 @@ def setUpTestData(cls): Prefix.objects.bulk_create(cls.prefixes) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_ui_get_object(self): + def test_get_object(self): + + # Attempt to retrieve object without permission + response = self.client.get(self.prefixes[0].get_absolute_url()) + self.assertHttpStatus(response, 403) # Assign object permission obj_perm = ObjectPermission( model=ContentType.objects.get_for_model(Prefix), - attrs={ - 'site__name': 'Site 1', - }, + attrs={'site__name': 'Site 1'}, can_view=True ) obj_perm.save() @@ -215,7 +218,7 @@ def test_ui_get_object(self): self.assertHttpStatus(response, 404) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_ui_list_objects(self): + def test_list_objects(self): # Attempt to list objects without permission response = self.client.get(reverse('ipam:prefix_list')) @@ -224,9 +227,7 @@ def test_ui_list_objects(self): # Assign object permission obj_perm = ObjectPermission( model=ContentType.objects.get_for_model(Prefix), - attrs={ - 'site__name': 'Site 1', - }, + attrs={'site__name': 'Site 1'}, can_view=True ) obj_perm.save() @@ -239,7 +240,7 @@ def test_ui_list_objects(self): self.assertNotIn(str(self.prefixes[3].prefix), str(response.content)) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_ui_create_object(self): + def test_create_object(self): initial_count = Prefix.objects.count() form_data = { 'prefix': '10.0.9.0/24', @@ -260,9 +261,7 @@ def test_ui_create_object(self): # Assign object permission obj_perm = ObjectPermission( model=ContentType.objects.get_for_model(Prefix), - attrs={ - 'site__name': 'Site 1', - }, + attrs={'site__name': 'Site 1'}, can_view=True, can_add=True ) @@ -277,7 +276,7 @@ def test_ui_create_object(self): } response = self.client.post(**request) self.assertHttpStatus(response, 200) - self.assertEqual(initial_count, Prefix.objects.count()) + self.assertEqual(Prefix.objects.count(), initial_count) # Create a permitted object form_data['site'] = self.sites[0].pk @@ -288,10 +287,10 @@ def test_ui_create_object(self): } response = self.client.post(**request) self.assertHttpStatus(response, 200) - self.assertEqual(initial_count + 1, Prefix.objects.count()) + self.assertEqual(Prefix.objects.count(), initial_count + 1) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_ui_edit_object(self): + def test_edit_object(self): form_data = { 'prefix': '10.0.9.0/24', 'site': self.sites[0].pk, @@ -310,9 +309,7 @@ def test_ui_edit_object(self): # Assign object permission obj_perm = ObjectPermission( model=ContentType.objects.get_for_model(Prefix), - attrs={ - 'site__name': 'Site 1', - }, + attrs={'site__name': 'Site 1'}, can_view=True, can_change=True ) @@ -340,7 +337,7 @@ def test_ui_edit_object(self): self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_RESERVED) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_ui_delete_object(self): + def test_delete_object(self): form_data = { 'confirm': True } @@ -348,9 +345,7 @@ def test_ui_delete_object(self): # Assign object permission obj_perm = ObjectPermission( model=ContentType.objects.get_for_model(Prefix), - attrs={ - 'site__name': 'Site 1', - }, + attrs={'site__name': 'Site 1'}, can_view=True, can_delete=True ) diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py index 46ec69458d..e540a04e06 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -8,115 +8,89 @@ from users.models import ObjectPermission -class ViewExemptModelBackend(ModelBackend): - """ - Custom implementation of Django's stock ModelBackend which allows for the exemption of arbitrary models from view - permission enforcement. - """ - def _get_user_permissions(self, user_obj): - - if not settings.EXEMPT_VIEW_PERMISSIONS: - # No view permissions have been exempted from enforcement, so fall back to the built-in logic. - return super()._get_user_permissions(user_obj) - - if '*' in settings.EXEMPT_VIEW_PERMISSIONS: - # All view permissions have been exempted from enforcement, so include all view permissions when fetching - # User permissions. - return Permission.objects.filter( - Q(user=user_obj) | Q(codename__startswith='view_') - ) - - # Return all Permissions that are either assigned to the user or that are view permissions listed in - # EXEMPT_VIEW_PERMISSIONS. - qs_filter = Q(user=user_obj) - for model in settings.EXEMPT_VIEW_PERMISSIONS: - app, name = model.split('.') - qs_filter |= Q(content_type__app_label=app, codename=f'view_{name}') - return Permission.objects.filter(qs_filter) - - def has_perm(self, user_obj, perm, obj=None): - - # Authenticated users need to have the view permissions cached for assessment - if user_obj.is_authenticated: - return super().has_perm(user_obj, perm, obj) - - # If this is a view permission, check whether the model has been exempted from enforcement - try: - app, codename = perm.split('.') - action, model = codename.split('_') - if action == 'view': - if ( - # All models are exempt from view permission enforcement - '*' in settings.EXEMPT_VIEW_PERMISSIONS - ) or ( - # This specific model is exempt from view permission enforcement - '{}.{}'.format(app, model) in settings.EXEMPT_VIEW_PERMISSIONS - ): - return True - except ValueError: - pass - - class ObjectPermissionBackend(ModelBackend): - """ - Evaluates permission of a user to access or modify a specific object based on the assignment of ObjectPermissions - either to the user directly or to a group of which the user is a member. Model-level permissions supersede this - check: For example, if a user has the dcim.view_site model-level permission assigned, the ViewExemptModelBackend - will grant permission before this backend is evaluated for permission to view a specific site. - """ - def _get_all_permissions(self, user_obj): + + def get_object_permissions(self, user_obj): """ - Retrieve all ObjectPermissions assigned to this User (either directly or through a Group) and return the model- - level equivalent codenames. + Return all model-level permissions granted to the user by an ObjectPermission. """ - perm_names = set() - for obj_perm in ObjectPermission.objects.filter( - Q(users=user_obj) | Q(groups__user=user_obj) - ).prefetch_related('model'): - for action in ['view', 'add', 'change', 'delete']: - if getattr(obj_perm, f"can_{action}"): - perm_names.add(f"{obj_perm.model.app_label}.{action}_{obj_perm.model.model}") - return perm_names + if not hasattr(user_obj, '_object_perm_cache'): + + # Cache all assigned ObjectPermissions on the User instance + perms = set() + for obj_perm in ObjectPermission.objects.filter( + Q(users=user_obj) | + Q(groups__user=user_obj) + ).prefetch_related('model'): + for action in ['view', 'add', 'change', 'delete']: + if getattr(obj_perm, f"can_{action}"): + perms.add(f"{obj_perm.model.app_label}.{action}_{obj_perm.model.model}") + setattr(user_obj, '_object_perm_cache', perms) + + return user_obj._object_perm_cache def get_all_permissions(self, user_obj, obj=None): - """ - Get all model-level permissions assigned by this backend. Permissions are cached on the User instance. - """ + + # Handle inactive/anonymous users if not user_obj.is_active or user_obj.is_anonymous: return set() - if not hasattr(user_obj, '_obj_perm_cache'): - user_obj._obj_perm_cache = self._get_all_permissions(user_obj) - return user_obj._obj_perm_cache + + # Cache model-level permissions on the User instance + if not hasattr(user_obj, '_perm_cache'): + user_obj._perm_cache = { + *self.get_user_permissions(user_obj, obj=obj), + *self.get_group_permissions(user_obj, obj=obj), + *self.get_object_permissions(user_obj) + } + + return user_obj._perm_cache def has_perm(self, user_obj, perm, obj=None): + app_label, codename = perm.split('.') + action, model_name = codename.split('_') - # If no object is specified, look for any matching ObjectPermissions. If one or more are found, this indicates - # that the user has permission to perform the requested action on at least *some* objects, but not necessarily - # on all of them. + # If this is a view permission, check whether the model has been exempted from enforcement + if action == 'view': + if ( + # All models are exempt from view permission enforcement + '*' in settings.EXEMPT_VIEW_PERMISSIONS + ) or ( + # This specific model is exempt from view permission enforcement + '{}.{}'.format(app_label, model_name) in settings.EXEMPT_VIEW_PERMISSIONS + ): + return True + + # If no object is specified, evaluate model-level permissions. The presence of a permission in this set tells + # us that the user has permission for *some* objects, but not necessarily a specific object. if obj is None: return perm in self.get_all_permissions(user_obj) - attrs = ObjectPermission.objects.get_attr_constraints(user_obj, perm) - - # No ObjectPermissions found for this combination of user, model, and action - if not attrs: - return - + # Sanity check: Ensure that the requested permission applies to the specified object model = obj._meta.model - - # Check that the requested permission applies to the specified object - app_label, codename = perm.split('.') - action, model_name = codename.split('_') if model._meta.label_lower != '.'.join((app_label, model_name)): raise ValueError(f"Invalid permission {perm} for model {model}") - # 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(attrs, pk=obj.pk).exists(): + # If the user has been granted model-level permission for the object, return True + model_perms = { + *self.get_user_permissions(user_obj), + *self.get_group_permissions(user_obj), + } + if perm in model_perms: return True + # Gather all ObjectPermissions pertinent to the requested permission. If none are found, the User has no + # applicable permissions. + attrs = ObjectPermission.objects.get_attr_constraints(user_obj, perm) + if not attrs: + return False + + # Permission to perform the requested action on the object depends on whether the specified object matches + # the specified attributes. Note that this check is made against the *database* record representing the object, + # not the instance itself. + return model.objects.filter(attrs, pk=obj.pk).exists() + -class RemoteUserBackend(ViewExemptModelBackend, RemoteUserBackend_): +class RemoteUserBackend(RemoteUserBackend_): """ Custom implementation of Django's RemoteUserBackend which provides configuration hooks for basic customization. """ From 8c40148ca730f21ec65b0ffa53d0e5bf924603bc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 20 May 2020 16:47:33 -0400 Subject: [PATCH 014/101] Add object permission tests for get and list API views --- netbox/netbox/tests/test_authentication.py | 121 +++++++++++++++++++++ netbox/utilities/api.py | 10 +- 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 18bf251d4e..64dd83783b 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -371,3 +371,124 @@ def test_delete_object(self): response = self.client.post(**request) self.assertHttpStatus(response, 404) self.assertTrue(Prefix.objects.filter(pk=self.prefixes[3].pk).exists()) + + +class ObjectPermissionAPIViewTestCase(TestCase): + client_class = APIClient + + @classmethod + def setUpTestData(cls): + + cls.sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + ) + Site.objects.bulk_create(cls.sites) + + cls.prefixes = ( + Prefix(prefix=IPNetwork('10.0.0.0/24'), site=cls.sites[0]), + Prefix(prefix=IPNetwork('10.0.1.0/24'), site=cls.sites[0]), + Prefix(prefix=IPNetwork('10.0.2.0/24'), site=cls.sites[0]), + Prefix(prefix=IPNetwork('10.0.3.0/24'), site=cls.sites[1]), + Prefix(prefix=IPNetwork('10.0.4.0/24'), site=cls.sites[1]), + Prefix(prefix=IPNetwork('10.0.5.0/24'), site=cls.sites[1]), + Prefix(prefix=IPNetwork('10.0.6.0/24'), site=cls.sites[2]), + Prefix(prefix=IPNetwork('10.0.7.0/24'), site=cls.sites[2]), + Prefix(prefix=IPNetwork('10.0.8.0/24'), site=cls.sites[2]), + ) + Prefix.objects.bulk_create(cls.prefixes) + + def setUp(self): + """ + Create a test user and token for API calls. + """ + self.user = User.objects.create(username='testuser') + self.token = Token.objects.create(user=self.user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)} + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_get_object(self): + + # Attempt to retrieve object without permission + url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk}) + response = self.client.get(url, **self.header) + self.assertEqual(response.status_code, 403) + + # Assign object permission + obj_perm = ObjectPermission( + model=ContentType.objects.get_for_model(Prefix), + attrs={ + 'site__name': 'Site 1', + }, + can_view=True + ) + obj_perm.save() + obj_perm.users.add(self.user) + + # Retrieve permitted object + url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk}) + response = self.client.get(url, **self.header) + self.assertEqual(response.status_code, 200) + + # Attempt to retrieve non-permitted object + url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[3].pk}) + response = self.client.get(url, **self.header) + self.assertEqual(response.status_code, 404) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_list_objects(self): + url = reverse('ipam-api:prefix-list') + + # Attempt to list objects without permission + response = self.client.get(url, **self.header) + self.assertEqual(response.status_code, 403) + + # Assign object permission + obj_perm = ObjectPermission( + model=ContentType.objects.get_for_model(Prefix), + attrs={ + 'site__name': 'Site 1', + }, + can_view=True + ) + obj_perm.save() + obj_perm.users.add(self.user) + + # Retrieve all objects. Only permitted objects should be returned. + response = self.client.get(url, **self.header) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['count'], 3) + + # TODO + # @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + # def test_create_object(self): + # url = reverse('ipam-api:prefix-list') + # data = { + # 'prefix': '10.0.9.0/24', + # 'site': self.sites[1].pk, + # } + # initial_count = Prefix.objects.count() + # + # # Attempt to create an object without permission + # response = self.client.post(url, data, format='json', **self.header) + # self.assertEqual(response.status_code, 403) + # + # # Assign object permission + # obj_perm = ObjectPermission( + # model=ContentType.objects.get_for_model(Prefix), + # attrs={'site__name': 'Site 1'}, + # can_view=True + # ) + # obj_perm.save() + # obj_perm.users.add(self.user) + # + # # Attempt to create a non-permitted object + # response = self.client.post(url, data, format='json', **self.header) + # self.assertEqual(response.status_code, 403) + # self.assertEqual(Prefix.objects.count(), initial_count) + # + # # Create a permitted object + # response = self.client.post(url, data, format='json', **self.header) + # self.assertEqual(response.status_code, 200) + # self.assertEqual(Prefix.objects.count(), initial_count + 1) diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 405c268787..9ec587369e 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -329,11 +329,15 @@ def initial(self, request, *args, **kwargs): if not request.user.is_authenticated or request.user.is_superuser: return - permission_required = 'dcim.view_site' + # Determine the required permission + permission_required = "{}.view_{}".format( + self.queryset.model._meta.app_label, + self.queryset.model._meta.model_name + ) # Enforce object-level permissions - if permission_required not in self.request.user._perm_cache: - attrs = ObjectPermission.objects.get_attr_constraints(self.request.user, permission_required) + if permission_required not in {*request.user._user_perm_cache, *request.user._group_perm_cache}: + attrs = ObjectPermission.objects.get_attr_constraints(request.user, permission_required) if attrs: # Update the view's QuerySet to filter only the permitted objects self.queryset = self.queryset.filter(attrs) From fa8407371bc10e1739009d537978c4ed1c80a375 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 20 May 2020 16:56:40 -0400 Subject: [PATCH 015/101] Swap position of REMOTE_AUTH_BACKEND --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index d265cc58cd..659eadb1c9 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -333,8 +333,8 @@ def _setting(name, default=None): # Set up authentication backends AUTHENTICATION_BACKENDS = [ - # REMOTE_AUTH_BACKEND, 'utilities.auth_backends.ObjectPermissionBackend', + REMOTE_AUTH_BACKEND, ] # Internationalization From a928d337d902ee72bd4a1e5127fde6c0e9c4694b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 May 2020 10:51:40 -0400 Subject: [PATCH 016/101] Add object permission support for create/update/delete API views --- netbox/netbox/tests/test_authentication.py | 127 +++++++++++++++------ netbox/utilities/api.py | 67 +++++++---- 2 files changed, 138 insertions(+), 56 deletions(-) diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 64dd83783b..03d0a1dc37 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -460,35 +460,98 @@ def test_list_objects(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.data['count'], 3) - # TODO - # @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - # def test_create_object(self): - # url = reverse('ipam-api:prefix-list') - # data = { - # 'prefix': '10.0.9.0/24', - # 'site': self.sites[1].pk, - # } - # initial_count = Prefix.objects.count() - # - # # Attempt to create an object without permission - # response = self.client.post(url, data, format='json', **self.header) - # self.assertEqual(response.status_code, 403) - # - # # Assign object permission - # obj_perm = ObjectPermission( - # model=ContentType.objects.get_for_model(Prefix), - # attrs={'site__name': 'Site 1'}, - # can_view=True - # ) - # obj_perm.save() - # obj_perm.users.add(self.user) - # - # # Attempt to create a non-permitted object - # response = self.client.post(url, data, format='json', **self.header) - # self.assertEqual(response.status_code, 403) - # self.assertEqual(Prefix.objects.count(), initial_count) - # - # # Create a permitted object - # response = self.client.post(url, data, format='json', **self.header) - # self.assertEqual(response.status_code, 200) - # self.assertEqual(Prefix.objects.count(), initial_count + 1) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_create_object(self): + url = reverse('ipam-api:prefix-list') + data = { + 'prefix': '10.0.9.0/24', + 'site': self.sites[1].pk, + } + initial_count = Prefix.objects.count() + + # Attempt to create an object without permission + response = self.client.post(url, data, format='json', **self.header) + self.assertEqual(response.status_code, 403) + + # Assign object permission + obj_perm = ObjectPermission( + model=ContentType.objects.get_for_model(Prefix), + attrs={'site__name': 'Site 1'}, + can_add=True + ) + obj_perm.save() + obj_perm.users.add(self.user) + + # Attempt to create a non-permitted object + response = self.client.post(url, data, format='json', **self.header) + self.assertEqual(response.status_code, 403) + self.assertEqual(Prefix.objects.count(), initial_count) + + # Create a permitted object + data['site'] = self.sites[0].pk + response = self.client.post(url, data, format='json', **self.header) + self.assertEqual(response.status_code, 201) + self.assertEqual(Prefix.objects.count(), initial_count + 1) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_edit_object(self): + + # Attempt to edit an object without permission + data = {'site': self.sites[0].pk} + url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk}) + response = self.client.patch(url, data, format='json', **self.header) + self.assertEqual(response.status_code, 403) + + # Assign object permission + obj_perm = ObjectPermission( + model=ContentType.objects.get_for_model(Prefix), + attrs={'site__name': 'Site 1'}, + can_change=True + ) + obj_perm.save() + obj_perm.users.add(self.user) + + # Attempt to edit a non-permitted object + data = {'site': self.sites[0].pk} + url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[3].pk}) + response = self.client.patch(url, data, format='json', **self.header) + self.assertEqual(response.status_code, 404) + + # Edit a permitted object + data['status'] = 'reserved' + url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk}) + response = self.client.patch(url, data, format='json', **self.header) + self.assertEqual(response.status_code, 200) + + # Attempt to modify a permitted object to a non-permitted object + data['site'] = self.sites[1].pk + url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk}) + response = self.client.patch(url, data, format='json', **self.header) + self.assertEqual(response.status_code, 403) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_delete_object(self): + + # Attempt to delete an object without permission + url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk}) + response = self.client.delete(url, format='json', **self.header) + self.assertEqual(response.status_code, 403) + + # Assign object permission + obj_perm = ObjectPermission( + model=ContentType.objects.get_for_model(Prefix), + attrs={'site__name': 'Site 1'}, + can_delete=True + ) + obj_perm.save() + obj_perm.users.add(self.user) + + # Attempt to delete a non-permitted object + url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[3].pk}) + response = self.client.delete(url, format='json', **self.header) + self.assertEqual(response.status_code, 404) + + # Delete a permitted object + url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk}) + response = self.client.delete(url, format='json', **self.header) + self.assertEqual(response.status_code, 204) diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 9ec587369e..745f812ff6 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -4,7 +4,8 @@ import pytz from django.conf import settings from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist +from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist, PermissionDenied +from django.db import transaction from django.db.models import ManyToManyField, ProtectedError from django.urls import reverse from rest_framework.exceptions import APIException @@ -14,6 +15,7 @@ from rest_framework.serializers import Field, ModelSerializer, ValidationError from rest_framework.viewsets import ModelViewSet as _ModelViewSet +from netbox.api import TokenPermissions from users.models import ObjectPermission from .utils import dict_to_filter_params, dynamic_import @@ -329,11 +331,13 @@ def initial(self, request, *args, **kwargs): if not request.user.is_authenticated or request.user.is_superuser: return - # Determine the required permission - permission_required = "{}.view_{}".format( - self.queryset.model._meta.app_label, - self.queryset.model._meta.model_name - ) + # TODO: Move this to a cleaner function + # Determine the required permission based on the request method + kwargs = { + 'app_label': self.queryset.model._meta.app_label, + 'model_name': self.queryset.model._meta.model_name + } + permission_required = TokenPermissions.perms_map[request.method][0] % kwargs # Enforce object-level permissions if permission_required not in {*request.user._user_perm_cache, *request.user._group_perm_cache}: @@ -361,34 +365,49 @@ def dispatch(self, request, *args, **kwargs): **kwargs ) - def list(self, *args, **kwargs): - """ - Call to super to allow for caching - """ - return super().list(*args, **kwargs) - - def retrieve(self, *args, **kwargs): + def _validate_objects(self, instance): """ - Call to super to allow for caching + Check that the provided instance or list of instances are matched by the current queryset. This confirms that + any newly created or modified objects abide by the attributes granted by any applicable ObjectPermissions. """ - return super().retrieve(*args, **kwargs) - - # - # Logging - # + if type(instance) is list: + # Check that all instances are still included in the view's queryset + conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count() + if conforming_count != len(instance): + raise ObjectDoesNotExist + else: + # Check that the instance is matched by the view's queryset + self.queryset.get(pk=instance.pk) def perform_create(self, serializer): - model = serializer.child.Meta.model if hasattr(serializer, 'many') else serializer.Meta.model + model = self.queryset.model logger = logging.getLogger('netbox.api.views.ModelViewSet') logger.info(f"Creating new {model._meta.verbose_name}") - return super().perform_create(serializer) + + # Enforce object-level permissions on save() + try: + with transaction.atomic(): + instance = serializer.save() + self._validate_objects(instance) + except ObjectDoesNotExist: + raise PermissionDenied() def perform_update(self, serializer): + model = self.queryset.model logger = logging.getLogger('netbox.api.views.ModelViewSet') - logger.info(f"Updating {serializer.instance} (PK: {serializer.instance.pk})") - return super().perform_update(serializer) + logger.info(f"Updating {model._meta.verbose_name} {serializer.instance} (PK: {serializer.instance.pk})") + + # Enforce object-level permissions on save() + try: + with transaction.atomic(): + instance = serializer.save() + self._validate_objects(instance) + except ObjectDoesNotExist: + raise PermissionDenied() def perform_destroy(self, instance): + model = self.queryset.model logger = logging.getLogger('netbox.api.views.ModelViewSet') - logger.info(f"Deleting {instance} (PK: {instance.pk})") + logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})") + return super().perform_destroy(instance) From 5486cff4410c2a86ab0f20e2fae781a5e3ecee1a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 May 2020 11:49:50 -0400 Subject: [PATCH 017/101] Add object permission support, tests for bulk import/edit/delete views --- netbox/ipam/views.py | 7 +- netbox/netbox/tests/test_authentication.py | 149 +++++++++++++++++++++ netbox/utilities/views.py | 41 ++++-- 3 files changed, 183 insertions(+), 14 deletions(-) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 0c7d0770fa..ace85bc1a5 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -605,14 +605,15 @@ class PrefixDeleteView(ObjectPermissionRequiredMixin, ObjectDeleteView): default_return_url = 'ipam:prefix_list' -class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView): +class PrefixBulkImportView(ObjectPermissionRequiredMixin, BulkImportView): permission_required = 'ipam.add_prefix' + queryset = Prefix.objects.all() model_form = forms.PrefixCSVForm table = tables.PrefixTable default_return_url = 'ipam:prefix_list' -class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView): +class PrefixBulkEditView(ObjectPermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_prefix' queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') filterset = filters.PrefixFilterSet @@ -621,7 +622,7 @@ class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView): default_return_url = 'ipam:prefix_list' -class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): +class PrefixBulkDeleteView(ObjectPermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_prefix' queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') filterset = filters.PrefixFilterSet diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 03d0a1dc37..d82ef67529 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -342,6 +342,14 @@ def test_delete_object(self): 'confirm': True } + # Attempt to delete object without permission + request = { + 'path': reverse('ipam:prefix_delete', kwargs={'pk': self.prefixes[0].pk}), + 'data': form_data, + } + response = self.client.post(**request) + self.assertHttpStatus(response, 403) + # Assign object permission obj_perm = ObjectPermission( model=ContentType.objects.get_for_model(Prefix), @@ -372,6 +380,147 @@ def test_delete_object(self): self.assertHttpStatus(response, 404) self.assertTrue(Prefix.objects.filter(pk=self.prefixes[3].pk).exists()) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_bulk_import_objects(self): + initial_count = Prefix.objects.count() + form_data = { + 'csv': "prefix,status,site\n" + "10.0.9.0/24,Active,Site 1\n" + "10.0.10.0/24,Active,Site 2\n" + "10.0.11.0/24,Active,Site 3\n", + } + + # Attempt to import objects without permission + request = { + 'path': reverse('ipam:prefix_import'), + 'data': form_data, + } + response = self.client.post(**request) + self.assertHttpStatus(response, 403) + self.assertEqual(initial_count, Prefix.objects.count()) + + # Assign object permission + obj_perm = ObjectPermission( + model=ContentType.objects.get_for_model(Prefix), + attrs={'site__name': 'Site 1'}, + can_add=True + ) + obj_perm.save() + obj_perm.users.add(self.user) + + # Attempt to create non-permitted objects + request = { + 'path': reverse('ipam:prefix_import'), + 'data': form_data, + } + response = self.client.post(**request) + self.assertHttpStatus(response, 200) + self.assertEqual(Prefix.objects.count(), initial_count) + + # Create a permitted object + form_data = { + 'csv': "prefix,status,site\n" + "10.0.9.0/24,Active,Site 1\n" + "10.0.10.0/24,Active,Site 1\n" + "10.0.11.0/24,Active,Site 1\n", + } + request = { + 'path': reverse('ipam:prefix_import'), + 'data': form_data, + } + response = self.client.post(**request) + self.assertHttpStatus(response, 200) + self.assertEqual(Prefix.objects.count(), initial_count + 3) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_bulk_edit_objects(self): + form_data = { + 'pk': [p.pk for p in self.prefixes], + 'status': 'reserved', + '_apply': True, + } + + # Attempt to edit objects without permission + request = { + 'path': reverse('ipam:prefix_bulk_edit'), + 'data': form_data, + } + response = self.client.post(**request) + self.assertHttpStatus(response, 403) + + # Assign object permission + obj_perm = ObjectPermission( + model=ContentType.objects.get_for_model(Prefix), + attrs={'site__name': 'Site 1'}, + can_change=True + ) + obj_perm.save() + obj_perm.users.add(self.user) + + # Attempt to edit non-permitted objects + request = { + 'path': reverse('ipam:prefix_bulk_edit'), + 'data': form_data, + } + response = self.client.post(**request) + self.assertHttpStatus(response, 200) + self.assertEqual(Prefix.objects.get(pk=self.prefixes[3].pk).status, 'active') + + # Edit permitted objects + form_data['pk'] = [p.pk for p in self.prefixes[:3]] + request = { + 'path': reverse('ipam:prefix_bulk_edit'), + 'data': form_data, + } + response = self.client.post(**request) + self.assertHttpStatus(response, 302) + self.assertEqual(Prefix.objects.get(pk=self.prefixes[0].pk).status, 'reserved') + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_bulk_delete_objects(self): + form_data = { + 'pk': [p.pk for p in self.prefixes], + 'confirm': True, + '_confirm': True, + } + + # Attempt to delete objects without permission + request = { + 'path': reverse('ipam:prefix_bulk_delete'), + 'data': form_data, + } + response = self.client.post(**request) + self.assertHttpStatus(response, 403) + + # Assign object permission + obj_perm = ObjectPermission( + model=ContentType.objects.get_for_model(Prefix), + attrs={'site__name': 'Site 1'}, + can_view=True, + can_delete=True + ) + obj_perm.save() + obj_perm.users.add(self.user) + + # Attempt to delete non-permitted object + request = { + 'path': reverse('ipam:prefix_bulk_delete'), + 'data': form_data, + } + response = self.client.post(**request) + self.assertHttpStatus(response, 200) + self.assertTrue(Prefix.objects.filter(pk=self.prefixes[3].pk).exists()) + + # Delete permitted objects + form_data['pk'] = [p.pk for p in self.prefixes[:3]] + request = { + 'path': reverse('ipam:prefix_bulk_delete'), + 'data': form_data, + } + response = self.client.post(**request) + self.assertHttpStatus(response, 302) + self.assertFalse(Prefix.objects.filter(pk=self.prefixes[0].pk).exists()) + class ObjectPermissionAPIViewTestCase(TestCase): client_class = APIClient diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index d9eace90b5..44dd40d903 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -297,9 +297,9 @@ def post(self, request, *args, **kwargs): 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") + msg = "Object save failed due to object-level permissions violation" + logger.debug(msg) + form.add_error(None, msg) else: logger.debug("Form validation failed") @@ -576,11 +576,13 @@ class BulkImportView(GetReturnURLMixin, View): """ Import objects in bulk (CSV format). - model_form: The form used to create each imported object - table: The django-tables2 Table used to render the list of imported objects - template_name: The name of the template - widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key) + :param queryset: Base queryset for the model + :param model_form: The form used to create each imported object + :param table: The django-tables2 Table used to render the list of imported objects + :param template_name: The name of the template + :param widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key) """ + queryset = None model_form = None table = None template_name = 'utilities/obj_bulk_import.html' @@ -634,6 +636,10 @@ def post(self, request): form.add_error('csv', "Row {} {}: {}".format(row, field, err[0])) raise ValidationError("") + # Enforce object-level permissions + if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs): + raise ObjectDoesNotExist + # Compile a table containing the imported objects obj_table = self.table(new_objs) @@ -650,6 +656,11 @@ def post(self, request): except ValidationError: pass + except ObjectDoesNotExist: + msg = "Object import failed due to object-level permissions violation" + logger.debug(msg) + form.add_error(None, msg) + else: logger.debug("Form validation failed") @@ -707,7 +718,7 @@ def post(self, request, **kwargs): with transaction.atomic(): - updated_count = 0 + updated_objects = [] for obj in model.objects.filter(pk__in=form.cleaned_data['pk']): # Update standard fields. If a field is listed in _nullify, delete its value. @@ -736,6 +747,7 @@ def post(self, request, **kwargs): obj.full_clean() obj.save() + updated_objects.append(obj) logger.debug(f"Saved {obj} (PK: {obj.pk})") # Update custom fields @@ -765,10 +777,12 @@ def post(self, request, **kwargs): if form.cleaned_data.get('remove_tags', None): obj.tags.remove(*form.cleaned_data['remove_tags']) - updated_count += 1 + # Enforce object-level permissions + if self.queryset.filter(pk__in=[obj.pk for obj in updated_objects]).count() != len(updated_objects): + raise ObjectDoesNotExist - if updated_count: - msg = 'Updated {} {}'.format(updated_count, model._meta.verbose_name_plural) + if updated_objects: + msg = 'Updated {} {}'.format(len(updated_objects), model._meta.verbose_name_plural) logger.info(msg) messages.success(self.request, msg) @@ -777,6 +791,11 @@ def post(self, request, **kwargs): except ValidationError as e: messages.error(self.request, "{} failed validation: {}".format(obj, e)) + except ObjectDoesNotExist: + msg = "Object update failed due to object-level permissions violation" + logger.debug(msg) + form.add_error(None, msg) + else: logger.debug("Form validation failed") From 40c590f44535f663f6b314b9e366b01fff9bcd8e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 May 2020 11:58:27 -0400 Subject: [PATCH 018/101] Add queryset to all BulkImportViews --- netbox/circuits/views.py | 3 +++ netbox/dcim/views.py | 23 +++++++++++++++++++++++ netbox/ipam/views.py | 8 ++++++++ netbox/secrets/views.py | 2 ++ netbox/tenancy/views.py | 2 ++ netbox/utilities/views.py | 20 ++++++++++---------- netbox/virtualization/views.py | 4 ++++ 7 files changed, 52 insertions(+), 10 deletions(-) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 0546b38327..c3b09f596e 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -80,6 +80,7 @@ class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView): class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'circuits.add_provider' + queryset = Provider.objects.all() model_form = forms.ProviderCSVForm table = tables.ProviderTable default_return_url = 'circuits:provider_list' @@ -125,6 +126,7 @@ class CircuitTypeEditView(CircuitTypeCreateView): class CircuitTypeBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'circuits.add_circuittype' + queryset = CircuitType.objects.all() model_form = forms.CircuitTypeCSVForm table = tables.CircuitTypeTable default_return_url = 'circuits:circuittype_list' @@ -196,6 +198,7 @@ class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView): class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'circuits.add_circuit' + queryset = Circuit.objects.all() model_form = forms.CircuitCSVForm table = tables.CircuitTable default_return_url = 'circuits:circuit_list' diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 03e375d351..d6b97e128a 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -169,6 +169,7 @@ class RegionEditView(RegionCreateView): class RegionBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_region' + queryset = Region.objects.all() model_form = forms.RegionCSVForm table = tables.RegionTable default_return_url = 'dcim:region_list' @@ -240,6 +241,7 @@ class SiteDeleteView(ObjectPermissionRequiredMixin, ObjectDeleteView): class SiteBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_site' + queryset = Site.objects.all() model_form = forms.SiteCSVForm table = tables.SiteTable default_return_url = 'dcim:site_list' @@ -293,6 +295,7 @@ class RackGroupEditView(RackGroupCreateView): class RackGroupBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_rackgroup' + queryset = RackGroup.objects.all() model_form = forms.RackGroupCSVForm table = tables.RackGroupTable default_return_url = 'dcim:rackgroup_list' @@ -329,6 +332,7 @@ class RackRoleEditView(RackRoleCreateView): class RackRoleBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_rackrole' + queryset = RackRole.objects.all() model_form = forms.RackRoleCSVForm table = tables.RackRoleTable default_return_url = 'dcim:rackrole_list' @@ -446,6 +450,7 @@ class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView): class RackBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_rack' + queryset = Rack.objects.all() model_form = forms.RackCSVForm table = tables.RackTable default_return_url = 'dcim:rack_list' @@ -520,6 +525,7 @@ class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView): class RackReservationImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_rackreservation' + queryset = RackReservation.objects.all() model_form = forms.RackReservationCSVForm table = tables.RackReservationTable default_return_url = 'dcim:rackreservation_list' @@ -579,6 +585,7 @@ class ManufacturerEditView(ManufacturerCreateView): class ManufacturerBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_manufacturer' + queryset = Manufacturer.objects.all() model_form = forms.ManufacturerCSVForm table = tables.ManufacturerTable default_return_url = 'dcim:manufacturer_list' @@ -1039,6 +1046,7 @@ class DeviceRoleEditView(DeviceRoleCreateView): class DeviceRoleBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_devicerole' + queryset = DeviceRole.objects.all() model_form = forms.DeviceRoleCSVForm table = tables.DeviceRoleTable default_return_url = 'dcim:devicerole_list' @@ -1074,6 +1082,7 @@ class PlatformEditView(PlatformCreateView): class PlatformBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_platform' + queryset = Platform.objects.all() model_form = forms.PlatformCSVForm table = tables.PlatformTable default_return_url = 'dcim:platform_list' @@ -1267,6 +1276,7 @@ class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView): class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_device' + queryset = Device.objects.all() model_form = forms.DeviceCSVForm table = tables.DeviceImportTable template_name = 'dcim/device_import.html' @@ -1275,6 +1285,7 @@ class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView): class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_device' + queryset = Device.objects.all() model_form = forms.ChildDeviceCSVForm table = tables.DeviceImportTable template_name = 'dcim/device_import_child.html' @@ -1343,6 +1354,7 @@ class ConsolePortDeleteView(PermissionRequiredMixin, ObjectDeleteView): class ConsolePortBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_consoleport' + queryset = ConsolePort.objects.all() model_form = forms.ConsolePortCSVForm table = tables.ConsolePortImportTable default_return_url = 'dcim:consoleport_list' @@ -1398,6 +1410,7 @@ class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): class ConsoleServerPortBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_consoleserverport' + queryset = ConsoleServerPort.objects.all() model_form = forms.ConsoleServerPortCSVForm table = tables.ConsoleServerPortImportTable default_return_url = 'dcim:consoleserverport_list' @@ -1465,6 +1478,7 @@ class PowerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): class PowerPortBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_powerport' + queryset = PowerPort.objects.all() model_form = forms.PowerPortCSVForm table = tables.PowerPortImportTable default_return_url = 'dcim:powerport_list' @@ -1520,6 +1534,7 @@ class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView): class PowerOutletBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_poweroutlet' + queryset = PowerOutlet.objects.all() model_form = forms.PowerOutletCSVForm table = tables.PowerOutletImportTable default_return_url = 'dcim:poweroutlet_list' @@ -1624,6 +1639,7 @@ class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): class InterfaceBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_interface' + queryset = Interface.objects.all() model_form = forms.InterfaceCSVForm table = tables.InterfaceImportTable default_return_url = 'dcim:interface_list' @@ -1691,6 +1707,7 @@ class FrontPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): class FrontPortBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_frontport' + queryset = FrontPort.objects.all() model_form = forms.FrontPortCSVForm table = tables.FrontPortImportTable default_return_url = 'dcim:frontport_list' @@ -1758,6 +1775,7 @@ class RearPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): class RearPortBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_rearport' + queryset = RearPort.objects.all() model_form = forms.RearPortCSVForm table = tables.RearPortImportTable default_return_url = 'dcim:rearport_list' @@ -1896,6 +1914,7 @@ def post(self, request, pk): class DeviceBayBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_devicebay' + queryset = DeviceBay.objects.all() model_form = forms.DeviceBayCSVForm table = tables.DeviceBayImportTable default_return_url = 'dcim:devicebay_list' @@ -2170,6 +2189,7 @@ class CableDeleteView(PermissionRequiredMixin, ObjectDeleteView): class CableBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_cable' + queryset = Cable.objects.all() model_form = forms.CableCSVForm table = tables.CableTable default_return_url = 'dcim:cable_list' @@ -2330,6 +2350,7 @@ class InventoryItemDeleteView(PermissionRequiredMixin, ObjectDeleteView): class InventoryItemBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_inventoryitem' + queryset = InventoryItem.objects.all() model_form = forms.InventoryItemCSVForm table = tables.InventoryItemTable default_return_url = 'dcim:inventoryitem_list' @@ -2673,6 +2694,7 @@ class PowerPanelDeleteView(PermissionRequiredMixin, ObjectDeleteView): class PowerPanelBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_powerpanel' + queryset = PowerPanel.objects.all() model_form = forms.PowerPanelCSVForm table = tables.PowerPanelTable default_return_url = 'dcim:powerpanel_list' @@ -2745,6 +2767,7 @@ class PowerFeedDeleteView(PermissionRequiredMixin, ObjectDeleteView): class PowerFeedBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_powerfeed' + queryset = PowerFeed.objects.all() model_form = forms.PowerFeedCSVForm table = tables.PowerFeedTable default_return_url = 'dcim:powerfeed_list' diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index ace85bc1a5..ab97afc2ab 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -155,6 +155,7 @@ class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView): class VRFBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'ipam.add_vrf' + queryset = VRF.objects.all() model_form = forms.VRFCSVForm table = tables.VRFTable default_return_url = 'ipam:vrf_list' @@ -271,6 +272,7 @@ class RIREditView(RIRCreateView): class RIRBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'ipam.add_rir' + queryset = RIR.objects.all() model_form = forms.RIRCSVForm table = tables.RIRTable default_return_url = 'ipam:rir_list' @@ -380,6 +382,7 @@ class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView): class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'ipam.add_aggregate' + queryset = Aggregate.objects.all() model_form = forms.AggregateCSVForm table = tables.AggregateTable default_return_url = 'ipam:aggregate_list' @@ -425,6 +428,7 @@ class RoleEditView(RoleCreateView): class RoleBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'ipam.add_role' + queryset = Role.objects.all() model_form = forms.RoleCSVForm table = tables.RoleTable default_return_url = 'ipam:role_list' @@ -782,6 +786,7 @@ class IPAddressBulkCreateView(PermissionRequiredMixin, BulkCreateView): class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'ipam.add_ipaddress' + queryset = IPAddress.objects.all() model_form = forms.IPAddressCSVForm table = tables.IPAddressTable default_return_url = 'ipam:ipaddress_list' @@ -829,6 +834,7 @@ class VLANGroupEditView(VLANGroupCreateView): class VLANGroupBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'ipam.add_vlangroup' + queryset = VLANGroup.objects.all() model_form = forms.VLANGroupCSVForm table = tables.VLANGroupTable default_return_url = 'ipam:vlangroup_list' @@ -952,6 +958,7 @@ class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView): class VLANBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'ipam.add_vlan' + queryset = VLAN.objects.all() model_form = forms.VLANCSVForm table = tables.VLANTable default_return_url = 'ipam:vlan_list' @@ -1018,6 +1025,7 @@ def get_return_url(self, request, service): class ServiceBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'ipam.add_service' + queryset = Service.objects.all() model_form = forms.ServiceCSVForm table = tables.ServiceTable default_return_url = 'ipam:service_list' diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index b40e41cb36..8ce9addb46 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -49,6 +49,7 @@ class SecretRoleEditView(SecretRoleCreateView): class SecretRoleBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'secrets.add_secretrole' + queryset = SecretRole.objects.all() model_form = forms.SecretRoleCSVForm table = tables.SecretRoleTable default_return_url = 'secrets:secretrole_list' @@ -197,6 +198,7 @@ class SecretDeleteView(PermissionRequiredMixin, ObjectDeleteView): class SecretBulkImportView(BulkImportView): permission_required = 'secrets.add_secret' + queryset = Secret.objects.all() model_form = forms.SecretCSVForm table = tables.SecretTable template_name = 'secrets/secret_import.html' diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 2af44094f9..745362271f 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -43,6 +43,7 @@ class TenantGroupEditView(TenantGroupCreateView): class TenantGroupBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'tenancy.add_tenantgroup' + queryset = TenantGroup.objects.all() model_form = forms.TenantGroupCSVForm table = tables.TenantGroupTable default_return_url = 'tenancy:tenantgroup_list' @@ -113,6 +114,7 @@ class TenantDeleteView(PermissionRequiredMixin, ObjectDeleteView): class TenantBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'tenancy.add_tenant' + queryset = Tenant.objects.all() model_form = forms.TenantCSVForm table = tables.TenantTable default_return_url = 'tenancy:tenant_list' diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 44dd40d903..01eb6d2ba9 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -676,11 +676,11 @@ class BulkEditView(GetReturnURLMixin, View): """ Edit objects in bulk. - queryset: Custom queryset to use when retrieving objects (e.g. to select related objects) - filter: FilterSet to apply when deleting by QuerySet - table: The table used to display devices being edited - form: The form class used to edit objects in bulk - template_name: The name of the template + :param queryset: Custom queryset to use when retrieving objects (e.g. to select related objects) + :param filter: FilterSet to apply when deleting by QuerySet + :param table: The table used to display devices being edited + :param form: The form class used to edit objects in bulk + :param template_name: The name of the template """ queryset = None filterset = None @@ -829,11 +829,11 @@ class BulkDeleteView(GetReturnURLMixin, View): """ Delete objects in bulk. - queryset: Custom queryset to use when retrieving objects (e.g. to select related objects) - filter: FilterSet to apply when deleting by QuerySet - table: The table used to display devices being deleted - form: The form class used to delete objects in bulk - template_name: The name of the template + :param queryset: Custom queryset to use when retrieving objects (e.g. to select related objects) + :param filter: FilterSet to apply when deleting by QuerySet + :param table: The table used to display devices being deleted + :param form: The form class used to delete objects in bulk + :param template_name: The name of the template """ queryset = None filterset = None diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 68a2443ae8..c6f107be7e 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -41,6 +41,7 @@ class ClusterTypeEditView(ClusterTypeCreateView): class ClusterTypeBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'virtualization.add_clustertype' + queryset = ClusterType.objects.all() model_form = forms.ClusterTypeCSVForm table = tables.ClusterTypeTable default_return_url = 'virtualization:clustertype_list' @@ -76,6 +77,7 @@ class ClusterGroupEditView(ClusterGroupCreateView): class ClusterGroupBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'virtualization.add_clustergroup' + queryset = ClusterGroup.objects.all() model_form = forms.ClusterGroupCSVForm table = tables.ClusterGroupTable default_return_url = 'virtualization:clustergroup_list' @@ -138,6 +140,7 @@ class ClusterDeleteView(PermissionRequiredMixin, ObjectDeleteView): class ClusterBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'virtualization.add_cluster' + queryset = Cluster.objects.all() model_form = forms.ClusterCSVForm table = tables.ClusterTable default_return_url = 'virtualization:cluster_list' @@ -299,6 +302,7 @@ class VirtualMachineDeleteView(PermissionRequiredMixin, ObjectDeleteView): class VirtualMachineBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'virtualization.add_virtualmachine' + queryset = VirtualMachine.objects.all() model_form = forms.VirtualMachineCSVForm table = tables.VirtualMachineTable default_return_url = 'virtualization:virtualmachine_list' From cc6e74dfd53b9d8fc3c5937055c9e90fcaa05275 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 May 2020 13:12:15 -0400 Subject: [PATCH 019/101] Move ObjectPermissionRequiredMixin to utilities.views --- netbox/dcim/views.py | 3 +- netbox/ipam/views.py | 2 +- netbox/netbox/authentication.py | 55 ----------------------------- netbox/utilities/permissions.py | 15 ++++++++ netbox/utilities/views.py | 62 ++++++++++++++++++++++++++++++++- 5 files changed, 78 insertions(+), 59 deletions(-) delete mode 100644 netbox/netbox/authentication.py create mode 100644 netbox/utilities/permissions.py diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index d6b97e128a..2bcf876c69 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -21,13 +21,12 @@ from extras.views import ObjectConfigContextView from ipam.models import Prefix, VLAN from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable -from netbox.authentication import ObjectPermissionRequiredMixin from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator from utilities.utils import csv_format from utilities.views import ( BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin, - ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView, + ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView, ObjectPermissionRequiredMixin, ) from virtualization.models import VirtualMachine from . import filters, forms, tables diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index ab97afc2ab..bb0844d4d9 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -8,10 +8,10 @@ from django_tables2 import RequestConfig from dcim.models import Device, Interface -from netbox.authentication import ObjectPermissionRequiredMixin from utilities.paginator import EnhancedPaginator from utilities.views import ( BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, + ObjectPermissionRequiredMixin, ) from virtualization.models import VirtualMachine from . import filters, forms, tables diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py deleted file mode 100644 index 2e68e6ef12..0000000000 --- a/netbox/netbox/authentication.py +++ /dev/null @@ -1,55 +0,0 @@ -from django.contrib.auth.mixins import AccessMixin -from django.core.exceptions import ImproperlyConfigured - -from users.models import ObjectPermission - - -class ObjectPermissionRequiredMixin(AccessMixin): - """ - Similar to Django's built-in PermissionRequiredMixin, but extended to check for both model-level and object-level - permission assignments. If the user has only object-level permissions assigned, the view's queryset is filtered - to return only those objects on which the user is permitted to perform the specified action. - """ - permission_required = None - - def has_permission(self): - user = self.request.user - - # First, check that the user is granted the required permission at either the model or object level. - if not user.has_perm(self.permission_required): - return False - - # Superusers implicitly have all permissions - if user.is_superuser: - return True - - # Determine whether the permission is model-level or object-level. Model-level permissions grant the - # specified action to *all* objects, so no further action is needed. - if self.permission_required in {*user._user_perm_cache, *user._group_perm_cache}: - return True - - # If the permission is granted only at the object level, filter the view's queryset to return only objects - # on which the user is permitted to perform the specified action. - attrs = ObjectPermission.objects.get_attr_constraints(user, self.permission_required) - if attrs: - # Update the view's QuerySet to filter only the permitted objects - self.queryset = self.queryset.filter(attrs) - return True - - def dispatch(self, request, *args, **kwargs): - if self.permission_required is None: - raise ImproperlyConfigured( - '{0} is missing the permission_required attribute. Define {0}.permission_required, or override ' - '{0}.get_permission_required().'.format(self.__class__.__name__) - ) - - if not hasattr(self, 'queryset'): - raise ImproperlyConfigured( - '{} has no queryset defined. ObjectPermissionRequiredMixin may only be used on views which define ' - 'a base queryset'.format(self.__class__.__name__) - ) - - if not self.has_permission(): - return self.handle_no_permission() - - return super().dispatch(request, *args, **kwargs) diff --git a/netbox/utilities/permissions.py b/netbox/utilities/permissions.py new file mode 100644 index 0000000000..516d6fe5b2 --- /dev/null +++ b/netbox/utilities/permissions.py @@ -0,0 +1,15 @@ +def get_permission_for_model(model, action): + """ + Resolve the named permission for a given model (or instance) and action (e.g. view or add). + + :param model: A model or instance + :param action: View, add, change, or delete (string) + """ + if action not in ('view', 'add', 'change', 'delete'): + raise ValueError(f"Unsupported action: {action}") + + return '{}.{}_{}'.format( + model._meta.app_label, + action, + model._meta.model_name + ) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 01eb6d2ba9..6097fa5b2b 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -4,7 +4,8 @@ from django.contrib import messages from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError +from django.contrib.auth.mixins import AccessMixin +from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured, ObjectDoesNotExist, ValidationError from django.db import transaction, IntegrityError from django.db.models import ManyToManyField, ProtectedError from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea @@ -32,6 +33,61 @@ from .paginator import EnhancedPaginator, get_paginate_count +# +# Mixins +# + +class ObjectPermissionRequiredMixin(AccessMixin): + """ + Similar to Django's built-in PermissionRequiredMixin, but extended to check for both model-level and object-level + permission assignments. If the user has only object-level permissions assigned, the view's queryset is filtered + to return only those objects on which the user is permitted to perform the specified action. + """ + permission_required = None + + def has_permission(self): + user = self.request.user + + # First, check that the user is granted the required permission at either the model or object level. + if not user.has_perm(self.permission_required): + return False + + # Superusers implicitly have all permissions + if user.is_superuser: + return True + + # Determine whether the permission is model-level or object-level. Model-level permissions grant the + # specified action to *all* objects, so no further action is needed. + if self.permission_required in {*user._user_perm_cache, *user._group_perm_cache}: + return True + + # If the permission is granted only at the object level, filter the view's queryset to return only objects + # on which the user is permitted to perform the specified action. + attrs = ObjectPermission.objects.get_attr_constraints(user, self.permission_required) + if attrs: + # Update the view's QuerySet to filter only the permitted objects + self.queryset = self.queryset.filter(attrs) + return True + + def dispatch(self, request, *args, **kwargs): + if self.permission_required is None: + raise ImproperlyConfigured( + '{0} is missing the permission_required attribute. Define {0}.permission_required, or override ' + '{0}.get_permission_required().'.format(self.__class__.__name__) + ) + + if not hasattr(self, 'queryset'): + raise ImproperlyConfigured( + '{} has no queryset defined. ObjectPermissionRequiredMixin may only be used on views which define ' + 'a base queryset'.format(self.__class__.__name__) + ) + + if not self.has_permission(): + return self.handle_no_permission() + + return super().dispatch(request, *args, **kwargs) + + class GetReturnURLMixin(object): """ Provides logic for determining where a user should be redirected after processing a form. @@ -58,6 +114,10 @@ def get_return_url(self, request, obj=None): return reverse('home') +# +# Generic views +# + class ObjectListView(View): """ List a series of objects. From 993ee8c900a45b433e20dee8d3751f42992bf7f8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 May 2020 13:22:09 -0400 Subject: [PATCH 020/101] Transition ObjectListView to use ObjectPermissionRequiredMixin --- netbox/circuits/views.py | 9 ++-- netbox/dcim/views.py | 79 ++++++++++++---------------------- netbox/extras/views.py | 9 ++-- netbox/ipam/views.py | 27 ++++-------- netbox/secrets/views.py | 6 +-- netbox/tenancy/views.py | 6 +-- netbox/utilities/views.py | 33 ++++++++------ netbox/virtualization/views.py | 12 ++---- 8 files changed, 69 insertions(+), 112 deletions(-) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index c3b09f596e..e3f3473987 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -23,8 +23,7 @@ # Providers # -class ProviderListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'circuits.view_provider' +class ProviderListView(ObjectListView): queryset = Provider.objects.annotate(count_circuits=Count('circuits')) filterset = filters.ProviderFilterSet filterset_form = forms.ProviderFilterForm @@ -107,8 +106,7 @@ class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Circuit Types # -class CircuitTypeListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'circuits.view_circuittype' +class CircuitTypeListView(ObjectListView): queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')) table = tables.CircuitTypeTable @@ -143,8 +141,7 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Circuits # -class CircuitListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'circuits.view_circuit' +class CircuitListView(ObjectListView): _terminations = CircuitTermination.objects.filter(circuit=OuterRef('pk')) queryset = Circuit.objects.prefetch_related( 'provider', 'type', 'tenant', 'terminations__site' diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 2bcf876c69..9faad490e3 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -141,8 +141,7 @@ def post(self, request): # Regions # -class RegionListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_region' +class RegionListView(ObjectListView): queryset = Region.objects.add_related_count( Region.objects.all(), Site, @@ -186,8 +185,7 @@ class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Sites # -class SiteListView(ObjectPermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_site' +class SiteListView(ObjectListView): queryset = Site.objects.prefetch_related('region', 'tenant') filterset = filters.SiteFilterSet filterset_form = forms.SiteFilterForm @@ -267,8 +265,7 @@ class SiteBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Rack groups # -class RackGroupListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_rackgroup' +class RackGroupListView(ObjectListView): queryset = RackGroup.objects.add_related_count( RackGroup.objects.all(), Rack, @@ -312,8 +309,7 @@ class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Rack roles # -class RackRoleListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_rackrole' +class RackRoleListView(ObjectListView): queryset = RackRole.objects.annotate(rack_count=Count('racks')) table = tables.RackRoleTable @@ -348,8 +344,7 @@ class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Racks # -class RackListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_rack' +class RackListView(ObjectListView): queryset = Rack.objects.prefetch_related( 'site', 'group', 'tenant', 'role', 'devices__device_type' ).annotate( @@ -476,8 +471,7 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Rack reservations # -class RackReservationListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_rackreservation' +class RackReservationListView(ObjectListView): queryset = RackReservation.objects.prefetch_related('rack__site') filterset = filters.RackReservationFilterSet filterset_form = forms.RackReservationFilterForm @@ -561,8 +555,7 @@ class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Manufacturers # -class ManufacturerListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_manufacturer' +class ManufacturerListView(ObjectListView): queryset = Manufacturer.objects.annotate( devicetype_count=Count('device_types', distinct=True), inventoryitem_count=Count('inventory_items', distinct=True), @@ -601,8 +594,7 @@ class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Device types # -class DeviceTypeListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_devicetype' +class DeviceTypeListView(ObjectListView): queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances')) filterset = filters.DeviceTypeFilterSet filterset_form = forms.DeviceTypeFilterForm @@ -1026,8 +1018,7 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Device roles # -class DeviceRoleListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_devicerole' +class DeviceRoleListView(ObjectListView): queryset = DeviceRole.objects.all() table = tables.DeviceRoleTable @@ -1062,8 +1053,7 @@ class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Platforms # -class PlatformListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_platform' +class PlatformListView(ObjectListView): queryset = Platform.objects.all() table = tables.PlatformTable @@ -1098,8 +1088,7 @@ class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Devices # -class DeviceListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_device' +class DeviceListView(ObjectListView): queryset = Device.objects.prefetch_related( 'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6' ) @@ -1323,8 +1312,7 @@ class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Console ports # -class ConsolePortListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_consoleport' +class ConsolePortListView(ObjectListView): queryset = ConsolePort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable') filterset = filters.ConsolePortFilterSet filterset_form = forms.ConsolePortFilterForm @@ -1379,8 +1367,7 @@ class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Console server ports # -class ConsoleServerPortListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_consoleserverport' +class ConsoleServerPortListView(ObjectListView): queryset = ConsoleServerPort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable') filterset = filters.ConsoleServerPortFilterSet filterset_form = forms.ConsoleServerPortFilterForm @@ -1447,8 +1434,7 @@ class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Power ports # -class PowerPortListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_powerport' +class PowerPortListView(ObjectListView): queryset = PowerPort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable') filterset = filters.PowerPortFilterSet filterset_form = forms.PowerPortFilterForm @@ -1503,8 +1489,7 @@ class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Power outlets # -class PowerOutletListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_poweroutlet' +class PowerOutletListView(ObjectListView): queryset = PowerOutlet.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable') filterset = filters.PowerOutletFilterSet filterset_form = forms.PowerOutletFilterForm @@ -1571,8 +1556,7 @@ class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Interfaces # -class InterfaceListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_interface' +class InterfaceListView(ObjectListView): queryset = Interface.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable') filterset = filters.InterfaceFilterSet filterset_form = forms.InterfaceFilterForm @@ -1676,8 +1660,7 @@ class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Front ports # -class FrontPortListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_frontport' +class FrontPortListView(ObjectListView): queryset = FrontPort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable') filterset = filters.FrontPortFilterSet filterset_form = forms.FrontPortFilterForm @@ -1744,8 +1727,7 @@ class FrontPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Rear ports # -class RearPortListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_rearport' +class RearPortListView(ObjectListView): queryset = RearPort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable') filterset = filters.RearPortFilterSet filterset_form = forms.RearPortFilterForm @@ -1812,8 +1794,7 @@ class RearPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Device bays # -class DeviceBayListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_devicebay' +class DeviceBayListView(ObjectListView): queryset = DeviceBay.objects.prefetch_related( 'device', 'device__site', 'installed_device', 'installed_device__site' ) @@ -2045,8 +2026,7 @@ class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateVie # Cables # -class CableListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_cable' +class CableListView(ObjectListView): queryset = Cable.objects.prefetch_related( 'termination_a', 'termination_b' ) @@ -2215,7 +2195,7 @@ class CableBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Connections # -class ConsoleConnectionsListView(PermissionRequiredMixin, ObjectListView): +class ConsoleConnectionsListView(ObjectListView): permission_required = ('dcim.view_consoleport', 'dcim.view_consoleserverport') queryset = ConsolePort.objects.prefetch_related( 'device', 'connected_endpoint__device' @@ -2247,7 +2227,7 @@ def queryset_to_csv(self): return '\n'.join(csv_data) -class PowerConnectionsListView(PermissionRequiredMixin, ObjectListView): +class PowerConnectionsListView(ObjectListView): permission_required = ('dcim.view_powerport', 'dcim.view_poweroutlet') queryset = PowerPort.objects.prefetch_related( 'device', '_connected_poweroutlet__device' @@ -2279,8 +2259,7 @@ def queryset_to_csv(self): return '\n'.join(csv_data) -class InterfaceConnectionsListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_interface' +class InterfaceConnectionsListView(ObjectListView): queryset = Interface.objects.prefetch_related( 'device', 'cable', '_connected_interface__device' ).filter( @@ -2319,8 +2298,7 @@ def queryset_to_csv(self): # Inventory items # -class InventoryItemListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_inventoryitem' +class InventoryItemListView(ObjectListView): queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer') filterset = filters.InventoryItemFilterSet filterset_form = forms.InventoryItemFilterForm @@ -2376,8 +2354,7 @@ class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Virtual chassis # -class VirtualChassisListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_virtualchassis' +class VirtualChassisListView(ObjectListView): queryset = VirtualChassis.objects.prefetch_related('master').annotate(member_count=Count('members')) table = tables.VirtualChassisTable filterset = filters.VirtualChassisFilterSet @@ -2644,8 +2621,7 @@ class VirtualChassisBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Power panels # -class PowerPanelListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_powerpanel' +class PowerPanelListView(ObjectListView): queryset = PowerPanel.objects.prefetch_related( 'site', 'rack_group' ).annotate( @@ -2724,8 +2700,7 @@ class PowerPanelBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Power feeds # -class PowerFeedListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_powerfeed' +class PowerFeedListView(ObjectListView): queryset = PowerFeed.objects.prefetch_related( 'power_panel', 'rack' ) diff --git a/netbox/extras/views.py b/netbox/extras/views.py index e466414b64..c1bee4dd70 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -25,8 +25,7 @@ # Tags # -class TagListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'extras.view_tag' +class TagListView(ObjectListView): queryset = Tag.objects.annotate( items=Count('extras_taggeditem_items', distinct=True) ).order_by( @@ -106,8 +105,7 @@ class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Config contexts # -class ConfigContextListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'extras.view_configcontext' +class ConfigContextListView(ObjectListView): queryset = ConfigContext.objects.all() filterset = filters.ConfigContextFilterSet filterset_form = forms.ConfigContextFilterForm @@ -200,8 +198,7 @@ def get(self, request, pk): # Change logging # -class ObjectChangeListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'extras.view_objectchange' +class ObjectChangeListView(ObjectListView): queryset = ObjectChange.objects.prefetch_related('user', 'changed_object_type') filterset = filters.ObjectChangeFilterSet filterset_form = forms.ObjectChangeFilterForm diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index bb0844d4d9..09c3f78924 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -113,8 +113,7 @@ def add_available_vlans(vlan_group, vlans): # VRFs # -class VRFListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'ipam.view_vrf' +class VRFListView(ObjectListView): queryset = VRF.objects.prefetch_related('tenant') filterset = filters.VRFFilterSet filterset_form = forms.VRFFilterForm @@ -182,8 +181,7 @@ class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # RIRs # -class RIRListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'ipam.view_rir' +class RIRListView(ObjectListView): queryset = RIR.objects.annotate(aggregate_count=Count('aggregates')) filterset = filters.RIRFilterSet filterset_form = forms.RIRFilterForm @@ -290,8 +288,7 @@ class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Aggregates # -class AggregateListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'ipam.view_aggregate' +class AggregateListView(ObjectListView): queryset = Aggregate.objects.prefetch_related('rir').annotate( child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ()) ) @@ -409,8 +406,7 @@ class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Prefix/VLAN roles # -class RoleListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'ipam.view_role' +class RoleListView(ObjectListView): queryset = Role.objects.all() table = tables.RoleTable @@ -445,8 +441,7 @@ class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Prefixes # -class PrefixListView(ObjectPermissionRequiredMixin, ObjectListView): - permission_required = 'ipam.view_prefix' +class PrefixListView(ObjectListView): queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') filterset = filters.PrefixFilterSet filterset_form = forms.PrefixFilterForm @@ -638,8 +633,7 @@ class PrefixBulkDeleteView(ObjectPermissionRequiredMixin, BulkDeleteView): # IP addresses # -class IPAddressListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'ipam.view_ipaddress' +class IPAddressListView(ObjectListView): queryset = IPAddress.objects.prefetch_related( 'vrf__tenant', 'tenant', 'nat_inside', 'interface__device', 'interface__virtual_machine' ) @@ -813,8 +807,7 @@ class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # VLAN groups # -class VLANGroupListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'ipam.view_vlangroup' +class VLANGroupListView(ObjectListView): queryset = VLANGroup.objects.prefetch_related('site').annotate(vlan_count=Count('vlans')) filterset = filters.VLANGroupFilterSet filterset_form = forms.VLANGroupFilterForm @@ -889,8 +882,7 @@ def get(self, request, pk): # VLANs # -class VLANListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'ipam.view_vlan' +class VLANListView(ObjectListView): queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role').prefetch_related('prefixes') filterset = filters.VLANFilterSet filterset_form = forms.VLANFilterForm @@ -985,8 +977,7 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Services # -class ServiceListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'ipam.view_service' +class ServiceListView(ObjectListView): queryset = Service.objects.prefetch_related('device', 'virtual_machine') filterset = filters.ServiceFilterSet filterset_form = forms.ServiceFilterForm diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index 8ce9addb46..eda8453759 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -30,8 +30,7 @@ def get_session_key(request): # Secret roles # -class SecretRoleListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'secrets.view_secretrole' +class SecretRoleListView(ObjectListView): queryset = SecretRole.objects.annotate(secret_count=Count('secrets')) table = tables.SecretRoleTable @@ -66,8 +65,7 @@ class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Secrets # -class SecretListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'secrets.view_secret' +class SecretListView(ObjectListView): queryset = Secret.objects.prefetch_related('role', 'device') filterset = filters.SecretFilterSet filterset_form = forms.SecretFilterForm diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 745362271f..b4e37d1533 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -18,8 +18,7 @@ # Tenant groups # -class TenantGroupListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'tenancy.view_tenantgroup' +class TenantGroupListView(ObjectListView): queryset = TenantGroup.objects.add_related_count( TenantGroup.objects.all(), Tenant, @@ -60,8 +59,7 @@ class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Tenants # -class TenantListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'tenancy.view_tenant' +class TenantListView(ObjectListView): queryset = Tenant.objects.prefetch_related('group') filterset = filters.TenantFilterSet filterset_form = forms.TenantFilterForm diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 6097fa5b2b..8b4efeb5a7 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -27,6 +27,7 @@ from users.models import ObjectPermission from utilities.exceptions import AbortTransaction from utilities.forms import BootstrapMixin, CSVDataField, TableConfigForm +from utilities.permissions import get_permission_for_model from utilities.utils import csv_format, prepare_cloned_fields from .error_handlers import handle_protectederror from .forms import ConfirmationForm, ImportForm @@ -45,11 +46,15 @@ class ObjectPermissionRequiredMixin(AccessMixin): """ permission_required = None + def get_required_permission(self): + return self.permission_required + def has_permission(self): user = self.request.user + permission_required = self.get_required_permission() # First, check that the user is granted the required permission at either the model or object level. - if not user.has_perm(self.permission_required): + if not user.has_perm(permission_required): return False # Superusers implicitly have all permissions @@ -58,23 +63,18 @@ def has_permission(self): # Determine whether the permission is model-level or object-level. Model-level permissions grant the # specified action to *all* objects, so no further action is needed. - if self.permission_required in {*user._user_perm_cache, *user._group_perm_cache}: + if permission_required in {*user._user_perm_cache, *user._group_perm_cache}: return True # If the permission is granted only at the object level, filter the view's queryset to return only objects # on which the user is permitted to perform the specified action. - attrs = ObjectPermission.objects.get_attr_constraints(user, self.permission_required) + attrs = ObjectPermission.objects.get_attr_constraints(user, permission_required) if attrs: # Update the view's QuerySet to filter only the permitted objects self.queryset = self.queryset.filter(attrs) return True def dispatch(self, request, *args, **kwargs): - if self.permission_required is None: - raise ImproperlyConfigured( - '{0} is missing the permission_required attribute. Define {0}.permission_required, or override ' - '{0}.get_permission_required().'.format(self.__class__.__name__) - ) if not hasattr(self, 'queryset'): raise ImproperlyConfigured( @@ -118,15 +118,15 @@ def get_return_url(self, request, obj=None): # Generic views # -class ObjectListView(View): +class ObjectListView(ObjectPermissionRequiredMixin, View): """ List a series of objects. - queryset: The queryset of objects to display - filter: A django-filter FilterSet that is applied to the queryset - filter_form: The form used to render filter options - table: The django-tables2 Table used to render the objects list - template_name: The name of the template + :param queryset: The queryset of objects to display + :param filter: A django-filter FilterSet that is applied to the queryset + :param filter_form: The form used to render filter options + :param table: The django-tables2 Table used to render the objects list + :param template_name: The name of the template """ queryset = None filterset = None @@ -135,6 +135,11 @@ class ObjectListView(View): template_name = 'utilities/obj_list.html' action_buttons = ('add', 'import', 'export') + def get_required_permission(self): + if getattr(self, 'permission_required') is not None: + return self.permission_required + return get_permission_for_model(self.queryset.model, 'view') + def queryset_to_yaml(self): """ Export the queryset of objects as concatenated YAML documents. diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index c6f107be7e..85dbf47749 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -22,8 +22,7 @@ # Cluster types # -class ClusterTypeListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'virtualization.view_clustertype' +class ClusterTypeListView(ObjectListView): queryset = ClusterType.objects.annotate(cluster_count=Count('clusters')) table = tables.ClusterTypeTable @@ -58,8 +57,7 @@ class ClusterTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Cluster groups # -class ClusterGroupListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'virtualization.view_clustergroup' +class ClusterGroupListView(ObjectListView): queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters')) table = tables.ClusterGroupTable @@ -94,8 +92,7 @@ class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Clusters # -class ClusterListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'virtualization.view_cluster' +class ClusterListView(ObjectListView): queryset = Cluster.objects.prefetch_related('type', 'group', 'site', 'tenant') table = tables.ClusterTable filterset = filters.ClusterFilterSet @@ -251,8 +248,7 @@ def post(self, request, pk): # Virtual machines # -class VirtualMachineListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'virtualization.view_virtualmachine' +class VirtualMachineListView(ObjectListView): queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role', 'primary_ip4', 'primary_ip6') filterset = filters.VirtualMachineFilterSet filterset_form = forms.VirtualMachineFilterForm From 406b076b95c94d1f307b9ddb823095da34d7b346 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 May 2020 13:59:19 -0400 Subject: [PATCH 021/101] Transition ObjectEditView to use ObjectPermissionRequiredMixin --- netbox/circuits/urls.py | 8 +- netbox/circuits/views.py | 28 +------ netbox/dcim/urls.py | 30 +++---- netbox/dcim/views.py | 145 +++++++-------------------------- netbox/extras/urls.py | 2 +- netbox/extras/views.py | 13 +-- netbox/ipam/urls.py | 16 ++-- netbox/ipam/views.py | 62 ++------------ netbox/secrets/urls.py | 2 +- netbox/secrets/views.py | 7 +- netbox/tenancy/urls.py | 4 +- netbox/tenancy/views.py | 14 +--- netbox/utilities/views.py | 8 +- netbox/virtualization/urls.py | 12 +-- netbox/virtualization/views.py | 31 ++----- 15 files changed, 99 insertions(+), 283 deletions(-) diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 72d9720df4..1a7fa283b4 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -10,7 +10,7 @@ # Providers path('providers/', views.ProviderListView.as_view(), name='provider_list'), - path('providers/add/', views.ProviderCreateView.as_view(), name='provider_add'), + path('providers/add/', views.ProviderEditView.as_view(), name='provider_add'), path('providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'), path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'), path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'), @@ -21,7 +21,7 @@ # Circuit types path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'), - path('circuit-types/add/', views.CircuitTypeCreateView.as_view(), name='circuittype_add'), + path('circuit-types/add/', views.CircuitTypeEditView.as_view(), name='circuittype_add'), path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'), path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), path('circuit-types//edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), @@ -29,7 +29,7 @@ # Circuits path('circuits/', views.CircuitListView.as_view(), name='circuit_list'), - path('circuits/add/', views.CircuitCreateView.as_view(), name='circuit_add'), + path('circuits/add/', views.CircuitEditView.as_view(), name='circuit_add'), path('circuits/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'), path('circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'), path('circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'), @@ -41,7 +41,7 @@ # Circuit terminations - path('circuits//terminations/add/', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'), + path('circuits//terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'), path('circuit-terminations//edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), path('circuit-terminations//delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), path('circuit-terminations//connect//', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}), diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index e3f3473987..59cdac930a 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -59,18 +59,13 @@ def get(self, request, slug): }) -class ProviderCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'circuits.add_provider' +class ProviderEditView(ObjectEditView): queryset = Provider.objects.all() model_form = forms.ProviderForm template_name = 'circuits/provider_edit.html' default_return_url = 'circuits:provider_list' -class ProviderEditView(ProviderCreateView): - permission_required = 'circuits.change_provider' - - class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'circuits.delete_provider' queryset = Provider.objects.all() @@ -111,17 +106,12 @@ class CircuitTypeListView(ObjectListView): table = tables.CircuitTypeTable -class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'circuits.add_circuittype' +class CircuitTypeEditView(ObjectEditView): queryset = CircuitType.objects.all() model_form = forms.CircuitTypeForm default_return_url = 'circuits:circuittype_list' -class CircuitTypeEditView(CircuitTypeCreateView): - permission_required = 'circuits.change_circuittype' - - class CircuitTypeBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'circuits.add_circuittype' queryset = CircuitType.objects.all() @@ -175,18 +165,13 @@ def get(self, request, pk): }) -class CircuitCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'circuits.add_circuit' +class CircuitEditView(ObjectEditView): queryset = Circuit.objects.all() model_form = forms.CircuitForm template_name = 'circuits/circuit_edit.html' default_return_url = 'circuits:circuit_list' -class CircuitEditView(CircuitCreateView): - permission_required = 'circuits.change_circuit' - - class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'circuits.delete_circuit' queryset = Circuit.objects.all() @@ -271,8 +256,7 @@ def circuit_terminations_swap(request, pk): # Circuit terminations # -class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'circuits.add_circuittermination' +class CircuitTerminationEditView(ObjectEditView): queryset = CircuitTermination.objects.all() model_form = forms.CircuitTerminationForm template_name = 'circuits/circuittermination_edit.html' @@ -286,10 +270,6 @@ def get_return_url(self, request, obj): return obj.circuit.get_absolute_url() -class CircuitTerminationEditView(CircuitTerminationCreateView): - permission_required = 'circuits.change_circuittermination' - - class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'circuits.delete_circuittermination' queryset = CircuitTermination.objects.all() diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 0b1f6250e7..a0d6bdc929 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -1,7 +1,7 @@ from django.urls import path from extras.views import ObjectChangeLogView, ImageAttachmentEditView -from ipam.views import ServiceCreateView +from ipam.views import ServiceEditView from . import views from .models import ( Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform, @@ -14,7 +14,7 @@ # Regions path('regions/', views.RegionListView.as_view(), name='region_list'), - path('regions/add/', views.RegionCreateView.as_view(), name='region_add'), + path('regions/add/', views.RegionEditView.as_view(), name='region_add'), path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'), path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), path('regions//edit/', views.RegionEditView.as_view(), name='region_edit'), @@ -22,7 +22,7 @@ # Sites path('sites/', views.SiteListView.as_view(), name='site_list'), - path('sites/add/', views.SiteCreateView.as_view(), name='site_add'), + path('sites/add/', views.SiteEditView.as_view(), name='site_add'), path('sites/import/', views.SiteBulkImportView.as_view(), name='site_import'), path('sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'), path('sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'), @@ -34,7 +34,7 @@ # Rack groups path('rack-groups/', views.RackGroupListView.as_view(), name='rackgroup_list'), - path('rack-groups/add/', views.RackGroupCreateView.as_view(), name='rackgroup_add'), + path('rack-groups/add/', views.RackGroupEditView.as_view(), name='rackgroup_add'), path('rack-groups/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'), path('rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'), path('rack-groups//edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'), @@ -42,7 +42,7 @@ # Rack roles path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'), - path('rack-roles/add/', views.RackRoleCreateView.as_view(), name='rackrole_add'), + path('rack-roles/add/', views.RackRoleEditView.as_view(), name='rackrole_add'), path('rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'), path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), path('rack-roles//edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'), @@ -50,7 +50,7 @@ # Rack reservations path('rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'), - path('rack-reservations/add/', views.RackReservationCreateView.as_view(), name='rackreservation_add'), + path('rack-reservations/add/', views.RackReservationEditView.as_view(), name='rackreservation_add'), path('rack-reservations/import/', views.RackReservationImportView.as_view(), name='rackreservation_import'), path('rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'), path('rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'), @@ -62,7 +62,7 @@ # Racks path('racks/', views.RackListView.as_view(), name='rack_list'), path('rack-elevations/', views.RackElevationListView.as_view(), name='rack_elevation_list'), - path('racks/add/', views.RackCreateView.as_view(), name='rack_add'), + path('racks/add/', views.RackEditView.as_view(), name='rack_add'), path('racks/import/', views.RackBulkImportView.as_view(), name='rack_import'), path('racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'), path('racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'), @@ -74,7 +74,7 @@ # Manufacturers path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'), - path('manufacturers/add/', views.ManufacturerCreateView.as_view(), name='manufacturer_add'), + path('manufacturers/add/', views.ManufacturerEditView.as_view(), name='manufacturer_add'), path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'), path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'), path('manufacturers//edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), @@ -82,7 +82,7 @@ # Device types path('device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'), - path('device-types/add/', views.DeviceTypeCreateView.as_view(), name='devicetype_add'), + path('device-types/add/', views.DeviceTypeEditView.as_view(), name='devicetype_add'), path('device-types/import/', views.DeviceTypeImportView.as_view(), name='devicetype_import'), path('device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'), path('device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'), @@ -149,7 +149,7 @@ # Device roles path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'), - path('device-roles/add/', views.DeviceRoleCreateView.as_view(), name='devicerole_add'), + path('device-roles/add/', views.DeviceRoleEditView.as_view(), name='devicerole_add'), path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'), path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'), path('device-roles//edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), @@ -157,7 +157,7 @@ # Platforms path('platforms/', views.PlatformListView.as_view(), name='platform_list'), - path('platforms/add/', views.PlatformCreateView.as_view(), name='platform_add'), + path('platforms/add/', views.PlatformEditView.as_view(), name='platform_add'), path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'), path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'), path('platforms//edit/', views.PlatformEditView.as_view(), name='platform_edit'), @@ -165,7 +165,7 @@ # Devices path('devices/', views.DeviceListView.as_view(), name='device_list'), - path('devices/add/', views.DeviceCreateView.as_view(), name='device_add'), + path('devices/add/', views.DeviceEditView.as_view(), name='device_add'), path('devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'), path('devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'), path('devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'), @@ -179,7 +179,7 @@ path('devices//status/', views.DeviceStatusView.as_view(), name='device_status'), path('devices//lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), path('devices//config/', views.DeviceConfigView.as_view(), name='device_config'), - path('devices//services/assign/', ServiceCreateView.as_view(), name='device_service_assign'), + path('devices//services/assign/', ServiceEditView.as_view(), name='device_service_assign'), path('devices//images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}), # Console ports @@ -332,7 +332,7 @@ # Power panels path('power-panels/', views.PowerPanelListView.as_view(), name='powerpanel_list'), - path('power-panels/add/', views.PowerPanelCreateView.as_view(), name='powerpanel_add'), + path('power-panels/add/', views.PowerPanelEditView.as_view(), name='powerpanel_add'), path('power-panels/import/', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'), path('power-panels/edit/', views.PowerPanelBulkEditView.as_view(), name='powerpanel_bulk_edit'), path('power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'), @@ -343,7 +343,7 @@ # Power feeds path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'), - path('power-feeds/add/', views.PowerFeedCreateView.as_view(), name='powerfeed_add'), + path('power-feeds/add/', views.PowerFeedEditView.as_view(), name='powerfeed_add'), path('power-feeds/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'), path('power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'), path('power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 9faad490e3..e33f3bd043 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -154,17 +154,12 @@ class RegionListView(ObjectListView): table = tables.RegionTable -class RegionCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.add_region' +class RegionEditView(ObjectEditView): queryset = Region.objects.all() model_form = forms.RegionForm default_return_url = 'dcim:region_list' -class RegionEditView(RegionCreateView): - permission_required = 'dcim.change_region' - - class RegionBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_region' queryset = Region.objects.all() @@ -218,18 +213,13 @@ def get(self, request, slug): }) -class SiteCreateView(ObjectPermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.add_site' +class SiteEditView(ObjectEditView): queryset = Site.objects.all() model_form = forms.SiteForm template_name = 'dcim/site_edit.html' default_return_url = 'dcim:site_list' -class SiteEditView(SiteCreateView): - permission_required = 'dcim.change_site' - - class SiteDeleteView(ObjectPermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_site' queryset = Site.objects.all() @@ -278,17 +268,12 @@ class RackGroupListView(ObjectListView): table = tables.RackGroupTable -class RackGroupCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.add_rackgroup' +class RackGroupEditView(ObjectEditView): queryset = RackGroup.objects.all() model_form = forms.RackGroupForm default_return_url = 'dcim:rackgroup_list' -class RackGroupEditView(RackGroupCreateView): - permission_required = 'dcim.change_rackgroup' - - class RackGroupBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_rackgroup' queryset = RackGroup.objects.all() @@ -314,17 +299,12 @@ class RackRoleListView(ObjectListView): table = tables.RackRoleTable -class RackRoleCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.add_rackrole' +class RackRoleEditView(ObjectEditView): queryset = RackRole.objects.all() model_form = forms.RackRoleForm default_return_url = 'dcim:rackrole_list' -class RackRoleEditView(RackRoleCreateView): - permission_required = 'dcim.change_rackrole' - - class RackRoleBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_rackrole' queryset = RackRole.objects.all() @@ -424,18 +404,13 @@ def get(self, request, pk): }) -class RackCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.add_rack' +class RackEditView(ObjectEditView): queryset = Rack.objects.all() model_form = forms.RackForm template_name = 'dcim/rack_edit.html' default_return_url = 'dcim:rack_list' -class RackEditView(RackCreateView): - permission_required = 'dcim.change_rack' - - class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_rack' queryset = Rack.objects.all() @@ -491,8 +466,7 @@ def get(self, request, pk): }) -class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.add_rackreservation' +class RackReservationEditView(ObjectEditView): queryset = RackReservation.objects.all() model_form = forms.RackReservationForm template_name = 'dcim/rackreservation_edit.html' @@ -506,10 +480,6 @@ def alter_obj(self, obj, request, args, kwargs): return obj -class RackReservationEditView(RackReservationCreateView): - permission_required = 'dcim.change_rackreservation' - - class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_rackreservation' queryset = RackReservation.objects.all() @@ -564,17 +534,12 @@ class ManufacturerListView(ObjectListView): table = tables.ManufacturerTable -class ManufacturerCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.add_manufacturer' +class ManufacturerEditView(ObjectEditView): queryset = Manufacturer.objects.all() model_form = forms.ManufacturerForm default_return_url = 'dcim:manufacturer_list' -class ManufacturerEditView(ManufacturerCreateView): - permission_required = 'dcim.change_manufacturer' - - class ManufacturerBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_manufacturer' queryset = Manufacturer.objects.all() @@ -664,18 +629,13 @@ def get(self, request, pk): }) -class DeviceTypeCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.add_devicetype' +class DeviceTypeEditView(ObjectEditView): queryset = DeviceType.objects.all() model_form = forms.DeviceTypeForm template_name = 'dcim/devicetype_edit.html' default_return_url = 'dcim:devicetype_list' -class DeviceTypeEditView(DeviceTypeCreateView): - permission_required = 'dcim.change_devicetype' - - class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_devicetype' queryset = DeviceType.objects.all() @@ -738,8 +698,7 @@ class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView template_name = 'dcim/device_component_add.html' -class ConsolePortTemplateEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_consoleporttemplate' +class ConsolePortTemplateEditView(ObjectEditView): queryset = ConsolePortTemplate.objects.all() model_form = forms.ConsolePortTemplateForm @@ -774,8 +733,7 @@ class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCrea template_name = 'dcim/device_component_add.html' -class ConsoleServerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_consoleserverporttemplate' +class ConsoleServerPortTemplateEditView(ObjectEditView): queryset = ConsoleServerPortTemplate.objects.all() model_form = forms.ConsoleServerPortTemplateForm @@ -810,8 +768,7 @@ class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class PowerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_powerporttemplate' +class PowerPortTemplateEditView(ObjectEditView): queryset = PowerPortTemplate.objects.all() model_form = forms.PowerPortTemplateForm @@ -846,8 +803,7 @@ class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView template_name = 'dcim/device_component_add.html' -class PowerOutletTemplateEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_poweroutlettemplate' +class PowerOutletTemplateEditView(ObjectEditView): queryset = PowerOutletTemplate.objects.all() model_form = forms.PowerOutletTemplateForm @@ -882,8 +838,7 @@ class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class InterfaceTemplateEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_interfacetemplate' +class InterfaceTemplateEditView(ObjectEditView): queryset = InterfaceTemplate.objects.all() model_form = forms.InterfaceTemplateForm @@ -918,8 +873,7 @@ class FrontPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class FrontPortTemplateEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_frontporttemplate' +class FrontPortTemplateEditView(ObjectEditView): queryset = FrontPortTemplate.objects.all() model_form = forms.FrontPortTemplateForm @@ -954,8 +908,7 @@ class RearPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class RearPortTemplateEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_rearporttemplate' +class RearPortTemplateEditView(ObjectEditView): queryset = RearPortTemplate.objects.all() model_form = forms.RearPortTemplateForm @@ -990,8 +943,7 @@ class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class DeviceBayTemplateEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_devicebaytemplate' +class DeviceBayTemplateEditView(ObjectEditView): queryset = DeviceBayTemplate.objects.all() model_form = forms.DeviceBayTemplateForm @@ -1023,17 +975,12 @@ class DeviceRoleListView(ObjectListView): table = tables.DeviceRoleTable -class DeviceRoleCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.add_devicerole' +class DeviceRoleEditView(ObjectEditView): queryset = DeviceRole.objects.all() model_form = forms.DeviceRoleForm default_return_url = 'dcim:devicerole_list' -class DeviceRoleEditView(DeviceRoleCreateView): - permission_required = 'dcim.change_devicerole' - - class DeviceRoleBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_devicerole' queryset = DeviceRole.objects.all() @@ -1058,17 +1005,12 @@ class PlatformListView(ObjectListView): table = tables.PlatformTable -class PlatformCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.add_platform' +class PlatformEditView(ObjectEditView): queryset = Platform.objects.all() model_form = forms.PlatformForm default_return_url = 'dcim:platform_list' -class PlatformEditView(PlatformCreateView): - permission_required = 'dcim.change_platform' - - class PlatformBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_platform' queryset = Platform.objects.all() @@ -1244,18 +1186,13 @@ class DeviceConfigContextView(PermissionRequiredMixin, ObjectConfigContextView): base_template = 'dcim/device.html' -class DeviceCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.add_device' +class DeviceEditView(ObjectEditView): queryset = Device.objects.all() model_form = forms.DeviceForm template_name = 'dcim/device_edit.html' default_return_url = 'dcim:device_list' -class DeviceEditView(DeviceCreateView): - permission_required = 'dcim.change_device' - - class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_device' queryset = Device.objects.all() @@ -1328,8 +1265,7 @@ class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class ConsolePortEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_consoleport' +class ConsolePortEditView(ObjectEditView): queryset = ConsolePort.objects.all() model_form = forms.ConsolePortForm @@ -1383,8 +1319,7 @@ class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class ConsoleServerPortEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_consoleserverport' +class ConsoleServerPortEditView(ObjectEditView): queryset = ConsoleServerPort.objects.all() model_form = forms.ConsoleServerPortForm @@ -1450,8 +1385,7 @@ class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class PowerPortEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_powerport' +class PowerPortEditView(ObjectEditView): queryset = PowerPort.objects.all() model_form = forms.PowerPortForm @@ -1505,8 +1439,7 @@ class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class PowerOutletEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_poweroutlet' +class PowerOutletEditView(ObjectEditView): queryset = PowerOutlet.objects.all() model_form = forms.PowerOutletForm @@ -1608,8 +1541,7 @@ class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class InterfaceEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_interface' +class InterfaceEditView(ObjectEditView): queryset = Interface.objects.all() model_form = forms.InterfaceForm template_name = 'dcim/interface_edit.html' @@ -1676,8 +1608,7 @@ class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class FrontPortEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_frontport' +class FrontPortEditView(ObjectEditView): queryset = FrontPort.objects.all() model_form = forms.FrontPortForm @@ -1743,8 +1674,7 @@ class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class RearPortEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_rearport' +class RearPortEditView(ObjectEditView): queryset = RearPort.objects.all() model_form = forms.RearPortForm @@ -1812,8 +1742,7 @@ class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class DeviceBayEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_devicebay' +class DeviceBayEditView(ObjectEditView): queryset = DeviceBay.objects.all() model_form = forms.DeviceBayForm @@ -2152,8 +2081,7 @@ def post(self, request, *args, **kwargs): }) -class CableEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_cable' +class CableEditView(ObjectEditView): queryset = Cable.objects.all() model_form = forms.CableForm template_name = 'dcim/cable_edit.html' @@ -2306,8 +2234,7 @@ class InventoryItemListView(ObjectListView): action_buttons = ('import', 'export') -class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_inventoryitem' +class InventoryItemEditView(ObjectEditView): queryset = InventoryItem.objects.all() model_form = forms.InventoryItemForm @@ -2650,17 +2577,12 @@ def get(self, request, pk): }) -class PowerPanelCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.add_powerpanel' +class PowerPanelEditView(ObjectEditView): queryset = PowerPanel.objects.all() model_form = forms.PowerPanelForm default_return_url = 'dcim:powerpanel_list' -class PowerPanelEditView(PowerPanelCreateView): - permission_required = 'dcim.change_powerpanel' - - class PowerPanelDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_powerpanel' queryset = PowerPanel.objects.all() @@ -2721,18 +2643,13 @@ def get(self, request, pk): }) -class PowerFeedCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.add_powerfeed' +class PowerFeedEditView(ObjectEditView): queryset = PowerFeed.objects.all() model_form = forms.PowerFeedForm template_name = 'dcim/powerfeed_edit.html' default_return_url = 'dcim:powerfeed_list' -class PowerFeedEditView(PowerFeedCreateView): - permission_required = 'dcim.change_powerfeed' - - class PowerFeedDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_powerfeed' queryset = PowerFeed.objects.all() diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index a486ce7fc8..3eee303a31 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -18,7 +18,7 @@ # Config contexts path('config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'), - path('config-contexts/add/', views.ConfigContextCreateView.as_view(), name='configcontext_add'), + path('config-contexts/add/', views.ConfigContextEditView.as_view(), name='configcontext_add'), path('config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'), path('config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'), path('config-contexts//', views.ConfigContextView.as_view(), name='configcontext'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index c1bee4dd70..b5d9306f84 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -64,8 +64,7 @@ def get(self, request, slug): }) -class TagEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'extras.change_tag' +class TagEditView(ObjectEditView): queryset = Tag.objects.all() model_form = forms.TagForm default_return_url = 'extras:tag_list' @@ -132,18 +131,13 @@ def get(self, request, pk): }) -class ConfigContextCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'extras.add_configcontext' +class ConfigContextEditView(ObjectEditView): queryset = ConfigContext.objects.all() model_form = forms.ConfigContextForm default_return_url = 'extras:configcontext_list' template_name = 'extras/configcontext_edit.html' -class ConfigContextEditView(ConfigContextCreateView): - permission_required = 'extras.change_configcontext' - - class ConfigContextBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'extras.change_configcontext' queryset = ConfigContext.objects.all() @@ -301,8 +295,7 @@ def get(self, request, model, **kwargs): # Image attachments # -class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'extras.change_imageattachment' +class ImageAttachmentEditView(ObjectEditView): queryset = ImageAttachment.objects.all() model_form = forms.ImageAttachmentForm diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index f1211473e8..de8fc86eb2 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -9,7 +9,7 @@ # VRFs path('vrfs/', views.VRFListView.as_view(), name='vrf_list'), - path('vrfs/add/', views.VRFCreateView.as_view(), name='vrf_add'), + path('vrfs/add/', views.VRFEditView.as_view(), name='vrf_add'), path('vrfs/import/', views.VRFBulkImportView.as_view(), name='vrf_import'), path('vrfs/edit/', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'), path('vrfs/delete/', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'), @@ -20,7 +20,7 @@ # RIRs path('rirs/', views.RIRListView.as_view(), name='rir_list'), - path('rirs/add/', views.RIRCreateView.as_view(), name='rir_add'), + path('rirs/add/', views.RIREditView.as_view(), name='rir_add'), path('rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'), path('rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'), path('rirs//edit/', views.RIREditView.as_view(), name='rir_edit'), @@ -28,7 +28,7 @@ # Aggregates path('aggregates/', views.AggregateListView.as_view(), name='aggregate_list'), - path('aggregates/add/', views.AggregateCreateView.as_view(), name='aggregate_add'), + path('aggregates/add/', views.AggregateEditView.as_view(), name='aggregate_add'), path('aggregates/import/', views.AggregateBulkImportView.as_view(), name='aggregate_import'), path('aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'), path('aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'), @@ -39,7 +39,7 @@ # Roles path('roles/', views.RoleListView.as_view(), name='role_list'), - path('roles/add/', views.RoleCreateView.as_view(), name='role_add'), + path('roles/add/', views.RoleEditView.as_view(), name='role_add'), path('roles/import/', views.RoleBulkImportView.as_view(), name='role_import'), path('roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'), path('roles//edit/', views.RoleEditView.as_view(), name='role_edit'), @@ -47,7 +47,7 @@ # Prefixes path('prefixes/', views.PrefixListView.as_view(), name='prefix_list'), - path('prefixes/add/', views.PrefixCreateView.as_view(), name='prefix_add'), + path('prefixes/add/', views.PrefixEditView.as_view(), name='prefix_add'), path('prefixes/import/', views.PrefixBulkImportView.as_view(), name='prefix_import'), path('prefixes/edit/', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'), path('prefixes/delete/', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'), @@ -60,7 +60,7 @@ # IP addresses path('ip-addresses/', views.IPAddressListView.as_view(), name='ipaddress_list'), - path('ip-addresses/add/', views.IPAddressCreateView.as_view(), name='ipaddress_add'), + path('ip-addresses/add/', views.IPAddressEditView.as_view(), name='ipaddress_add'), path('ip-addresses/bulk-add/', views.IPAddressBulkCreateView.as_view(), name='ipaddress_bulk_add'), path('ip-addresses/import/', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'), path('ip-addresses/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'), @@ -73,7 +73,7 @@ # VLAN groups path('vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'), - path('vlan-groups/add/', views.VLANGroupCreateView.as_view(), name='vlangroup_add'), + path('vlan-groups/add/', views.VLANGroupEditView.as_view(), name='vlangroup_add'), path('vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'), path('vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'), path('vlan-groups//edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), @@ -82,7 +82,7 @@ # VLANs path('vlans/', views.VLANListView.as_view(), name='vlan_list'), - path('vlans/add/', views.VLANCreateView.as_view(), name='vlan_add'), + path('vlans/add/', views.VLANEditView.as_view(), name='vlan_add'), path('vlans/import/', views.VLANBulkImportView.as_view(), name='vlan_import'), path('vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'), path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 09c3f78924..220205c198 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -134,18 +134,13 @@ def get(self, request, pk): }) -class VRFCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'ipam.add_vrf' +class VRFEditView(ObjectEditView): queryset = VRF.objects.all() model_form = forms.VRFForm template_name = 'ipam/vrf_edit.html' default_return_url = 'ipam:vrf_list' -class VRFEditView(VRFCreateView): - permission_required = 'ipam.change_vrf' - - class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'ipam.delete_vrf' queryset = VRF.objects.all() @@ -257,17 +252,12 @@ def alter_queryset(self, request): return rirs -class RIRCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'ipam.add_rir' +class RIREditView(ObjectEditView): queryset = RIR.objects.all() model_form = forms.RIRForm default_return_url = 'ipam:rir_list' -class RIREditView(RIRCreateView): - permission_required = 'ipam.change_rir' - - class RIRBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'ipam.add_rir' queryset = RIR.objects.all() @@ -359,18 +349,13 @@ def get(self, request, pk): }) -class AggregateCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'ipam.add_aggregate' +class AggregateEditView(ObjectEditView): queryset = Aggregate.objects.all() model_form = forms.AggregateForm template_name = 'ipam/aggregate_edit.html' default_return_url = 'ipam:aggregate_list' -class AggregateEditView(AggregateCreateView): - permission_required = 'ipam.change_aggregate' - - class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'ipam.delete_aggregate' queryset = Aggregate.objects.all() @@ -411,17 +396,12 @@ class RoleListView(ObjectListView): table = tables.RoleTable -class RoleCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'ipam.add_role' +class RoleEditView(ObjectEditView): queryset = Role.objects.all() model_form = forms.RoleForm default_return_url = 'ipam:role_list' -class RoleEditView(RoleCreateView): - permission_required = 'ipam.change_role' - - class RoleBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'ipam.add_role' queryset = Role.objects.all() @@ -585,18 +565,13 @@ def get(self, request, pk): }) -class PrefixCreateView(ObjectPermissionRequiredMixin, ObjectEditView): - permission_required = 'ipam.add_prefix' +class PrefixEditView(ObjectEditView): queryset = Prefix.objects.all() model_form = forms.PrefixForm template_name = 'ipam/prefix_edit.html' default_return_url = 'ipam:prefix_list' -class PrefixEditView(PrefixCreateView): - permission_required = 'ipam.change_prefix' - - class PrefixDeleteView(ObjectPermissionRequiredMixin, ObjectDeleteView): permission_required = 'ipam.delete_prefix' queryset = Prefix.objects.all() @@ -696,8 +671,7 @@ def get(self, request, pk): }) -class IPAddressCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'ipam.add_ipaddress' +class IPAddressEditView(ObjectEditView): queryset = IPAddress.objects.all() model_form = forms.IPAddressForm template_name = 'ipam/ipaddress_edit.html' @@ -715,10 +689,6 @@ def alter_obj(self, obj, request, url_args, url_kwargs): return obj -class IPAddressEditView(IPAddressCreateView): - permission_required = 'ipam.change_ipaddress' - - class IPAddressAssignView(PermissionRequiredMixin, View): """ Search for IPAddresses to be assigned to an Interface. @@ -814,17 +784,13 @@ class VLANGroupListView(ObjectListView): table = tables.VLANGroupTable -class VLANGroupCreateView(PermissionRequiredMixin, ObjectEditView): +class VLANGroupEditView(ObjectEditView): permission_required = 'ipam.add_vlangroup' queryset = VLANGroup.objects.all() model_form = forms.VLANGroupForm default_return_url = 'ipam:vlangroup_list' -class VLANGroupEditView(VLANGroupCreateView): - permission_required = 'ipam.change_vlangroup' - - class VLANGroupBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'ipam.add_vlangroup' queryset = VLANGroup.objects.all() @@ -930,18 +896,13 @@ def get(self, request, pk): }) -class VLANCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'ipam.add_vlan' +class VLANEditView(ObjectEditView): queryset = VLAN.objects.all() model_form = forms.VLANForm template_name = 'ipam/vlan_edit.html' default_return_url = 'ipam:vlan_list' -class VLANEditView(VLANCreateView): - permission_required = 'ipam.change_vlan' - - class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'ipam.delete_vlan' queryset = VLAN.objects.all() @@ -997,8 +958,7 @@ def get(self, request, pk): }) -class ServiceCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'ipam.add_service' +class ServiceEditView(ObjectEditView): queryset = Service.objects.all() model_form = forms.ServiceForm template_name = 'ipam/service_edit.html' @@ -1022,10 +982,6 @@ class ServiceBulkImportView(PermissionRequiredMixin, BulkImportView): default_return_url = 'ipam:service_list' -class ServiceEditView(ServiceCreateView): - permission_required = 'ipam.change_service' - - class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'ipam.delete_service' queryset = Service.objects.all() diff --git a/netbox/secrets/urls.py b/netbox/secrets/urls.py index a19ec6ae05..ac75a7ed4a 100644 --- a/netbox/secrets/urls.py +++ b/netbox/secrets/urls.py @@ -9,7 +9,7 @@ # Secret roles path('secret-roles/', views.SecretRoleListView.as_view(), name='secretrole_list'), - path('secret-roles/add/', views.SecretRoleCreateView.as_view(), name='secretrole_add'), + path('secret-roles/add/', views.SecretRoleEditView.as_view(), name='secretrole_add'), path('secret-roles/import/', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'), path('secret-roles/delete/', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'), path('secret-roles//edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'), diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index eda8453759..be0c87cee2 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -35,17 +35,12 @@ class SecretRoleListView(ObjectListView): table = tables.SecretRoleTable -class SecretRoleCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'secrets.add_secretrole' +class SecretRoleEditView(ObjectEditView): queryset = SecretRole.objects.all() model_form = forms.SecretRoleForm default_return_url = 'secrets:secretrole_list' -class SecretRoleEditView(SecretRoleCreateView): - permission_required = 'secrets.change_secretrole' - - class SecretRoleBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'secrets.add_secretrole' queryset = SecretRole.objects.all() diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index 0218a5674f..4c65ce4e89 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -9,7 +9,7 @@ # Tenant groups path('tenant-groups/', views.TenantGroupListView.as_view(), name='tenantgroup_list'), - path('tenant-groups/add/', views.TenantGroupCreateView.as_view(), name='tenantgroup_add'), + path('tenant-groups/add/', views.TenantGroupEditView.as_view(), name='tenantgroup_add'), path('tenant-groups/import/', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'), path('tenant-groups/delete/', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'), path('tenant-groups//edit/', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'), @@ -17,7 +17,7 @@ # Tenants path('tenants/', views.TenantListView.as_view(), name='tenant_list'), - path('tenants/add/', views.TenantCreateView.as_view(), name='tenant_add'), + path('tenants/add/', views.TenantEditView.as_view(), name='tenant_add'), path('tenants/import/', views.TenantBulkImportView.as_view(), name='tenant_import'), path('tenants/edit/', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'), path('tenants/delete/', views.TenantBulkDeleteView.as_view(), name='tenant_bulk_delete'), diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index b4e37d1533..4dbc99815c 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -29,17 +29,12 @@ class TenantGroupListView(ObjectListView): table = tables.TenantGroupTable -class TenantGroupCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'tenancy.add_tenantgroup' +class TenantGroupEditView(ObjectEditView): queryset = TenantGroup.objects.all() model_form = forms.TenantGroupForm default_return_url = 'tenancy:tenantgroup_list' -class TenantGroupEditView(TenantGroupCreateView): - permission_required = 'tenancy.change_tenantgroup' - - class TenantGroupBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'tenancy.add_tenantgroup' queryset = TenantGroup.objects.all() @@ -92,18 +87,13 @@ def get(self, request, slug): }) -class TenantCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'tenancy.add_tenant' +class TenantEditView(ObjectEditView): queryset = Tenant.objects.all() model_form = forms.TenantForm template_name = 'tenancy/tenant_edit.html' default_return_url = 'tenancy:tenant_list' -class TenantEditView(TenantCreateView): - permission_required = 'tenancy.change_tenant' - - class TenantDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'tenancy.delete_tenant' queryset = Tenant.objects.all() diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 8b4efeb5a7..127e0daebe 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -278,7 +278,7 @@ def extra_context(self): return {} -class ObjectEditView(GetReturnURLMixin, View): +class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Create or edit a single object. @@ -290,6 +290,12 @@ class ObjectEditView(GetReturnURLMixin, View): model_form = None template_name = 'utilities/obj_edit.html' + def get_required_permission(self): + # Determine required permission based on whether we are editing an existing object + if self.obj.pk is None: + return get_permission_for_model(self.queryset.model, 'add') + return get_permission_for_model(self.queryset.model, 'change') + def get_object(self, kwargs): # Look up an existing object by slug or PK, if provided. if 'slug' in kwargs: diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index 557f8a9ca6..38ad1a8b17 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -1,7 +1,7 @@ from django.urls import path from extras.views import ObjectChangeLogView -from ipam.views import ServiceCreateView +from ipam.views import ServiceEditView from . import views from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -10,7 +10,7 @@ # Cluster types path('cluster-types/', views.ClusterTypeListView.as_view(), name='clustertype_list'), - path('cluster-types/add/', views.ClusterTypeCreateView.as_view(), name='clustertype_add'), + path('cluster-types/add/', views.ClusterTypeEditView.as_view(), name='clustertype_add'), path('cluster-types/import/', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'), path('cluster-types/delete/', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'), path('cluster-types//edit/', views.ClusterTypeEditView.as_view(), name='clustertype_edit'), @@ -18,7 +18,7 @@ # Cluster groups path('cluster-groups/', views.ClusterGroupListView.as_view(), name='clustergroup_list'), - path('cluster-groups/add/', views.ClusterGroupCreateView.as_view(), name='clustergroup_add'), + path('cluster-groups/add/', views.ClusterGroupEditView.as_view(), name='clustergroup_add'), path('cluster-groups/import/', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'), path('cluster-groups/delete/', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'), path('cluster-groups//edit/', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'), @@ -26,7 +26,7 @@ # Clusters path('clusters/', views.ClusterListView.as_view(), name='cluster_list'), - path('clusters/add/', views.ClusterCreateView.as_view(), name='cluster_add'), + path('clusters/add/', views.ClusterEditView.as_view(), name='cluster_add'), path('clusters/import/', views.ClusterBulkImportView.as_view(), name='cluster_import'), path('clusters/edit/', views.ClusterBulkEditView.as_view(), name='cluster_bulk_edit'), path('clusters/delete/', views.ClusterBulkDeleteView.as_view(), name='cluster_bulk_delete'), @@ -39,7 +39,7 @@ # Virtual machines path('virtual-machines/', views.VirtualMachineListView.as_view(), name='virtualmachine_list'), - path('virtual-machines/add/', views.VirtualMachineCreateView.as_view(), name='virtualmachine_add'), + path('virtual-machines/add/', views.VirtualMachineEditView.as_view(), name='virtualmachine_add'), path('virtual-machines/import/', views.VirtualMachineBulkImportView.as_view(), name='virtualmachine_import'), path('virtual-machines/edit/', views.VirtualMachineBulkEditView.as_view(), name='virtualmachine_bulk_edit'), path('virtual-machines/delete/', views.VirtualMachineBulkDeleteView.as_view(), name='virtualmachine_bulk_delete'), @@ -48,7 +48,7 @@ path('virtual-machines//delete/', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'), path('virtual-machines//config-context/', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'), path('virtual-machines//changelog/', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}), - path('virtual-machines//services/assign/', ServiceCreateView.as_view(), name='virtualmachine_service_assign'), + path('virtual-machines//services/assign/', ServiceEditView.as_view(), name='virtualmachine_service_assign'), # VM interfaces path('interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'), diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 85dbf47749..11090def84 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -27,17 +27,12 @@ class ClusterTypeListView(ObjectListView): table = tables.ClusterTypeTable -class ClusterTypeCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'virtualization.add_clustertype' +class ClusterTypeEditView(ObjectEditView): queryset = ClusterType.objects.all() model_form = forms.ClusterTypeForm default_return_url = 'virtualization:clustertype_list' -class ClusterTypeEditView(ClusterTypeCreateView): - permission_required = 'virtualization.change_clustertype' - - class ClusterTypeBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'virtualization.add_clustertype' queryset = ClusterType.objects.all() @@ -62,17 +57,12 @@ class ClusterGroupListView(ObjectListView): table = tables.ClusterGroupTable -class ClusterGroupCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'virtualization.add_clustergroup' +class ClusterGroupEditView(ObjectEditView): queryset = ClusterGroup.objects.all() model_form = forms.ClusterGroupForm default_return_url = 'virtualization:clustergroup_list' -class ClusterGroupEditView(ClusterGroupCreateView): - permission_required = 'virtualization.change_clustergroup' - - class ClusterGroupBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'virtualization.add_clustergroup' queryset = ClusterGroup.objects.all() @@ -118,17 +108,12 @@ def get(self, request, pk): }) -class ClusterCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'virtualization.add_cluster' +class ClusterEditView(ObjectEditView): template_name = 'virtualization/cluster_edit.html' queryset = Cluster.objects.all() model_form = forms.ClusterForm -class ClusterEditView(ClusterCreateView): - permission_required = 'virtualization.change_cluster' - - class ClusterDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'virtualization.delete_cluster' queryset = Cluster.objects.all() @@ -278,18 +263,13 @@ class VirtualMachineConfigContextView(PermissionRequiredMixin, ObjectConfigConte base_template = 'virtualization/virtualmachine.html' -class VirtualMachineCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'virtualization.add_virtualmachine' +class VirtualMachineEditView(ObjectEditView): queryset = VirtualMachine.objects.all() model_form = forms.VirtualMachineForm template_name = 'virtualization/virtualmachine_edit.html' default_return_url = 'virtualization:virtualmachine_list' -class VirtualMachineEditView(VirtualMachineCreateView): - permission_required = 'virtualization.change_virtualmachine' - - class VirtualMachineDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'virtualization.delete_virtualmachine' queryset = VirtualMachine.objects.all() @@ -333,8 +313,7 @@ class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'virtualization/virtualmachine_component_add.html' -class InterfaceEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_interface' +class InterfaceEditView(ObjectEditView): queryset = Interface.objects.all() model_form = forms.InterfaceForm template_name = 'virtualization/interface_edit.html' From 5381c4e0aeae0ede246d28f795ebea843d1b209b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 May 2020 14:26:56 -0400 Subject: [PATCH 022/101] Tweak evaluation of required permission for ObjectEditView --- netbox/utilities/views.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 127e0daebe..9815018b7c 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -291,10 +291,9 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): template_name = 'utilities/obj_edit.html' def get_required_permission(self): - # Determine required permission based on whether we are editing an existing object - if self.obj.pk is None: - return get_permission_for_model(self.queryset.model, 'add') - return get_permission_for_model(self.queryset.model, 'change') + # self._permission_action is set by dispatch() to either "add" or "change" depending on whether + # we are modifying an existing object or creating a new one. + return get_permission_for_model(self.queryset.model, self._permission_action) def get_object(self, kwargs): # Look up an existing object by slug or PK, if provided. @@ -311,25 +310,32 @@ def alter_obj(self, obj, request, url_args, url_kwargs): return obj def dispatch(self, request, *args, **kwargs): - self.obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs) + # Determine required permission based on whether we are editing an existing object + self._permission_action = 'change' if kwargs else 'add' return super().dispatch(request, *args, **kwargs) def get(self, request, *args, **kwargs): + obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs) + # Parse initial data manually to avoid setting field values as lists initial_data = {k: request.GET[k] for k in request.GET} - form = self.model_form(instance=self.obj, initial=initial_data) + form = self.model_form(instance=obj, initial=initial_data) return render(request, self.template_name, { - 'obj': self.obj, + 'obj': obj, 'obj_type': self.queryset.model._meta.verbose_name, 'form': form, - 'return_url': self.get_return_url(request, self.obj), + 'return_url': self.get_return_url(request, obj), }) def post(self, request, *args, **kwargs): logger = logging.getLogger('netbox.views.ObjectEditView') - form = self.model_form(request.POST, request.FILES, instance=self.obj) + form = self.model_form( + data=request.POST, + files=request.FILES, + instance=self.alter_obj(self.get_object(kwargs), request, args, kwargs) + ) if form.is_valid(): logger.debug("Form validation was successful") @@ -376,10 +382,10 @@ def post(self, request, *args, **kwargs): logger.debug("Form validation failed") return render(request, self.template_name, { - 'obj': self.obj, + 'obj': obj, 'obj_type': self.queryset.model._meta.verbose_name, 'form': form, - 'return_url': self.get_return_url(request, self.obj), + 'return_url': self.get_return_url(request, obj), }) From 2b32430a1070b3bf0bddddc7c30e0dc20b3573be Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 May 2020 14:34:40 -0400 Subject: [PATCH 023/101] Transition ObjectDeleteView to use ObjectPermissionRequiredMixin --- netbox/circuits/views.py | 11 ++--- netbox/dcim/views.py | 78 ++++++++++++---------------------- netbox/extras/views.py | 9 ++-- netbox/ipam/views.py | 18 +++----- netbox/secrets/views.py | 3 +- netbox/tenancy/views.py | 3 +- netbox/utilities/views.py | 15 ++++--- netbox/virtualization/views.py | 9 ++-- 8 files changed, 53 insertions(+), 93 deletions(-) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 59cdac930a..7016a5b9d3 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -3,7 +3,7 @@ from django.contrib.auth.decorators import permission_required from django.contrib.auth.mixins import PermissionRequiredMixin from django.db import transaction -from django.db.models import Count, OuterRef, Subquery +from django.db.models import Count, OuterRef from django.shortcuts import get_object_or_404, redirect, render from django.views.generic import View from django_tables2 import RequestConfig @@ -66,8 +66,7 @@ class ProviderEditView(ObjectEditView): default_return_url = 'circuits:provider_list' -class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'circuits.delete_provider' +class ProviderDeleteView(ObjectDeleteView): queryset = Provider.objects.all() default_return_url = 'circuits:provider_list' @@ -172,8 +171,7 @@ class CircuitEditView(ObjectEditView): default_return_url = 'circuits:circuit_list' -class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'circuits.delete_circuit' +class CircuitDeleteView(ObjectDeleteView): queryset = Circuit.objects.all() default_return_url = 'circuits:circuit_list' @@ -270,6 +268,5 @@ def get_return_url(self, request, obj): return obj.circuit.get_absolute_url() -class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'circuits.delete_circuittermination' +class CircuitTerminationDeleteView(ObjectDeleteView): queryset = CircuitTermination.objects.all() diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index e33f3bd043..d61d0f82fe 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -220,8 +220,7 @@ class SiteEditView(ObjectEditView): default_return_url = 'dcim:site_list' -class SiteDeleteView(ObjectPermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_site' +class SiteDeleteView(ObjectDeleteView): queryset = Site.objects.all() default_return_url = 'dcim:site_list' @@ -411,8 +410,7 @@ class RackEditView(ObjectEditView): default_return_url = 'dcim:rack_list' -class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_rack' +class RackDeleteView(ObjectDeleteView): queryset = Rack.objects.all() default_return_url = 'dcim:rack_list' @@ -480,8 +478,7 @@ def alter_obj(self, obj, request, args, kwargs): return obj -class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_rackreservation' +class RackReservationDeleteView(ObjectDeleteView): queryset = RackReservation.objects.all() default_return_url = 'dcim:rackreservation_list' @@ -636,8 +633,7 @@ class DeviceTypeEditView(ObjectEditView): default_return_url = 'dcim:devicetype_list' -class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_devicetype' +class DeviceTypeDeleteView(ObjectDeleteView): queryset = DeviceType.objects.all() default_return_url = 'dcim:devicetype_list' @@ -703,8 +699,7 @@ class ConsolePortTemplateEditView(ObjectEditView): model_form = forms.ConsolePortTemplateForm -class ConsolePortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_consoleporttemplate' +class ConsolePortTemplateDeleteView(ObjectDeleteView): queryset = ConsolePortTemplate.objects.all() @@ -738,8 +733,7 @@ class ConsoleServerPortTemplateEditView(ObjectEditView): model_form = forms.ConsoleServerPortTemplateForm -class ConsoleServerPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_consoleserverporttemplate' +class ConsoleServerPortTemplateDeleteView(ObjectDeleteView): queryset = ConsoleServerPortTemplate.objects.all() @@ -773,8 +767,7 @@ class PowerPortTemplateEditView(ObjectEditView): model_form = forms.PowerPortTemplateForm -class PowerPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_powerporttemplate' +class PowerPortTemplateDeleteView(ObjectDeleteView): queryset = PowerPortTemplate.objects.all() @@ -808,8 +801,7 @@ class PowerOutletTemplateEditView(ObjectEditView): model_form = forms.PowerOutletTemplateForm -class PowerOutletTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_poweroutlettemplate' +class PowerOutletTemplateDeleteView(ObjectDeleteView): queryset = PowerOutletTemplate.objects.all() @@ -843,8 +835,7 @@ class InterfaceTemplateEditView(ObjectEditView): model_form = forms.InterfaceTemplateForm -class InterfaceTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_interfacetemplate' +class InterfaceTemplateDeleteView(ObjectDeleteView): queryset = InterfaceTemplate.objects.all() @@ -878,8 +869,7 @@ class FrontPortTemplateEditView(ObjectEditView): model_form = forms.FrontPortTemplateForm -class FrontPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_frontporttemplate' +class FrontPortTemplateDeleteView(ObjectDeleteView): queryset = FrontPortTemplate.objects.all() @@ -913,8 +903,7 @@ class RearPortTemplateEditView(ObjectEditView): model_form = forms.RearPortTemplateForm -class RearPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_rearporttemplate' +class RearPortTemplateDeleteView(ObjectDeleteView): queryset = RearPortTemplate.objects.all() @@ -948,8 +937,7 @@ class DeviceBayTemplateEditView(ObjectEditView): model_form = forms.DeviceBayTemplateForm -class DeviceBayTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_devicebaytemplate' +class DeviceBayTemplateDeleteView(ObjectDeleteView): queryset = DeviceBayTemplate.objects.all() @@ -1193,8 +1181,7 @@ class DeviceEditView(ObjectEditView): default_return_url = 'dcim:device_list' -class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_device' +class DeviceDeleteView(ObjectDeleteView): queryset = Device.objects.all() default_return_url = 'dcim:device_list' @@ -1270,8 +1257,7 @@ class ConsolePortEditView(ObjectEditView): model_form = forms.ConsolePortForm -class ConsolePortDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_consoleport' +class ConsolePortDeleteView(ObjectDeleteView): queryset = ConsolePort.objects.all() @@ -1324,8 +1310,7 @@ class ConsoleServerPortEditView(ObjectEditView): model_form = forms.ConsoleServerPortForm -class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_consoleserverport' +class ConsoleServerPortDeleteView(ObjectDeleteView): queryset = ConsoleServerPort.objects.all() @@ -1390,8 +1375,7 @@ class PowerPortEditView(ObjectEditView): model_form = forms.PowerPortForm -class PowerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_powerport' +class PowerPortDeleteView(ObjectDeleteView): queryset = PowerPort.objects.all() @@ -1444,8 +1428,7 @@ class PowerOutletEditView(ObjectEditView): model_form = forms.PowerOutletForm -class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_poweroutlet' +class PowerOutletDeleteView(ObjectDeleteView): queryset = PowerOutlet.objects.all() @@ -1547,8 +1530,7 @@ class InterfaceEditView(ObjectEditView): template_name = 'dcim/interface_edit.html' -class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_interface' +class InterfaceDeleteView(ObjectDeleteView): queryset = Interface.objects.all() @@ -1613,8 +1595,7 @@ class FrontPortEditView(ObjectEditView): model_form = forms.FrontPortForm -class FrontPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_frontport' +class FrontPortDeleteView(ObjectDeleteView): queryset = FrontPort.objects.all() @@ -1679,8 +1660,7 @@ class RearPortEditView(ObjectEditView): model_form = forms.RearPortForm -class RearPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_rearport' +class RearPortDeleteView(ObjectDeleteView): queryset = RearPort.objects.all() @@ -1747,8 +1727,7 @@ class DeviceBayEditView(ObjectEditView): model_form = forms.DeviceBayForm -class DeviceBayDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_devicebay' +class DeviceBayDeleteView(ObjectDeleteView): queryset = DeviceBay.objects.all() @@ -2088,8 +2067,7 @@ class CableEditView(ObjectEditView): default_return_url = 'dcim:cable_list' -class CableDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_cable' +class CableDeleteView(ObjectDeleteView): queryset = Cable.objects.all() default_return_url = 'dcim:cable_list' @@ -2247,8 +2225,7 @@ class InventoryItemCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class InventoryItemDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_inventoryitem' +class InventoryItemDeleteView(ObjectDeleteView): queryset = InventoryItem.objects.all() @@ -2420,8 +2397,7 @@ def post(self, request, pk): }) -class VirtualChassisDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_virtualchassis' +class VirtualChassisDeleteView(ObjectDeleteView): queryset = VirtualChassis.objects.all() default_return_url = 'dcim:device_list' @@ -2583,8 +2559,7 @@ class PowerPanelEditView(ObjectEditView): default_return_url = 'dcim:powerpanel_list' -class PowerPanelDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_powerpanel' +class PowerPanelDeleteView(ObjectDeleteView): queryset = PowerPanel.objects.all() default_return_url = 'dcim:powerpanel_list' @@ -2650,8 +2625,7 @@ class PowerFeedEditView(ObjectEditView): default_return_url = 'dcim:powerfeed_list' -class PowerFeedDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_powerfeed' +class PowerFeedDeleteView(ObjectDeleteView): queryset = PowerFeed.objects.all() default_return_url = 'dcim:powerfeed_list' diff --git a/netbox/extras/views.py b/netbox/extras/views.py index b5d9306f84..63764b683e 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -71,8 +71,7 @@ class TagEditView(ObjectEditView): template_name = 'extras/tag_edit.html' -class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'extras.delete_tag' +class TagDeleteView(ObjectDeleteView): queryset = Tag.objects.all() default_return_url = 'extras:tag_list' @@ -147,8 +146,7 @@ class ConfigContextBulkEditView(PermissionRequiredMixin, BulkEditView): default_return_url = 'extras:configcontext_list' -class ConfigContextDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'extras.delete_configcontext' +class ConfigContextDeleteView(ObjectDeleteView): queryset = ConfigContext.objects.all() default_return_url = 'extras:configcontext_list' @@ -310,8 +308,7 @@ def get_return_url(self, request, imageattachment): return imageattachment.parent.get_absolute_url() -class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'extras.delete_imageattachment' +class ImageAttachmentDeleteView(ObjectDeleteView): queryset = ImageAttachment.objects.all() def get_return_url(self, request, imageattachment): diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 220205c198..176321982f 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -141,8 +141,7 @@ class VRFEditView(ObjectEditView): default_return_url = 'ipam:vrf_list' -class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'ipam.delete_vrf' +class VRFDeleteView(ObjectDeleteView): queryset = VRF.objects.all() default_return_url = 'ipam:vrf_list' @@ -356,8 +355,7 @@ class AggregateEditView(ObjectEditView): default_return_url = 'ipam:aggregate_list' -class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'ipam.delete_aggregate' +class AggregateDeleteView(ObjectDeleteView): queryset = Aggregate.objects.all() default_return_url = 'ipam:aggregate_list' @@ -572,8 +570,7 @@ class PrefixEditView(ObjectEditView): default_return_url = 'ipam:prefix_list' -class PrefixDeleteView(ObjectPermissionRequiredMixin, ObjectDeleteView): - permission_required = 'ipam.delete_prefix' +class PrefixDeleteView(ObjectDeleteView): queryset = Prefix.objects.all() template_name = 'ipam/prefix_delete.html' default_return_url = 'ipam:prefix_list' @@ -733,8 +730,7 @@ def post(self, request): }) -class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'ipam.delete_ipaddress' +class IPAddressDeleteView(ObjectDeleteView): queryset = IPAddress.objects.all() default_return_url = 'ipam:ipaddress_list' @@ -903,8 +899,7 @@ class VLANEditView(ObjectEditView): default_return_url = 'ipam:vlan_list' -class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'ipam.delete_vlan' +class VLANDeleteView(ObjectDeleteView): queryset = VLAN.objects.all() default_return_url = 'ipam:vlan_list' @@ -982,8 +977,7 @@ class ServiceBulkImportView(PermissionRequiredMixin, BulkImportView): default_return_url = 'ipam:service_list' -class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'ipam.delete_service' +class ServiceDeleteView(ObjectDeleteView): queryset = Service.objects.all() diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index be0c87cee2..7c69d0ac4c 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -183,8 +183,7 @@ def secret_edit(request, pk): }) -class SecretDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'secrets.delete_secret' +class SecretDeleteView(ObjectDeleteView): queryset = Secret.objects.all() default_return_url = 'secrets:secret_list' diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 4dbc99815c..97480bb6ab 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -94,8 +94,7 @@ class TenantEditView(ObjectEditView): default_return_url = 'tenancy:tenant_list' -class TenantDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'tenancy.delete_tenant' +class TenantDeleteView(ObjectDeleteView): queryset = Tenant.objects.all() default_return_url = 'tenancy:tenant_list' diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 9815018b7c..f4267748f0 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -282,9 +282,9 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Create or edit a single object. - queryset: The base queryset for the object being modified - model_form: The form used to create or edit the object - template_name: The name of the template + :param queryset: The base queryset for the object being modified + :param model_form: The form used to create or edit the object + :param template_name: The name of the template """ queryset = None model_form = None @@ -389,16 +389,19 @@ def post(self, request, *args, **kwargs): }) -class ObjectDeleteView(GetReturnURLMixin, View): +class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Delete a single object. - queryset: The base queryset for the object being deleted - template_name: The name of the template + :param queryset: The base queryset for the object being deleted + :param template_name: The name of the template """ queryset = None template_name = 'utilities/obj_delete.html' + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'delete') + def get_object(self, kwargs): # Look up object by slug if one has been provided. Otherwise, use PK. if 'slug' in kwargs: diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 11090def84..8bc3876ca2 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -114,8 +114,7 @@ class ClusterEditView(ObjectEditView): model_form = forms.ClusterForm -class ClusterDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'virtualization.delete_cluster' +class ClusterDeleteView(ObjectDeleteView): queryset = Cluster.objects.all() default_return_url = 'virtualization:cluster_list' @@ -270,8 +269,7 @@ class VirtualMachineEditView(ObjectEditView): default_return_url = 'virtualization:virtualmachine_list' -class VirtualMachineDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'virtualization.delete_virtualmachine' +class VirtualMachineDeleteView(ObjectDeleteView): queryset = VirtualMachine.objects.all() default_return_url = 'virtualization:virtualmachine_list' @@ -319,8 +317,7 @@ class InterfaceEditView(ObjectEditView): template_name = 'virtualization/interface_edit.html' -class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_interface' +class InterfaceDeleteView(ObjectDeleteView): queryset = Interface.objects.all() From 5e5038d7808870347dc07c22c3ab693368fcf783 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 May 2020 14:43:27 -0400 Subject: [PATCH 024/101] Transition BulkImportView to use ObjectPermissionRequiredMixin --- netbox/circuits/views.py | 9 ++--- netbox/dcim/views.py | 69 ++++++++++++---------------------- netbox/ipam/views.py | 27 +++++-------- netbox/secrets/views.py | 4 +- netbox/tenancy/views.py | 6 +-- netbox/utilities/views.py | 5 ++- netbox/virtualization/views.py | 12 ++---- 7 files changed, 46 insertions(+), 86 deletions(-) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 7016a5b9d3..7ee6a7dc12 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -71,8 +71,7 @@ class ProviderDeleteView(ObjectDeleteView): default_return_url = 'circuits:provider_list' -class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'circuits.add_provider' +class ProviderBulkImportView(BulkImportView): queryset = Provider.objects.all() model_form = forms.ProviderCSVForm table = tables.ProviderTable @@ -111,8 +110,7 @@ class CircuitTypeEditView(ObjectEditView): default_return_url = 'circuits:circuittype_list' -class CircuitTypeBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'circuits.add_circuittype' +class CircuitTypeBulkImportView(BulkImportView): queryset = CircuitType.objects.all() model_form = forms.CircuitTypeCSVForm table = tables.CircuitTypeTable @@ -176,8 +174,7 @@ class CircuitDeleteView(ObjectDeleteView): default_return_url = 'circuits:circuit_list' -class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'circuits.add_circuit' +class CircuitBulkImportView(BulkImportView): queryset = Circuit.objects.all() model_form = forms.CircuitCSVForm table = tables.CircuitTable diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index d61d0f82fe..d1882359d8 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -160,8 +160,7 @@ class RegionEditView(ObjectEditView): default_return_url = 'dcim:region_list' -class RegionBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_region' +class RegionBulkImportView(BulkImportView): queryset = Region.objects.all() model_form = forms.RegionCSVForm table = tables.RegionTable @@ -225,8 +224,7 @@ class SiteDeleteView(ObjectDeleteView): default_return_url = 'dcim:site_list' -class SiteBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_site' +class SiteBulkImportView(BulkImportView): queryset = Site.objects.all() model_form = forms.SiteCSVForm table = tables.SiteTable @@ -273,8 +271,7 @@ class RackGroupEditView(ObjectEditView): default_return_url = 'dcim:rackgroup_list' -class RackGroupBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_rackgroup' +class RackGroupBulkImportView(BulkImportView): queryset = RackGroup.objects.all() model_form = forms.RackGroupCSVForm table = tables.RackGroupTable @@ -304,8 +301,7 @@ class RackRoleEditView(ObjectEditView): default_return_url = 'dcim:rackrole_list' -class RackRoleBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_rackrole' +class RackRoleBulkImportView(BulkImportView): queryset = RackRole.objects.all() model_form = forms.RackRoleCSVForm table = tables.RackRoleTable @@ -415,8 +411,7 @@ class RackDeleteView(ObjectDeleteView): default_return_url = 'dcim:rack_list' -class RackBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_rack' +class RackBulkImportView(BulkImportView): queryset = Rack.objects.all() model_form = forms.RackCSVForm table = tables.RackTable @@ -483,8 +478,7 @@ class RackReservationDeleteView(ObjectDeleteView): default_return_url = 'dcim:rackreservation_list' -class RackReservationImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_rackreservation' +class RackReservationImportView(BulkImportView): queryset = RackReservation.objects.all() model_form = forms.RackReservationCSVForm table = tables.RackReservationTable @@ -537,8 +531,7 @@ class ManufacturerEditView(ObjectEditView): default_return_url = 'dcim:manufacturer_list' -class ManufacturerBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_manufacturer' +class ManufacturerBulkImportView(BulkImportView): queryset = Manufacturer.objects.all() model_form = forms.ManufacturerCSVForm table = tables.ManufacturerTable @@ -969,8 +962,7 @@ class DeviceRoleEditView(ObjectEditView): default_return_url = 'dcim:devicerole_list' -class DeviceRoleBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_devicerole' +class DeviceRoleBulkImportView(BulkImportView): queryset = DeviceRole.objects.all() model_form = forms.DeviceRoleCSVForm table = tables.DeviceRoleTable @@ -999,8 +991,7 @@ class PlatformEditView(ObjectEditView): default_return_url = 'dcim:platform_list' -class PlatformBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_platform' +class PlatformBulkImportView(BulkImportView): queryset = Platform.objects.all() model_form = forms.PlatformCSVForm table = tables.PlatformTable @@ -1186,8 +1177,7 @@ class DeviceDeleteView(ObjectDeleteView): default_return_url = 'dcim:device_list' -class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_device' +class DeviceBulkImportView(BulkImportView): queryset = Device.objects.all() model_form = forms.DeviceCSVForm table = tables.DeviceImportTable @@ -1195,8 +1185,7 @@ class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView): default_return_url = 'dcim:device_list' -class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_device' +class ChildDeviceBulkImportView(BulkImportView): queryset = Device.objects.all() model_form = forms.ChildDeviceCSVForm table = tables.DeviceImportTable @@ -1261,8 +1250,7 @@ class ConsolePortDeleteView(ObjectDeleteView): queryset = ConsolePort.objects.all() -class ConsolePortBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_consoleport' +class ConsolePortBulkImportView(BulkImportView): queryset = ConsolePort.objects.all() model_form = forms.ConsolePortCSVForm table = tables.ConsolePortImportTable @@ -1314,8 +1302,7 @@ class ConsoleServerPortDeleteView(ObjectDeleteView): queryset = ConsoleServerPort.objects.all() -class ConsoleServerPortBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_consoleserverport' +class ConsoleServerPortBulkImportView(BulkImportView): queryset = ConsoleServerPort.objects.all() model_form = forms.ConsoleServerPortCSVForm table = tables.ConsoleServerPortImportTable @@ -1379,8 +1366,7 @@ class PowerPortDeleteView(ObjectDeleteView): queryset = PowerPort.objects.all() -class PowerPortBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_powerport' +class PowerPortBulkImportView(BulkImportView): queryset = PowerPort.objects.all() model_form = forms.PowerPortCSVForm table = tables.PowerPortImportTable @@ -1432,8 +1418,7 @@ class PowerOutletDeleteView(ObjectDeleteView): queryset = PowerOutlet.objects.all() -class PowerOutletBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_poweroutlet' +class PowerOutletBulkImportView(BulkImportView): queryset = PowerOutlet.objects.all() model_form = forms.PowerOutletCSVForm table = tables.PowerOutletImportTable @@ -1534,8 +1519,7 @@ class InterfaceDeleteView(ObjectDeleteView): queryset = Interface.objects.all() -class InterfaceBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_interface' +class InterfaceBulkImportView(BulkImportView): queryset = Interface.objects.all() model_form = forms.InterfaceCSVForm table = tables.InterfaceImportTable @@ -1599,8 +1583,7 @@ class FrontPortDeleteView(ObjectDeleteView): queryset = FrontPort.objects.all() -class FrontPortBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_frontport' +class FrontPortBulkImportView(BulkImportView): queryset = FrontPort.objects.all() model_form = forms.FrontPortCSVForm table = tables.FrontPortImportTable @@ -1664,8 +1647,7 @@ class RearPortDeleteView(ObjectDeleteView): queryset = RearPort.objects.all() -class RearPortBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_rearport' +class RearPortBulkImportView(BulkImportView): queryset = RearPort.objects.all() model_form = forms.RearPortCSVForm table = tables.RearPortImportTable @@ -1800,8 +1782,7 @@ def post(self, request, pk): }) -class DeviceBayBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_devicebay' +class DeviceBayBulkImportView(BulkImportView): queryset = DeviceBay.objects.all() model_form = forms.DeviceBayCSVForm table = tables.DeviceBayImportTable @@ -2072,8 +2053,7 @@ class CableDeleteView(ObjectDeleteView): default_return_url = 'dcim:cable_list' -class CableBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_cable' +class CableBulkImportView(BulkImportView): queryset = Cable.objects.all() model_form = forms.CableCSVForm table = tables.CableTable @@ -2229,8 +2209,7 @@ class InventoryItemDeleteView(ObjectDeleteView): queryset = InventoryItem.objects.all() -class InventoryItemBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_inventoryitem' +class InventoryItemBulkImportView(BulkImportView): queryset = InventoryItem.objects.all() model_form = forms.InventoryItemCSVForm table = tables.InventoryItemTable @@ -2564,8 +2543,7 @@ class PowerPanelDeleteView(ObjectDeleteView): default_return_url = 'dcim:powerpanel_list' -class PowerPanelBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_powerpanel' +class PowerPanelBulkImportView(BulkImportView): queryset = PowerPanel.objects.all() model_form = forms.PowerPanelCSVForm table = tables.PowerPanelTable @@ -2630,8 +2608,7 @@ class PowerFeedDeleteView(ObjectDeleteView): default_return_url = 'dcim:powerfeed_list' -class PowerFeedBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_powerfeed' +class PowerFeedBulkImportView(BulkImportView): queryset = PowerFeed.objects.all() model_form = forms.PowerFeedCSVForm table = tables.PowerFeedTable diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 176321982f..dbd45b9231 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -146,8 +146,7 @@ class VRFDeleteView(ObjectDeleteView): default_return_url = 'ipam:vrf_list' -class VRFBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'ipam.add_vrf' +class VRFBulkImportView(BulkImportView): queryset = VRF.objects.all() model_form = forms.VRFCSVForm table = tables.VRFTable @@ -257,8 +256,7 @@ class RIREditView(ObjectEditView): default_return_url = 'ipam:rir_list' -class RIRBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'ipam.add_rir' +class RIRBulkImportView(BulkImportView): queryset = RIR.objects.all() model_form = forms.RIRCSVForm table = tables.RIRTable @@ -360,8 +358,7 @@ class AggregateDeleteView(ObjectDeleteView): default_return_url = 'ipam:aggregate_list' -class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'ipam.add_aggregate' +class AggregateBulkImportView(BulkImportView): queryset = Aggregate.objects.all() model_form = forms.AggregateCSVForm table = tables.AggregateTable @@ -400,8 +397,7 @@ class RoleEditView(ObjectEditView): default_return_url = 'ipam:role_list' -class RoleBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'ipam.add_role' +class RoleBulkImportView(BulkImportView): queryset = Role.objects.all() model_form = forms.RoleCSVForm table = tables.RoleTable @@ -576,8 +572,7 @@ class PrefixDeleteView(ObjectDeleteView): default_return_url = 'ipam:prefix_list' -class PrefixBulkImportView(ObjectPermissionRequiredMixin, BulkImportView): - permission_required = 'ipam.add_prefix' +class PrefixBulkImportView(BulkImportView): queryset = Prefix.objects.all() model_form = forms.PrefixCSVForm table = tables.PrefixTable @@ -744,8 +739,7 @@ class IPAddressBulkCreateView(PermissionRequiredMixin, BulkCreateView): default_return_url = 'ipam:ipaddress_list' -class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'ipam.add_ipaddress' +class IPAddressBulkImportView(BulkImportView): queryset = IPAddress.objects.all() model_form = forms.IPAddressCSVForm table = tables.IPAddressTable @@ -787,8 +781,7 @@ class VLANGroupEditView(ObjectEditView): default_return_url = 'ipam:vlangroup_list' -class VLANGroupBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'ipam.add_vlangroup' +class VLANGroupBulkImportView(BulkImportView): queryset = VLANGroup.objects.all() model_form = forms.VLANGroupCSVForm table = tables.VLANGroupTable @@ -904,8 +897,7 @@ class VLANDeleteView(ObjectDeleteView): default_return_url = 'ipam:vlan_list' -class VLANBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'ipam.add_vlan' +class VLANBulkImportView(BulkImportView): queryset = VLAN.objects.all() model_form = forms.VLANCSVForm table = tables.VLANTable @@ -969,8 +961,7 @@ def get_return_url(self, request, service): return service.parent.get_absolute_url() -class ServiceBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'ipam.add_service' +class ServiceBulkImportView(BulkImportView): queryset = Service.objects.all() model_form = forms.ServiceCSVForm table = tables.ServiceTable diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index 7c69d0ac4c..00794f6840 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -41,8 +41,7 @@ class SecretRoleEditView(ObjectEditView): default_return_url = 'secrets:secretrole_list' -class SecretRoleBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'secrets.add_secretrole' +class SecretRoleBulkImportView(BulkImportView): queryset = SecretRole.objects.all() model_form = forms.SecretRoleCSVForm table = tables.SecretRoleTable @@ -189,7 +188,6 @@ class SecretDeleteView(ObjectDeleteView): class SecretBulkImportView(BulkImportView): - permission_required = 'secrets.add_secret' queryset = Secret.objects.all() model_form = forms.SecretCSVForm table = tables.SecretTable diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 97480bb6ab..f666e606a3 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -35,8 +35,7 @@ class TenantGroupEditView(ObjectEditView): default_return_url = 'tenancy:tenantgroup_list' -class TenantGroupBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'tenancy.add_tenantgroup' +class TenantGroupBulkImportView(BulkImportView): queryset = TenantGroup.objects.all() model_form = forms.TenantGroupCSVForm table = tables.TenantGroupTable @@ -99,8 +98,7 @@ class TenantDeleteView(ObjectDeleteView): default_return_url = 'tenancy:tenant_list' -class TenantBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'tenancy.add_tenant' +class TenantBulkImportView(BulkImportView): queryset = Tenant.objects.all() model_form = forms.TenantCSVForm table = tables.TenantTable diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index f4267748f0..3d11cf25b6 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -652,7 +652,7 @@ def post(self, request): }) -class BulkImportView(GetReturnURLMixin, View): +class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Import objects in bulk (CSV format). @@ -684,6 +684,9 @@ def _save_obj(self, obj_form, request): """ return obj_form.save() + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'add') + def get(self, request): return render(request, self.template_name, { diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 8bc3876ca2..de4569b83a 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -33,8 +33,7 @@ class ClusterTypeEditView(ObjectEditView): default_return_url = 'virtualization:clustertype_list' -class ClusterTypeBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'virtualization.add_clustertype' +class ClusterTypeBulkImportView(BulkImportView): queryset = ClusterType.objects.all() model_form = forms.ClusterTypeCSVForm table = tables.ClusterTypeTable @@ -63,8 +62,7 @@ class ClusterGroupEditView(ObjectEditView): default_return_url = 'virtualization:clustergroup_list' -class ClusterGroupBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'virtualization.add_clustergroup' +class ClusterGroupBulkImportView(BulkImportView): queryset = ClusterGroup.objects.all() model_form = forms.ClusterGroupCSVForm table = tables.ClusterGroupTable @@ -119,8 +117,7 @@ class ClusterDeleteView(ObjectDeleteView): default_return_url = 'virtualization:cluster_list' -class ClusterBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'virtualization.add_cluster' +class ClusterBulkImportView(BulkImportView): queryset = Cluster.objects.all() model_form = forms.ClusterCSVForm table = tables.ClusterTable @@ -274,8 +271,7 @@ class VirtualMachineDeleteView(ObjectDeleteView): default_return_url = 'virtualization:virtualmachine_list' -class VirtualMachineBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'virtualization.add_virtualmachine' +class VirtualMachineBulkImportView(BulkImportView): queryset = VirtualMachine.objects.all() model_form = forms.VirtualMachineCSVForm table = tables.VirtualMachineTable From 82c247f3cf7660a24791f3e9e6505b0b18ab99b2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 May 2020 15:07:20 -0400 Subject: [PATCH 025/101] Transition BulkEditView to use ObjectPermissionRequiredMixin --- netbox/circuits/views.py | 6 +- netbox/dcim/views.py | 77 ++++++++-------------- netbox/extras/views.py | 6 +- netbox/ipam/views.py | 18 ++--- netbox/netbox/tests/test_authentication.py | 2 +- netbox/secrets/views.py | 3 +- netbox/tenancy/views.py | 3 +- netbox/utilities/views.py | 9 ++- netbox/virtualization/views.py | 9 +-- 9 files changed, 48 insertions(+), 85 deletions(-) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 7ee6a7dc12..3dc7032e4b 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -78,8 +78,7 @@ class ProviderBulkImportView(BulkImportView): default_return_url = 'circuits:provider_list' -class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'circuits.change_provider' +class ProviderBulkEditView(BulkEditView): queryset = Provider.objects.annotate(count_circuits=Count('circuits')) filterset = filters.ProviderFilterSet table = tables.ProviderTable @@ -181,8 +180,7 @@ class CircuitBulkImportView(BulkImportView): default_return_url = 'circuits:circuit_list' -class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'circuits.change_circuit' +class CircuitBulkEditView(BulkEditView): queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site') filterset = filters.CircuitFilterSet table = tables.CircuitTable diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index d1882359d8..8e2355a9ce 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -231,8 +231,7 @@ class SiteBulkImportView(BulkImportView): default_return_url = 'dcim:site_list' -class SiteBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_site' +class SiteBulkEditView(BulkEditView): queryset = Site.objects.prefetch_related('region', 'tenant') filterset = filters.SiteFilterSet table = tables.SiteTable @@ -418,8 +417,7 @@ class RackBulkImportView(BulkImportView): default_return_url = 'dcim:rack_list' -class RackBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_rack' +class RackBulkEditView(BulkEditView): queryset = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role') filterset = filters.RackFilterSet table = tables.RackTable @@ -495,8 +493,7 @@ def _save_obj(self, obj_form, request): return instance -class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_rackreservation' +class RackReservationBulkEditView(BulkEditView): queryset = RackReservation.objects.prefetch_related('rack', 'user') filterset = filters.RackReservationFilterSet table = tables.RackReservationTable @@ -658,8 +655,7 @@ class DeviceTypeImportView(PermissionRequiredMixin, ObjectImportView): default_return_url = 'dcim:devicetype_import' -class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_devicetype' +class DeviceTypeBulkEditView(BulkEditView): queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances')) filterset = filters.DeviceTypeFilterSet table = tables.DeviceTypeTable @@ -696,8 +692,7 @@ class ConsolePortTemplateDeleteView(ObjectDeleteView): queryset = ConsolePortTemplate.objects.all() -class ConsolePortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_consoleporttemplate' +class ConsolePortTemplateBulkEditView(BulkEditView): queryset = ConsolePortTemplate.objects.all() table = tables.ConsolePortTemplateTable form = forms.ConsolePortTemplateBulkEditForm @@ -730,8 +725,7 @@ class ConsoleServerPortTemplateDeleteView(ObjectDeleteView): queryset = ConsoleServerPortTemplate.objects.all() -class ConsoleServerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_consoleserverporttemplate' +class ConsoleServerPortTemplateBulkEditView(BulkEditView): queryset = ConsoleServerPortTemplate.objects.all() table = tables.ConsoleServerPortTemplateTable form = forms.ConsoleServerPortTemplateBulkEditForm @@ -764,8 +758,7 @@ class PowerPortTemplateDeleteView(ObjectDeleteView): queryset = PowerPortTemplate.objects.all() -class PowerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_powerporttemplate' +class PowerPortTemplateBulkEditView(BulkEditView): queryset = PowerPortTemplate.objects.all() table = tables.PowerPortTemplateTable form = forms.PowerPortTemplateBulkEditForm @@ -798,8 +791,7 @@ class PowerOutletTemplateDeleteView(ObjectDeleteView): queryset = PowerOutletTemplate.objects.all() -class PowerOutletTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_poweroutlettemplate' +class PowerOutletTemplateBulkEditView(BulkEditView): queryset = PowerOutletTemplate.objects.all() table = tables.PowerOutletTemplateTable form = forms.PowerOutletTemplateBulkEditForm @@ -832,8 +824,7 @@ class InterfaceTemplateDeleteView(ObjectDeleteView): queryset = InterfaceTemplate.objects.all() -class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_interfacetemplate' +class InterfaceTemplateBulkEditView(BulkEditView): queryset = InterfaceTemplate.objects.all() table = tables.InterfaceTemplateTable form = forms.InterfaceTemplateBulkEditForm @@ -866,8 +857,7 @@ class FrontPortTemplateDeleteView(ObjectDeleteView): queryset = FrontPortTemplate.objects.all() -class FrontPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_frontporttemplate' +class FrontPortTemplateBulkEditView(BulkEditView): queryset = FrontPortTemplate.objects.all() table = tables.FrontPortTemplateTable form = forms.FrontPortTemplateBulkEditForm @@ -900,8 +890,7 @@ class RearPortTemplateDeleteView(ObjectDeleteView): queryset = RearPortTemplate.objects.all() -class RearPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_rearporttemplate' +class RearPortTemplateBulkEditView(BulkEditView): queryset = RearPortTemplate.objects.all() table = tables.RearPortTemplateTable form = forms.RearPortTemplateBulkEditForm @@ -934,7 +923,7 @@ class DeviceBayTemplateDeleteView(ObjectDeleteView): queryset = DeviceBayTemplate.objects.all() -# class DeviceBayTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): +# class DeviceBayTemplateBulkEditView(BulkEditView): # permission_required = 'dcim.change_devicebaytemplate' # queryset = DeviceBayTemplate.objects.all() # table = tables.DeviceBayTemplateTable @@ -1204,8 +1193,7 @@ def _save_obj(self, obj_form, request): return obj -class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_device' +class DeviceBulkEditView(BulkEditView): queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer') filterset = filters.DeviceFilterSet table = tables.DeviceTable @@ -1257,8 +1245,7 @@ class ConsolePortBulkImportView(BulkImportView): default_return_url = 'dcim:consoleport_list' -class ConsolePortBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_consoleport' +class ConsolePortBulkEditView(BulkEditView): queryset = ConsolePort.objects.all() filterset = filters.ConsolePortFilterSet table = tables.ConsolePortTable @@ -1309,8 +1296,7 @@ class ConsoleServerPortBulkImportView(BulkImportView): default_return_url = 'dcim:consoleserverport_list' -class ConsoleServerPortBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_consoleserverport' +class ConsoleServerPortBulkEditView(BulkEditView): queryset = ConsoleServerPort.objects.all() filterset = filters.ConsoleServerPortFilterSet table = tables.ConsoleServerPortTable @@ -1373,8 +1359,7 @@ class PowerPortBulkImportView(BulkImportView): default_return_url = 'dcim:powerport_list' -class PowerPortBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_powerport' +class PowerPortBulkEditView(BulkEditView): queryset = PowerPort.objects.all() filterset = filters.PowerPortFilterSet table = tables.PowerPortTable @@ -1425,8 +1410,7 @@ class PowerOutletBulkImportView(BulkImportView): default_return_url = 'dcim:poweroutlet_list' -class PowerOutletBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_poweroutlet' +class PowerOutletBulkEditView(BulkEditView): queryset = PowerOutlet.objects.all() filterset = filters.PowerOutletFilterSet table = tables.PowerOutletTable @@ -1526,8 +1510,7 @@ class InterfaceBulkImportView(BulkImportView): default_return_url = 'dcim:interface_list' -class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_interface' +class InterfaceBulkEditView(BulkEditView): queryset = Interface.objects.all() filterset = filters.InterfaceFilterSet table = tables.InterfaceTable @@ -1590,8 +1573,7 @@ class FrontPortBulkImportView(BulkImportView): default_return_url = 'dcim:frontport_list' -class FrontPortBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_frontport' +class FrontPortBulkEditView(BulkEditView): queryset = FrontPort.objects.all() filterset = filters.FrontPortFilterSet table = tables.FrontPortTable @@ -1654,8 +1636,7 @@ class RearPortBulkImportView(BulkImportView): default_return_url = 'dcim:rearport_list' -class RearPortBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_rearport' +class RearPortBulkEditView(BulkEditView): queryset = RearPort.objects.all() filterset = filters.RearPortFilterSet table = tables.RearPortTable @@ -1789,8 +1770,7 @@ class DeviceBayBulkImportView(BulkImportView): default_return_url = 'dcim:devicebay_list' -class DeviceBayBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_devicebay' +class DeviceBayBulkEditView(BulkEditView): queryset = DeviceBay.objects.all() filterset = filters.DeviceBayFilterSet table = tables.DeviceBayTable @@ -2060,8 +2040,7 @@ class CableBulkImportView(BulkImportView): default_return_url = 'dcim:cable_list' -class CableBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_cable' +class CableBulkEditView(BulkEditView): queryset = Cable.objects.prefetch_related('termination_a', 'termination_b') filterset = filters.CableFilterSet table = tables.CableTable @@ -2216,8 +2195,7 @@ class InventoryItemBulkImportView(BulkImportView): default_return_url = 'dcim:inventoryitem_list' -class InventoryItemBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_inventoryitem' +class InventoryItemBulkEditView(BulkEditView): queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer') filterset = filters.InventoryItemFilterSet table = tables.InventoryItemTable @@ -2482,8 +2460,7 @@ def post(self, request, pk): }) -class VirtualChassisBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_virtualchassis' +class VirtualChassisBulkEditView(BulkEditView): queryset = VirtualChassis.objects.all() filterset = filters.VirtualChassisFilterSet table = tables.VirtualChassisTable @@ -2550,8 +2527,7 @@ class PowerPanelBulkImportView(BulkImportView): default_return_url = 'dcim:powerpanel_list' -class PowerPanelBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_powerpanel' +class PowerPanelBulkEditView(BulkEditView): queryset = PowerPanel.objects.prefetch_related('site', 'rack_group') filterset = filters.PowerPanelFilterSet table = tables.PowerPanelTable @@ -2615,8 +2591,7 @@ class PowerFeedBulkImportView(BulkImportView): default_return_url = 'dcim:powerfeed_list' -class PowerFeedBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_powerfeed' +class PowerFeedBulkEditView(BulkEditView): queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack') filterset = filters.PowerFeedFilterSet table = tables.PowerFeedTable diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 63764b683e..3aadbda983 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -76,8 +76,7 @@ class TagDeleteView(ObjectDeleteView): default_return_url = 'extras:tag_list' -class TagBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'extras.change_tag' +class TagBulkEditView(BulkEditView): queryset = Tag.objects.annotate( items=Count('extras_taggeditem_items', distinct=True) ).order_by( @@ -137,8 +136,7 @@ class ConfigContextEditView(ObjectEditView): template_name = 'extras/configcontext_edit.html' -class ConfigContextBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'extras.change_configcontext' +class ConfigContextBulkEditView(BulkEditView): queryset = ConfigContext.objects.all() filterset = filters.ConfigContextFilterSet table = ConfigContextTable diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index dbd45b9231..ba4b310ef3 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -153,8 +153,7 @@ class VRFBulkImportView(BulkImportView): default_return_url = 'ipam:vrf_list' -class VRFBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'ipam.change_vrf' +class VRFBulkEditView(BulkEditView): queryset = VRF.objects.prefetch_related('tenant') filterset = filters.VRFFilterSet table = tables.VRFTable @@ -365,8 +364,7 @@ class AggregateBulkImportView(BulkImportView): default_return_url = 'ipam:aggregate_list' -class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'ipam.change_aggregate' +class AggregateBulkEditView(BulkEditView): queryset = Aggregate.objects.prefetch_related('rir') filterset = filters.AggregateFilterSet table = tables.AggregateTable @@ -579,8 +577,7 @@ class PrefixBulkImportView(BulkImportView): default_return_url = 'ipam:prefix_list' -class PrefixBulkEditView(ObjectPermissionRequiredMixin, BulkEditView): - permission_required = 'ipam.change_prefix' +class PrefixBulkEditView(BulkEditView): queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') filterset = filters.PrefixFilterSet table = tables.PrefixTable @@ -746,8 +743,7 @@ class IPAddressBulkImportView(BulkImportView): default_return_url = 'ipam:ipaddress_list' -class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'ipam.change_ipaddress' +class IPAddressBulkEditView(BulkEditView): queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant').prefetch_related('interface__device') filterset = filters.IPAddressFilterSet table = tables.IPAddressTable @@ -904,8 +900,7 @@ class VLANBulkImportView(BulkImportView): default_return_url = 'ipam:vlan_list' -class VLANBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'ipam.change_vlan' +class VLANBulkEditView(BulkEditView): queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role') filterset = filters.VLANFilterSet table = tables.VLANTable @@ -972,8 +967,7 @@ class ServiceDeleteView(ObjectDeleteView): queryset = Service.objects.all() -class ServiceBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'ipam.change_service' +class ServiceBulkEditView(BulkEditView): queryset = Service.objects.prefetch_related('device', 'virtual_machine') filterset = filters.ServiceFilterSet table = tables.ServiceTable diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index d82ef67529..39e82df619 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -463,7 +463,7 @@ def test_bulk_edit_objects(self): 'data': form_data, } response = self.client.post(**request) - self.assertHttpStatus(response, 200) + self.assertHttpStatus(response, 302) self.assertEqual(Prefix.objects.get(pk=self.prefixes[3].pk).status, 'active') # Edit permitted objects diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index 00794f6840..8771336197 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -235,8 +235,7 @@ def post(self, request): }) -class SecretBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'secrets.change_secret' +class SecretBulkEditView(BulkEditView): queryset = Secret.objects.prefetch_related('role', 'device') filterset = filters.SecretFilterSet table = tables.SecretTable diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index f666e606a3..fdfcbd7f55 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -105,8 +105,7 @@ class TenantBulkImportView(BulkImportView): default_return_url = 'tenancy:tenant_list' -class TenantBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'tenancy.change_tenant' +class TenantBulkEditView(BulkEditView): queryset = Tenant.objects.prefetch_related('group') filterset = filters.TenantFilterSet table = tables.TenantTable diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 3d11cf25b6..1c8ceb525a 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -755,7 +755,7 @@ def post(self, request): }) -class BulkEditView(GetReturnURLMixin, View): +class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Edit objects in bulk. @@ -771,6 +771,9 @@ class BulkEditView(GetReturnURLMixin, View): form = None template_name = 'utilities/obj_bulk_edit.html' + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'change') + def get(self, request): return redirect(self.get_return_url(request)) @@ -781,7 +784,7 @@ def post(self, request, **kwargs): # If we are editing *all* objects in the queryset, replace the PK list with all matched objects. if request.POST.get('_all') and self.filterset is not None: pk_list = [ - obj.pk for obj in self.filterset(request.GET, model.objects.only('pk')).qs + obj.pk for obj in self.filterset(request.GET, self.queryset.only('pk')).qs ] else: pk_list = request.POST.getlist('pk') @@ -802,7 +805,7 @@ def post(self, request, **kwargs): with transaction.atomic(): updated_objects = [] - for obj in model.objects.filter(pk__in=form.cleaned_data['pk']): + for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']): # Update standard fields. If a field is listed in _nullify, delete its value. for name in standard_fields: diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index de4569b83a..e565832d83 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -124,8 +124,7 @@ class ClusterBulkImportView(BulkImportView): default_return_url = 'virtualization:cluster_list' -class ClusterBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'virtualization.change_cluster' +class ClusterBulkEditView(BulkEditView): queryset = Cluster.objects.prefetch_related('type', 'group', 'site') filterset = filters.ClusterFilterSet table = tables.ClusterTable @@ -278,8 +277,7 @@ class VirtualMachineBulkImportView(BulkImportView): default_return_url = 'virtualization:virtualmachine_list' -class VirtualMachineBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'virtualization.change_virtualmachine' +class VirtualMachineBulkEditView(BulkEditView): queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role') filterset = filters.VirtualMachineFilterSet table = tables.VirtualMachineTable @@ -317,8 +315,7 @@ class InterfaceDeleteView(ObjectDeleteView): queryset = Interface.objects.all() -class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_interface' +class InterfaceBulkEditView(BulkEditView): queryset = Interface.objects.all() table = tables.InterfaceTable form = forms.InterfaceBulkEditForm From 8fd860a413361b0c1a739e72237d57046f0f2dcb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 May 2020 15:14:29 -0400 Subject: [PATCH 026/101] Transition BulkDeleteView to use ObjectPermissionRequiredMixin --- netbox/circuits/views.py | 9 ++-- netbox/dcim/views.py | 96 ++++++++++++---------------------- netbox/extras/views.py | 6 +-- netbox/ipam/views.py | 27 ++++------ netbox/secrets/views.py | 6 +-- netbox/tenancy/views.py | 6 +-- netbox/utilities/views.py | 5 +- netbox/virtualization/views.py | 15 ++---- 8 files changed, 59 insertions(+), 111 deletions(-) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 3dc7032e4b..e3260431f4 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -86,8 +86,7 @@ class ProviderBulkEditView(BulkEditView): default_return_url = 'circuits:provider_list' -class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'circuits.delete_provider' +class ProviderBulkDeleteView(BulkDeleteView): queryset = Provider.objects.annotate(count_circuits=Count('circuits')) filterset = filters.ProviderFilterSet table = tables.ProviderTable @@ -116,8 +115,7 @@ class CircuitTypeBulkImportView(BulkImportView): default_return_url = 'circuits:circuittype_list' -class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'circuits.delete_circuittype' +class CircuitTypeBulkDeleteView(BulkDeleteView): queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')) table = tables.CircuitTypeTable default_return_url = 'circuits:circuittype_list' @@ -188,8 +186,7 @@ class CircuitBulkEditView(BulkEditView): default_return_url = 'circuits:circuit_list' -class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'circuits.delete_circuit' +class CircuitBulkDeleteView(BulkDeleteView): queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site') filterset = filters.CircuitFilterSet table = tables.CircuitTable diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 8e2355a9ce..5559d577c2 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -167,8 +167,7 @@ class RegionBulkImportView(BulkImportView): default_return_url = 'dcim:region_list' -class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_region' +class RegionBulkDeleteView(BulkDeleteView): queryset = Region.objects.all() filterset = filters.RegionFilterSet table = tables.RegionTable @@ -239,8 +238,7 @@ class SiteBulkEditView(BulkEditView): default_return_url = 'dcim:site_list' -class SiteBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_site' +class SiteBulkDeleteView(BulkDeleteView): queryset = Site.objects.prefetch_related('region', 'tenant') filterset = filters.SiteFilterSet table = tables.SiteTable @@ -277,8 +275,7 @@ class RackGroupBulkImportView(BulkImportView): default_return_url = 'dcim:rackgroup_list' -class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_rackgroup' +class RackGroupBulkDeleteView(BulkDeleteView): queryset = RackGroup.objects.prefetch_related('site').annotate(rack_count=Count('racks')) filterset = filters.RackGroupFilterSet table = tables.RackGroupTable @@ -307,8 +304,7 @@ class RackRoleBulkImportView(BulkImportView): default_return_url = 'dcim:rackrole_list' -class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_rackrole' +class RackRoleBulkDeleteView(BulkDeleteView): queryset = RackRole.objects.annotate(rack_count=Count('racks')) table = tables.RackRoleTable default_return_url = 'dcim:rackrole_list' @@ -425,8 +421,7 @@ class RackBulkEditView(BulkEditView): default_return_url = 'dcim:rack_list' -class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_rack' +class RackBulkDeleteView(BulkDeleteView): queryset = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role') filterset = filters.RackFilterSet table = tables.RackTable @@ -501,8 +496,7 @@ class RackReservationBulkEditView(BulkEditView): default_return_url = 'dcim:rackreservation_list' -class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_rackreservation' +class RackReservationBulkDeleteView(BulkDeleteView): queryset = RackReservation.objects.prefetch_related('rack', 'user') filterset = filters.RackReservationFilterSet table = tables.RackReservationTable @@ -535,8 +529,7 @@ class ManufacturerBulkImportView(BulkImportView): default_return_url = 'dcim:manufacturer_list' -class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_manufacturer' +class ManufacturerBulkDeleteView(BulkDeleteView): queryset = Manufacturer.objects.annotate(devicetype_count=Count('device_types')) table = tables.ManufacturerTable default_return_url = 'dcim:manufacturer_list' @@ -663,8 +656,7 @@ class DeviceTypeBulkEditView(BulkEditView): default_return_url = 'dcim:devicetype_list' -class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_devicetype' +class DeviceTypeBulkDeleteView(BulkDeleteView): queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances')) filterset = filters.DeviceTypeFilterSet table = tables.DeviceTypeTable @@ -698,8 +690,7 @@ class ConsolePortTemplateBulkEditView(BulkEditView): form = forms.ConsolePortTemplateBulkEditForm -class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_consoleporttemplate' +class ConsolePortTemplateBulkDeleteView(BulkDeleteView): queryset = ConsolePortTemplate.objects.all() table = tables.ConsolePortTemplateTable @@ -731,8 +722,7 @@ class ConsoleServerPortTemplateBulkEditView(BulkEditView): form = forms.ConsoleServerPortTemplateBulkEditForm -class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_consoleserverporttemplate' +class ConsoleServerPortTemplateBulkDeleteView(BulkDeleteView): queryset = ConsoleServerPortTemplate.objects.all() table = tables.ConsoleServerPortTemplateTable @@ -764,8 +754,7 @@ class PowerPortTemplateBulkEditView(BulkEditView): form = forms.PowerPortTemplateBulkEditForm -class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_powerporttemplate' +class PowerPortTemplateBulkDeleteView(BulkDeleteView): queryset = PowerPortTemplate.objects.all() table = tables.PowerPortTemplateTable @@ -797,8 +786,7 @@ class PowerOutletTemplateBulkEditView(BulkEditView): form = forms.PowerOutletTemplateBulkEditForm -class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_poweroutlettemplate' +class PowerOutletTemplateBulkDeleteView(BulkDeleteView): queryset = PowerOutletTemplate.objects.all() table = tables.PowerOutletTemplateTable @@ -830,8 +818,7 @@ class InterfaceTemplateBulkEditView(BulkEditView): form = forms.InterfaceTemplateBulkEditForm -class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_interfacetemplate' +class InterfaceTemplateBulkDeleteView(BulkDeleteView): queryset = InterfaceTemplate.objects.all() table = tables.InterfaceTemplateTable @@ -863,8 +850,7 @@ class FrontPortTemplateBulkEditView(BulkEditView): form = forms.FrontPortTemplateBulkEditForm -class FrontPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_frontporttemplate' +class FrontPortTemplateBulkDeleteView(BulkDeleteView): queryset = FrontPortTemplate.objects.all() table = tables.FrontPortTemplateTable @@ -896,8 +882,7 @@ class RearPortTemplateBulkEditView(BulkEditView): form = forms.RearPortTemplateBulkEditForm -class RearPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_rearporttemplate' +class RearPortTemplateBulkDeleteView(BulkDeleteView): queryset = RearPortTemplate.objects.all() table = tables.RearPortTemplateTable @@ -930,8 +915,7 @@ class DeviceBayTemplateDeleteView(ObjectDeleteView): # form = forms.DeviceBayTemplateBulkEditForm -class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_devicebaytemplate' +class DeviceBayTemplateBulkDeleteView(BulkDeleteView): queryset = DeviceBayTemplate.objects.all() table = tables.DeviceBayTemplateTable @@ -958,8 +942,7 @@ class DeviceRoleBulkImportView(BulkImportView): default_return_url = 'dcim:devicerole_list' -class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_devicerole' +class DeviceRoleBulkDeleteView(BulkDeleteView): queryset = DeviceRole.objects.all() table = tables.DeviceRoleTable default_return_url = 'dcim:devicerole_list' @@ -987,8 +970,7 @@ class PlatformBulkImportView(BulkImportView): default_return_url = 'dcim:platform_list' -class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_platform' +class PlatformBulkDeleteView(BulkDeleteView): queryset = Platform.objects.all() table = tables.PlatformTable default_return_url = 'dcim:platform_list' @@ -1201,8 +1183,7 @@ class DeviceBulkEditView(BulkEditView): default_return_url = 'dcim:device_list' -class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_device' +class DeviceBulkDeleteView(BulkDeleteView): queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer') filterset = filters.DeviceFilterSet table = tables.DeviceTable @@ -1252,8 +1233,7 @@ class ConsolePortBulkEditView(BulkEditView): form = forms.ConsolePortBulkEditForm -class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_consoleport' +class ConsolePortBulkDeleteView(BulkDeleteView): queryset = ConsolePort.objects.all() filterset = filters.ConsolePortFilterSet table = tables.ConsolePortTable @@ -1315,8 +1295,7 @@ class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnec form = forms.ConsoleServerPortBulkDisconnectForm -class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_consoleserverport' +class ConsoleServerPortBulkDeleteView(BulkDeleteView): queryset = ConsoleServerPort.objects.all() filterset = filters.ConsoleServerPortFilterSet table = tables.ConsoleServerPortTable @@ -1366,8 +1345,7 @@ class PowerPortBulkEditView(BulkEditView): form = forms.PowerPortBulkEditForm -class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_powerport' +class PowerPortBulkDeleteView(BulkDeleteView): queryset = PowerPort.objects.all() filterset = filters.PowerPortFilterSet table = tables.PowerPortTable @@ -1429,8 +1407,7 @@ class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView) form = forms.PowerOutletBulkDisconnectForm -class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_poweroutlet' +class PowerOutletBulkDeleteView(BulkDeleteView): queryset = PowerOutlet.objects.all() filterset = filters.PowerOutletFilterSet table = tables.PowerOutletTable @@ -1529,8 +1506,7 @@ class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): form = forms.InterfaceBulkDisconnectForm -class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_interface' +class InterfaceBulkDeleteView(BulkDeleteView): queryset = Interface.objects.all() filterset = filters.InterfaceFilterSet table = tables.InterfaceTable @@ -1592,8 +1568,7 @@ class FrontPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): form = forms.FrontPortBulkDisconnectForm -class FrontPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_frontport' +class FrontPortBulkDeleteView(BulkDeleteView): queryset = FrontPort.objects.all() filterset = filters.FrontPortFilterSet table = tables.FrontPortTable @@ -1655,8 +1630,7 @@ class RearPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): form = forms.RearPortBulkDisconnectForm -class RearPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_rearport' +class RearPortBulkDeleteView(BulkDeleteView): queryset = RearPort.objects.all() filterset = filters.RearPortFilterSet table = tables.RearPortTable @@ -1783,8 +1757,7 @@ class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView): form = forms.DeviceBayBulkRenameForm -class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_devicebay' +class DeviceBayBulkDeleteView(BulkDeleteView): queryset = DeviceBay.objects.all() filterset = filters.DeviceBayFilterSet table = tables.DeviceBayTable @@ -2048,8 +2021,7 @@ class CableBulkEditView(BulkEditView): default_return_url = 'dcim:cable_list' -class CableBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_cable' +class CableBulkDeleteView(BulkDeleteView): queryset = Cable.objects.prefetch_related('termination_a', 'termination_b') filterset = filters.CableFilterSet table = tables.CableTable @@ -2203,8 +2175,7 @@ class InventoryItemBulkEditView(BulkEditView): default_return_url = 'dcim:inventoryitem_list' -class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_inventoryitem' +class InventoryItemBulkDeleteView(BulkDeleteView): queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer') table = tables.InventoryItemTable template_name = 'dcim/inventoryitem_bulk_delete.html' @@ -2468,8 +2439,7 @@ class VirtualChassisBulkEditView(BulkEditView): default_return_url = 'dcim:virtualchassis_list' -class VirtualChassisBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_virtualchassis' +class VirtualChassisBulkDeleteView(BulkDeleteView): queryset = VirtualChassis.objects.all() filterset = filters.VirtualChassisFilterSet table = tables.VirtualChassisTable @@ -2535,8 +2505,7 @@ class PowerPanelBulkEditView(BulkEditView): default_return_url = 'dcim:powerpanel_list' -class PowerPanelBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_powerpanel' +class PowerPanelBulkDeleteView(BulkDeleteView): queryset = PowerPanel.objects.prefetch_related( 'site', 'rack_group' ).annotate( @@ -2599,8 +2568,7 @@ class PowerFeedBulkEditView(BulkEditView): default_return_url = 'dcim:powerfeed_list' -class PowerFeedBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_powerfeed' +class PowerFeedBulkDeleteView(BulkDeleteView): queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack') filterset = filters.PowerFeedFilterSet table = tables.PowerFeedTable diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 3aadbda983..0a3796a283 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -87,8 +87,7 @@ class TagBulkEditView(BulkEditView): default_return_url = 'extras:tag_list' -class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'extras.delete_tag' +class TagBulkDeleteView(BulkDeleteView): queryset = Tag.objects.annotate( items=Count('extras_taggeditem_items') ).order_by( @@ -149,8 +148,7 @@ class ConfigContextDeleteView(ObjectDeleteView): default_return_url = 'extras:configcontext_list' -class ConfigContextBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'extras.delete_configcontext' +class ConfigContextBulkDeleteView(BulkDeleteView): queryset = ConfigContext.objects.all() table = ConfigContextTable default_return_url = 'extras:configcontext_list' diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index ba4b310ef3..19d38be5d7 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -161,8 +161,7 @@ class VRFBulkEditView(BulkEditView): default_return_url = 'ipam:vrf_list' -class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'ipam.delete_vrf' +class VRFBulkDeleteView(BulkDeleteView): queryset = VRF.objects.prefetch_related('tenant') filterset = filters.VRFFilterSet table = tables.VRFTable @@ -262,8 +261,7 @@ class RIRBulkImportView(BulkImportView): default_return_url = 'ipam:rir_list' -class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'ipam.delete_rir' +class RIRBulkDeleteView(BulkDeleteView): queryset = RIR.objects.annotate(aggregate_count=Count('aggregates')) filterset = filters.RIRFilterSet table = tables.RIRTable @@ -372,8 +370,7 @@ class AggregateBulkEditView(BulkEditView): default_return_url = 'ipam:aggregate_list' -class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'ipam.delete_aggregate' +class AggregateBulkDeleteView(BulkDeleteView): queryset = Aggregate.objects.prefetch_related('rir') filterset = filters.AggregateFilterSet table = tables.AggregateTable @@ -402,8 +399,7 @@ class RoleBulkImportView(BulkImportView): default_return_url = 'ipam:role_list' -class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'ipam.delete_role' +class RoleBulkDeleteView(BulkDeleteView): queryset = Role.objects.all() table = tables.RoleTable default_return_url = 'ipam:role_list' @@ -585,8 +581,7 @@ class PrefixBulkEditView(BulkEditView): default_return_url = 'ipam:prefix_list' -class PrefixBulkDeleteView(ObjectPermissionRequiredMixin, BulkDeleteView): - permission_required = 'ipam.delete_prefix' +class PrefixBulkDeleteView(BulkDeleteView): queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') filterset = filters.PrefixFilterSet table = tables.PrefixTable @@ -751,8 +746,7 @@ class IPAddressBulkEditView(BulkEditView): default_return_url = 'ipam:ipaddress_list' -class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'ipam.delete_ipaddress' +class IPAddressBulkDeleteView(BulkDeleteView): queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant').prefetch_related('interface__device') filterset = filters.IPAddressFilterSet table = tables.IPAddressTable @@ -784,8 +778,7 @@ class VLANGroupBulkImportView(BulkImportView): default_return_url = 'ipam:vlangroup_list' -class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'ipam.delete_vlangroup' +class VLANGroupBulkDeleteView(BulkDeleteView): queryset = VLANGroup.objects.prefetch_related('site').annotate(vlan_count=Count('vlans')) filterset = filters.VLANGroupFilterSet table = tables.VLANGroupTable @@ -908,8 +901,7 @@ class VLANBulkEditView(BulkEditView): default_return_url = 'ipam:vlan_list' -class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'ipam.delete_vlan' +class VLANBulkDeleteView(BulkDeleteView): queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role') filterset = filters.VLANFilterSet table = tables.VLANTable @@ -975,8 +967,7 @@ class ServiceBulkEditView(BulkEditView): default_return_url = 'ipam:service_list' -class ServiceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'ipam.delete_service' +class ServiceBulkDeleteView(BulkDeleteView): queryset = Service.objects.prefetch_related('device', 'virtual_machine') filterset = filters.ServiceFilterSet table = tables.ServiceTable diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index 8771336197..dbcf72262b 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -48,8 +48,7 @@ class SecretRoleBulkImportView(BulkImportView): default_return_url = 'secrets:secretrole_list' -class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'secrets.delete_secretrole' +class SecretRoleBulkDeleteView(BulkDeleteView): queryset = SecretRole.objects.annotate(secret_count=Count('secrets')) table = tables.SecretRoleTable default_return_url = 'secrets:secretrole_list' @@ -243,8 +242,7 @@ class SecretBulkEditView(BulkEditView): default_return_url = 'secrets:secret_list' -class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'secrets.delete_secret' +class SecretBulkDeleteView(BulkDeleteView): queryset = Secret.objects.prefetch_related('role', 'device') filterset = filters.SecretFilterSet table = tables.SecretTable diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index fdfcbd7f55..3de321301c 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -42,8 +42,7 @@ class TenantGroupBulkImportView(BulkImportView): default_return_url = 'tenancy:tenantgroup_list' -class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'tenancy.delete_tenantgroup' +class TenantGroupBulkDeleteView(BulkDeleteView): queryset = TenantGroup.objects.annotate(tenant_count=Count('tenants')) table = tables.TenantGroupTable default_return_url = 'tenancy:tenantgroup_list' @@ -113,8 +112,7 @@ class TenantBulkEditView(BulkEditView): default_return_url = 'tenancy:tenant_list' -class TenantBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'tenancy.delete_tenant' +class TenantBulkDeleteView(BulkDeleteView): queryset = Tenant.objects.prefetch_related('group') filterset = filters.TenantFilterSet table = tables.TenantTable diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 1c8ceb525a..6a1086c942 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -911,7 +911,7 @@ def post(self, request, **kwargs): }) -class BulkDeleteView(GetReturnURLMixin, View): +class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Delete objects in bulk. @@ -927,6 +927,9 @@ class BulkDeleteView(GetReturnURLMixin, View): form = None template_name = 'utilities/obj_bulk_delete.html' + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'delete') + def get(self, request): return redirect(self.get_return_url(request)) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index e565832d83..898648f90a 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -40,8 +40,7 @@ class ClusterTypeBulkImportView(BulkImportView): default_return_url = 'virtualization:clustertype_list' -class ClusterTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'virtualization.delete_clustertype' +class ClusterTypeBulkDeleteView(BulkDeleteView): queryset = ClusterType.objects.annotate(cluster_count=Count('clusters')) table = tables.ClusterTypeTable default_return_url = 'virtualization:clustertype_list' @@ -69,8 +68,7 @@ class ClusterGroupBulkImportView(BulkImportView): default_return_url = 'virtualization:clustergroup_list' -class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'virtualization.delete_clustergroup' +class ClusterGroupBulkDeleteView(BulkDeleteView): queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters')) table = tables.ClusterGroupTable default_return_url = 'virtualization:clustergroup_list' @@ -132,8 +130,7 @@ class ClusterBulkEditView(BulkEditView): default_return_url = 'virtualization:cluster_list' -class ClusterBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'virtualization.delete_cluster' +class ClusterBulkDeleteView(BulkDeleteView): queryset = Cluster.objects.prefetch_related('type', 'group', 'site') filterset = filters.ClusterFilterSet table = tables.ClusterTable @@ -285,8 +282,7 @@ class VirtualMachineBulkEditView(BulkEditView): default_return_url = 'virtualization:virtualmachine_list' -class VirtualMachineBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'virtualization.delete_virtualmachine' +class VirtualMachineBulkDeleteView(BulkDeleteView): queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role') filterset = filters.VirtualMachineFilterSet table = tables.VirtualMachineTable @@ -321,8 +317,7 @@ class InterfaceBulkEditView(BulkEditView): form = forms.InterfaceBulkEditForm -class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_interface' +class InterfaceBulkDeleteView(BulkDeleteView): queryset = Interface.objects.all() table = tables.InterfaceTable From e61fc1f7090a70e483df7fdcc556263b8aea6e25 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 May 2020 15:39:07 -0400 Subject: [PATCH 027/101] Introduce ObjectView to enforce object-level permissions for individual object views --- docs/development/utility-views.md | 4 ++ netbox/circuits/views.py | 16 +++---- netbox/dcim/views.py | 80 ++++++++++++++++--------------- netbox/extras/views.py | 20 ++++---- netbox/ipam/views.py | 57 +++++++++++----------- netbox/secrets/views.py | 9 ++-- netbox/tenancy/views.py | 10 ++-- netbox/utilities/views.py | 12 +++++ netbox/virtualization/views.py | 16 +++---- 9 files changed, 118 insertions(+), 106 deletions(-) diff --git a/docs/development/utility-views.md b/docs/development/utility-views.md index a6e50f71ea..3b9c1053dc 100644 --- a/docs/development/utility-views.md +++ b/docs/development/utility-views.md @@ -4,6 +4,10 @@ Utility views are reusable views that handle common CRUD tasks, such as listing ## Individual Views +### ObjectView + +Retrieve and display a single object. + ### ObjectListView Generates a paginated table of objects from a given queryset, which may optionally be filtered. diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index e3260431f4..1f5f052306 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -1,18 +1,16 @@ from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import permission_required -from django.contrib.auth.mixins import PermissionRequiredMixin from django.db import transaction from django.db.models import Count, OuterRef from django.shortcuts import get_object_or_404, redirect, render -from django.views.generic import View from django_tables2 import RequestConfig from extras.models import Graph from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator from utilities.views import ( - BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, + BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView, ) from . import filters, forms, tables from .choices import CircuitTerminationSideChoices @@ -30,12 +28,12 @@ class ProviderListView(ObjectListView): table = tables.ProviderTable -class ProviderView(PermissionRequiredMixin, View): - permission_required = 'circuits.view_provider' +class ProviderView(ObjectView): + queryset = Provider.objects.all() def get(self, request, slug): - provider = get_object_or_404(Provider, slug=slug) + provider = get_object_or_404(self.queryset, slug=slug) circuits = Circuit.objects.filter( provider=provider ).prefetch_related( @@ -135,12 +133,12 @@ class CircuitListView(ObjectListView): table = tables.CircuitTable -class CircuitView(PermissionRequiredMixin, View): - permission_required = 'circuits.view_circuit' +class CircuitView(ObjectView): + queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant__group') def get(self, request, pk): - circuit = get_object_or_404(Circuit.objects.prefetch_related('provider', 'type', 'tenant__group'), pk=pk) + circuit = get_object_or_404(self.queryset, pk=pk) termination_a = CircuitTermination.objects.prefetch_related( 'site__region', 'connected_endpoint__device' ).filter( diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 5559d577c2..fb60b6b315 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -26,7 +26,7 @@ from utilities.utils import csv_format from utilities.views import ( BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin, - ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView, ObjectPermissionRequiredMixin, + ObjectView, ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView, ObjectPermissionRequiredMixin, ) from virtualization.models import VirtualMachine from . import filters, forms, tables @@ -185,8 +185,7 @@ class SiteListView(ObjectListView): table = tables.SiteTable -class SiteView(ObjectPermissionRequiredMixin, View): - permission_required = 'dcim.view_site' +class SiteView(ObjectView): queryset = Site.objects.prefetch_related('region', 'tenant__group') def get(self, request, slug): @@ -362,12 +361,12 @@ def get(self, request): }) -class RackView(PermissionRequiredMixin, View): - permission_required = 'dcim.view_rack' +class RackView(ObjectView): + queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role') def get(self, request, pk): - rack = get_object_or_404(Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role'), pk=pk) + rack = get_object_or_404(self.queryset, pk=pk) nonracked_devices = Device.objects.filter( rack=rack, @@ -440,12 +439,12 @@ class RackReservationListView(ObjectListView): action_buttons = ('export',) -class RackReservationView(PermissionRequiredMixin, View): - permission_required = 'dcim.view_rackreservation' +class RackReservationView(ObjectView): + queryset = RackReservation.objects.prefetch_related('rack') def get(self, request, pk): - rackreservation = get_object_or_404(RackReservation.objects.prefetch_related('rack'), pk=pk) + rackreservation = get_object_or_404(self.queryset, pk=pk) return render(request, 'dcim/rackreservation.html', { 'rackreservation': rackreservation, @@ -546,12 +545,12 @@ class DeviceTypeListView(ObjectListView): table = tables.DeviceTypeTable -class DeviceTypeView(PermissionRequiredMixin, View): - permission_required = 'dcim.view_devicetype' +class DeviceTypeView(ObjectView): + queryset = DeviceType.objects.prefetch_related('manufacturer') def get(self, request, pk): - devicetype = get_object_or_404(DeviceType, pk=pk) + devicetype = get_object_or_404(self.queryset, pk=pk) # Component tables consoleport_table = tables.ConsolePortTemplateTable( @@ -990,14 +989,14 @@ class DeviceListView(ObjectListView): template_name = 'dcim/device_list.html' -class DeviceView(PermissionRequiredMixin, View): - permission_required = 'dcim.view_device' +class DeviceView(ObjectView): + queryset = Device.objects.prefetch_related( + 'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform' + ) def get(self, request, pk): - device = get_object_or_404(Device.objects.prefetch_related( - 'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform' - ), pk=pk) + device = get_object_or_404(self.queryset, pk=pk) # VirtualChassis members if device.virtual_chassis is not None: @@ -1068,12 +1067,12 @@ def get(self, request, pk): }) -class DeviceInventoryView(PermissionRequiredMixin, View): - permission_required = 'dcim.view_device' +class DeviceInventoryView(ObjectView): + queryset = Device.objects.all() def get(self, request, pk): - device = get_object_or_404(Device, pk=pk) + device = get_object_or_404(self.queryset, pk=pk) inventory_items = InventoryItem.objects.filter( device=device, parent=None ).prefetch_related( @@ -1087,12 +1086,13 @@ def get(self, request, pk): }) -class DeviceStatusView(PermissionRequiredMixin, View): +class DeviceStatusView(ObjectView): permission_required = ('dcim.view_device', 'dcim.napalm_read') + queryset = Device.objects.all() def get(self, request, pk): - device = get_object_or_404(Device, pk=pk) + device = get_object_or_404(self.queryset, pk=pk) return render(request, 'dcim/device_status.html', { 'device': device, @@ -1102,10 +1102,11 @@ def get(self, request, pk): class DeviceLLDPNeighborsView(PermissionRequiredMixin, View): permission_required = ('dcim.view_device', 'dcim.napalm_read') + queryset = Device.objects.all() def get(self, request, pk): - device = get_object_or_404(Device, pk=pk) + device = get_object_or_404(self.queryset, pk=pk) interfaces = device.vc_interfaces.exclude(type__in=NONCONNECTABLE_IFACE_TYPES).prefetch_related( '_connected_interface__device' ) @@ -1119,10 +1120,11 @@ def get(self, request, pk): class DeviceConfigView(PermissionRequiredMixin, View): permission_required = ('dcim.view_device', 'dcim.napalm_read') + queryset = Device.objects.all() def get(self, request, pk): - device = get_object_or_404(Device, pk=pk) + device = get_object_or_404(self.queryset, pk=pk) return render(request, 'dcim/device_config.html', { 'device': device, @@ -1426,12 +1428,12 @@ class InterfaceListView(ObjectListView): action_buttons = ('import', 'export') -class InterfaceView(PermissionRequiredMixin, View): - permission_required = 'dcim.view_interface' +class InterfaceView(ObjectView): + queryset = Interface.objects.all() def get(self, request, pk): - interface = get_object_or_404(Interface, pk=pk) + interface = get_object_or_404(self.queryset, pk=pk) # Get assigned IP addresses ipaddress_table = InterfaceIPAddressTable( @@ -1878,12 +1880,12 @@ class CableListView(ObjectListView): action_buttons = ('import', 'export') -class CableView(PermissionRequiredMixin, View): - permission_required = 'dcim.view_cable' +class CableView(ObjectView): + queryset = Cable.objects.all() def get(self, request, pk): - cable = get_object_or_404(Cable, pk=pk) + cable = get_object_or_404(self.queryset, pk=pk) return render(request, 'dcim/cable.html', { 'cable': cable, @@ -2194,11 +2196,11 @@ class VirtualChassisListView(ObjectListView): action_buttons = ('export',) -class VirtualChassisView(PermissionRequiredMixin, View): - permission_required = 'dcim.view_virtualchassis' +class VirtualChassisView(ObjectView): + queryset = VirtualChassis.objects.prefetch_related('members') def get(self, request, pk): - virtualchassis = get_object_or_404(VirtualChassis.objects.prefetch_related('members'), pk=pk) + virtualchassis = get_object_or_404(self.queryset, pk=pk) return render(request, 'dcim/virtualchassis.html', { 'virtualchassis': virtualchassis, @@ -2461,12 +2463,12 @@ class PowerPanelListView(ObjectListView): table = tables.PowerPanelTable -class PowerPanelView(PermissionRequiredMixin, View): - permission_required = 'dcim.view_powerpanel' +class PowerPanelView(ObjectView): + queryset = PowerPanel.objects.prefetch_related('site', 'rack_group') def get(self, request, pk): - powerpanel = get_object_or_404(PowerPanel.objects.prefetch_related('site', 'rack_group'), pk=pk) + powerpanel = get_object_or_404(self.queryset, pk=pk) powerfeed_table = tables.PowerFeedTable( data=PowerFeed.objects.filter(power_panel=powerpanel).prefetch_related('rack'), orderable=False @@ -2529,12 +2531,12 @@ class PowerFeedListView(ObjectListView): table = tables.PowerFeedTable -class PowerFeedView(PermissionRequiredMixin, View): - permission_required = 'dcim.view_powerfeed' +class PowerFeedView(ObjectView): + queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack') def get(self, request, pk): - powerfeed = get_object_or_404(PowerFeed.objects.prefetch_related('power_panel', 'rack'), pk=pk) + powerfeed = get_object_or_404(self.queryset, pk=pk) return render(request, 'dcim/powerfeed.html', { 'powerfeed': powerfeed, diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 0a3796a283..78db8f24aa 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -13,7 +13,7 @@ from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator from utilities.utils import shallow_compare_dict -from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView +from utilities.views import BulkDeleteView, BulkEditView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView from . import filters, forms from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem from .reports import get_report, get_reports @@ -37,12 +37,12 @@ class TagListView(ObjectListView): action_buttons = () -class TagView(PermissionRequiredMixin, View): - permission_required = 'extras.view_tag' +class TagView(ObjectView): + queryset = Tag.objects.all() def get(self, request, slug): - tag = get_object_or_404(Tag, slug=slug) + tag = get_object_or_404(self.queryset, slug=slug) tagged_items = TaggedItem.objects.filter( tag=tag ).prefetch_related( @@ -109,11 +109,11 @@ class ConfigContextListView(ObjectListView): action_buttons = ('add',) -class ConfigContextView(PermissionRequiredMixin, View): - permission_required = 'extras.view_configcontext' +class ConfigContextView(ObjectView): + queryset = ConfigContext.objects.all() def get(self, request, pk): - configcontext = get_object_or_404(ConfigContext, pk=pk) + configcontext = get_object_or_404(self.queryset, pk=pk) # Determine user's preferred output format if request.GET.get('format') in ['json', 'yaml']: @@ -195,12 +195,12 @@ class ObjectChangeListView(ObjectListView): action_buttons = ('export',) -class ObjectChangeView(PermissionRequiredMixin, View): - permission_required = 'extras.view_objectchange' +class ObjectChangeView(ObjectView): + queryset = ObjectChange.objects.all() def get(self, request, pk): - objectchange = get_object_or_404(ObjectChange, pk=pk) + objectchange = get_object_or_404(self.queryset, pk=pk) related_changes = ObjectChange.objects.filter(request_id=objectchange.request_id).exclude(pk=objectchange.pk) related_changes_table = ObjectChangeTable( diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 19d38be5d7..706f819cc5 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -10,8 +10,8 @@ from dcim.models import Device, Interface from utilities.paginator import EnhancedPaginator from utilities.views import ( - BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, - ObjectPermissionRequiredMixin, + BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, + ObjectListView, ) from virtualization.models import VirtualMachine from . import filters, forms, tables @@ -120,12 +120,12 @@ class VRFListView(ObjectListView): table = tables.VRFTable -class VRFView(PermissionRequiredMixin, View): - permission_required = 'ipam.view_vrf' +class VRFView(ObjectView): + queryset = VRF.objects.all() def get(self, request, pk): - vrf = get_object_or_404(VRF.objects.all(), pk=pk) + vrf = get_object_or_404(self.queryset, pk=pk) prefix_count = Prefix.objects.filter(vrf=vrf).count() return render(request, 'ipam/vrf.html', { @@ -298,12 +298,12 @@ def extra_context(self): } -class AggregateView(PermissionRequiredMixin, View): - permission_required = 'ipam.view_aggregate' +class AggregateView(ObjectView): + queryset = Aggregate.objects.all() def get(self, request, pk): - aggregate = get_object_or_404(Aggregate, pk=pk) + aggregate = get_object_or_404(self.queryset, pk=pk) # Find all child prefixes contained by this aggregate child_prefixes = Prefix.objects.filter( @@ -422,8 +422,7 @@ def alter_queryset(self, request): return self.queryset.annotate_depth(limit=limit) -class PrefixView(ObjectPermissionRequiredMixin, View): - permission_required = 'ipam.view_prefix' +class PrefixView(ObjectView): queryset = Prefix.objects.prefetch_related('vrf', 'site__region', 'tenant__group', 'vlan__group', 'role') def get(self, request, pk): @@ -465,12 +464,12 @@ def get(self, request, pk): }) -class PrefixPrefixesView(PermissionRequiredMixin, View): - permission_required = 'ipam.view_prefix' +class PrefixPrefixesView(ObjectView): + queryset = Prefix.objects.all() def get(self, request, pk): - prefix = get_object_or_404(Prefix.objects.all(), pk=pk) + prefix = get_object_or_404(self.queryset, pk=pk) # Child prefixes table child_prefixes = prefix.get_child_prefixes().prefetch_related( @@ -509,12 +508,12 @@ def get(self, request, pk): }) -class PrefixIPAddressesView(PermissionRequiredMixin, View): - permission_required = 'ipam.view_prefix' +class PrefixIPAddressesView(ObjectView): + queryset = Prefix.objects.all() def get(self, request, pk): - prefix = get_object_or_404(Prefix.objects.all(), pk=pk) + prefix = get_object_or_404(self.queryset, pk=pk) # Find all IPAddresses belonging to this Prefix ipaddresses = prefix.get_child_ips().prefetch_related( @@ -601,12 +600,12 @@ class IPAddressListView(ObjectListView): table = tables.IPAddressDetailTable -class IPAddressView(PermissionRequiredMixin, View): - permission_required = 'ipam.view_ipaddress' +class IPAddressView(ObjectView): + queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant') def get(self, request, pk): - ipaddress = get_object_or_404(IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'), pk=pk) + ipaddress = get_object_or_404(self.queryset, pk=pk) # Parent prefixes table parent_prefixes = Prefix.objects.filter( @@ -833,14 +832,12 @@ class VLANListView(ObjectListView): table = tables.VLANDetailTable -class VLANView(PermissionRequiredMixin, View): - permission_required = 'ipam.view_vlan' +class VLANView(ObjectView): + queryset = VLAN.objects.prefetch_related('site__region', 'tenant__group', 'role') def get(self, request, pk): - vlan = get_object_or_404(VLAN.objects.prefetch_related( - 'site__region', 'tenant__group', 'role' - ), pk=pk) + vlan = get_object_or_404(self.queryset, pk=pk) prefixes = Prefix.objects.filter(vlan=vlan).prefetch_related('vrf', 'site', 'role') prefix_table = tables.PrefixTable(list(prefixes), orderable=False) prefix_table.exclude = ('vlan',) @@ -851,12 +848,12 @@ def get(self, request, pk): }) -class VLANMembersView(PermissionRequiredMixin, View): - permission_required = 'ipam.view_vlan' +class VLANMembersView(ObjectView): + queryset = VLAN.objects.all() def get(self, request, pk): - vlan = get_object_or_404(VLAN.objects.all(), pk=pk) + vlan = get_object_or_404(self.queryset, pk=pk) members = vlan.get_members().prefetch_related('device', 'virtual_machine') members_table = tables.VLANMemberTable(members) @@ -920,12 +917,12 @@ class ServiceListView(ObjectListView): action_buttons = ('export',) -class ServiceView(PermissionRequiredMixin, View): - permission_required = 'ipam.view_service' +class ServiceView(ObjectView): + queryset = Service.objects.all() def get(self, request, pk): - service = get_object_or_404(Service, pk=pk) + service = get_object_or_404(self.queryset, pk=pk) return render(request, 'ipam/service.html', { 'service': service, diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index dbcf72262b..a2e627a7ca 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -9,7 +9,8 @@ from django.views.generic import View from utilities.views import ( - BulkDeleteView, BulkEditView, BulkImportView, GetReturnURLMixin, ObjectDeleteView, ObjectEditView, ObjectListView, + BulkDeleteView, BulkEditView, BulkImportView, GetReturnURLMixin, ObjectView, ObjectDeleteView, ObjectEditView, + ObjectListView, ) from . import filters, forms, tables from .decorators import userkey_required @@ -66,12 +67,12 @@ class SecretListView(ObjectListView): action_buttons = ('import', 'export') -class SecretView(PermissionRequiredMixin, View): - permission_required = 'secrets.view_secret' +class SecretView(ObjectView): + queryset = Secret.objects.all() def get(self, request, pk): - secret = get_object_or_404(Secret, pk=pk) + secret = get_object_or_404(self.queryset, pk=pk) return render(request, 'secrets/secret.html', { 'secret': secret, diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 3de321301c..823df99332 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -1,13 +1,11 @@ -from django.contrib.auth.mixins import PermissionRequiredMixin from django.db.models import Count from django.shortcuts import get_object_or_404, render -from django.views.generic import View from circuits.models import Circuit from dcim.models import Site, Rack, Device, RackReservation from ipam.models import IPAddress, Prefix, VLAN, VRF from utilities.views import ( - BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, + BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView, ) from virtualization.models import VirtualMachine, Cluster from . import filters, forms, tables @@ -59,12 +57,12 @@ class TenantListView(ObjectListView): table = tables.TenantTable -class TenantView(PermissionRequiredMixin, View): - permission_required = 'tenancy.view_tenant' +class TenantView(ObjectView): + queryset = Tenant.objects.prefetch_related('group') def get(self, request, slug): - tenant = get_object_or_404(Tenant, slug=slug) + tenant = get_object_or_404(self.queryset, slug=slug) stats = { 'site_count': Site.objects.filter(tenant=tenant).count(), 'rack_count': Rack.objects.filter(tenant=tenant).count(), diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 6a1086c942..bd612b4df3 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -118,6 +118,18 @@ def get_return_url(self, request, obj=None): # Generic views # +class ObjectView(ObjectPermissionRequiredMixin, View): + """ + Retrieve a single object for display. + + :param queryset: The base queryset for retrieving the object. + """ + queryset = None + + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'view') + + class ObjectListView(ObjectPermissionRequiredMixin, View): """ List a series of objects. diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 898648f90a..53fcf96975 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -11,8 +11,8 @@ from extras.views import ObjectConfigContextView from ipam.models import Service from utilities.views import ( - BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView, - ObjectEditView, ObjectListView, + BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectView, + ObjectDeleteView, ObjectEditView, ObjectListView, ) from . import filters, forms, tables from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -85,12 +85,12 @@ class ClusterListView(ObjectListView): filterset_form = forms.ClusterFilterForm -class ClusterView(PermissionRequiredMixin, View): - permission_required = 'virtualization.view_cluster' +class ClusterView(ObjectView): + queryset = Cluster.objects.all() def get(self, request, pk): - cluster = get_object_or_404(Cluster, pk=pk) + cluster = get_object_or_404(self.queryset, pk=pk) devices = Device.objects.filter(cluster=cluster).prefetch_related( 'site', 'rack', 'tenant', 'device_type__manufacturer' ) @@ -233,12 +233,12 @@ class VirtualMachineListView(ObjectListView): template_name = 'virtualization/virtualmachine_list.html' -class VirtualMachineView(PermissionRequiredMixin, View): - permission_required = 'virtualization.view_virtualmachine' +class VirtualMachineView(ObjectView): + queryset = VirtualMachine.objects.prefetch_related('tenant__group') def get(self, request, pk): - virtualmachine = get_object_or_404(VirtualMachine.objects.prefetch_related('tenant__group'), pk=pk) + virtualmachine = get_object_or_404(self.queryset, pk=pk) interfaces = Interface.objects.filter(virtual_machine=virtualmachine) services = Service.objects.filter(virtual_machine=virtualmachine) From 91362b0f821bad2a0634eb6e582dbcbbe12d0745 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 May 2020 15:53:50 -0400 Subject: [PATCH 028/101] Transition BulkCreateView to use ObjectPermissionRequiredMixin --- netbox/ipam/views.py | 2 +- netbox/utilities/views.py | 26 ++++++++++++++++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 706f819cc5..476943b137 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -721,7 +721,7 @@ class IPAddressDeleteView(ObjectDeleteView): default_return_url = 'ipam:ipaddress_list' -class IPAddressBulkCreateView(PermissionRequiredMixin, BulkCreateView): +class IPAddressBulkCreateView(BulkCreateView): permission_required = 'ipam.add_ipaddress' form = forms.IPAddressBulkCreateForm model_form = forms.IPAddressBulkAddForm diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index bd612b4df3..ba1c18acc0 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -468,20 +468,25 @@ def post(self, request, **kwargs): }) -class BulkCreateView(GetReturnURLMixin, View): +class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Create new objects in bulk. - form: Form class which provides the `pattern` field - model_form: The ModelForm used to create individual objects - pattern_target: Name of the field to be evaluated as a pattern (if any) - template_name: The name of the template + :param queryset: Base queryset for the objects being created + :param form: Form class which provides the `pattern` field + :param model_form: The ModelForm used to create individual objects + :param pattern_target: Name of the field to be evaluated as a pattern (if any) + :param template_name: The name of the template """ + queryset = None form = None model_form = None pattern_target = '' template_name = None + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'add') + def get(self, request): # Set initial values for visible form fields from query args initial = {} @@ -501,7 +506,7 @@ def get(self, request): def post(self, request): logger = logging.getLogger('netbox.views.BulkCreateView') - model = self.model_form._meta.model + model = self.queryset.model form = self.form(request.POST) model_form = self.model_form(request.POST) @@ -534,6 +539,10 @@ def post(self, request): # Raise an IntegrityError to break the for loop and abort the transaction. raise IntegrityError() + # Enforce object-level permissions + if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs): + raise ObjectDoesNotExist + # If we make it to this point, validation has succeeded on all new objects. msg = "Added {} {}".format(len(new_objs), model._meta.verbose_name_plural) logger.info(msg) @@ -546,6 +555,11 @@ def post(self, request): except IntegrityError: pass + except ObjectDoesNotExist: + msg = "Object creation failed due to object-level permissions violation" + logger.debug(msg) + form.add_error(None, msg) + else: logger.debug("Form validation failed") From af8e1a647273bd991907884d2e1a738fea49bfaa Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 May 2020 16:00:18 -0400 Subject: [PATCH 029/101] Strip 'param' indicators from docstrings --- netbox/utilities/views.py | 62 +++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index ba1c18acc0..cc0c7596dc 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -122,7 +122,7 @@ class ObjectView(ObjectPermissionRequiredMixin, View): """ Retrieve a single object for display. - :param queryset: The base queryset for retrieving the object. + queryset: The base queryset for retrieving the object. """ queryset = None @@ -134,11 +134,11 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): """ List a series of objects. - :param queryset: The queryset of objects to display - :param filter: A django-filter FilterSet that is applied to the queryset - :param filter_form: The form used to render filter options - :param table: The django-tables2 Table used to render the objects list - :param template_name: The name of the template + queryset: The queryset of objects to display + filter: A django-filter FilterSet that is applied to the queryset + filter_form: The form used to render filter options + table: The django-tables2 Table used to render the objects list + template_name: The name of the template """ queryset = None filterset = None @@ -294,9 +294,9 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Create or edit a single object. - :param queryset: The base queryset for the object being modified - :param model_form: The form used to create or edit the object - :param template_name: The name of the template + queryset: The base queryset for the object being modified + model_form: The form used to create or edit the object + template_name: The name of the template """ queryset = None model_form = None @@ -405,8 +405,8 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Delete a single object. - :param queryset: The base queryset for the object being deleted - :param template_name: The name of the template + queryset: The base queryset for the object being deleted + template_name: The name of the template """ queryset = None template_name = 'utilities/obj_delete.html' @@ -472,11 +472,11 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Create new objects in bulk. - :param queryset: Base queryset for the objects being created - :param form: Form class which provides the `pattern` field - :param model_form: The ModelForm used to create individual objects - :param pattern_target: Name of the field to be evaluated as a pattern (if any) - :param template_name: The name of the template + queryset: Base queryset for the objects being created + form: Form class which provides the `pattern` field + model_form: The ModelForm used to create individual objects + pattern_target: Name of the field to be evaluated as a pattern (if any) + template_name: The name of the template """ queryset = None form = None @@ -682,11 +682,11 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Import objects in bulk (CSV format). - :param queryset: Base queryset for the model - :param model_form: The form used to create each imported object - :param table: The django-tables2 Table used to render the list of imported objects - :param template_name: The name of the template - :param widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key) + queryset: Base queryset for the model + model_form: The form used to create each imported object + table: The django-tables2 Table used to render the list of imported objects + template_name: The name of the template + widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key) """ queryset = None model_form = None @@ -785,11 +785,11 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Edit objects in bulk. - :param queryset: Custom queryset to use when retrieving objects (e.g. to select related objects) - :param filter: FilterSet to apply when deleting by QuerySet - :param table: The table used to display devices being edited - :param form: The form class used to edit objects in bulk - :param template_name: The name of the template + queryset: Custom queryset to use when retrieving objects (e.g. to select related objects) + filter: FilterSet to apply when deleting by QuerySet + table: The table used to display devices being edited + form: The form class used to edit objects in bulk + template_name: The name of the template """ queryset = None filterset = None @@ -941,11 +941,11 @@ class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Delete objects in bulk. - :param queryset: Custom queryset to use when retrieving objects (e.g. to select related objects) - :param filter: FilterSet to apply when deleting by QuerySet - :param table: The table used to display devices being deleted - :param form: The form class used to delete objects in bulk - :param template_name: The name of the template + queryset: Custom queryset to use when retrieving objects (e.g. to select related objects) + filter: FilterSet to apply when deleting by QuerySet + table: The table used to display devices being deleted + form: The form class used to delete objects in bulk + template_name: The name of the template """ queryset = None filterset = None From 49b780358ed3a1deb59b4319be87fa2741df1344 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 May 2020 16:11:46 -0400 Subject: [PATCH 030/101] Transition BulkRenameView, BulkDisconnectView to use ObjectPermissionRequiredMixin --- netbox/dcim/views.py | 68 ++++++++++++++++++++------------------------ 1 file changed, 31 insertions(+), 37 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index fb60b6b315..34a482da82 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -23,6 +23,7 @@ from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator +from utilities.permissions import get_permission_for_model from utilities.utils import csv_format from utilities.views import ( BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin, @@ -41,7 +42,7 @@ ) -class BulkRenameView(GetReturnURLMixin, View): +class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ An extendable view for renaming device components in bulk. """ @@ -49,9 +50,10 @@ class BulkRenameView(GetReturnURLMixin, View): form = None template_name = 'dcim/bulk_rename.html' - def post(self, request): + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'change') - model = self.queryset.model + def post(self, request): if '_preview' in request.POST or '_apply' in request.POST: form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')}) @@ -76,7 +78,7 @@ def post(self, request): obj.save() messages.success(request, "Renamed {} {}".format( len(selected_objects), - model._meta.verbose_name_plural + self.queryset.model._meta.verbose_name_plural )) return redirect(self.get_return_url(request)) @@ -86,7 +88,7 @@ def post(self, request): return render(request, self.template_name, { 'form': form, - 'obj_type_plural': model._meta.verbose_name_plural, + 'obj_type_plural': self.queryset.model._meta.verbose_name_plural, 'selected_objects': selected_objects, 'return_url': self.get_return_url(request), }) @@ -96,10 +98,13 @@ class BulkDisconnectView(GetReturnURLMixin, View): """ An extendable view for disconnection console/power/interface components in bulk. """ - model = None + queryset = None form = None template_name = 'dcim/bulk_disconnect.html' + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'change') + def post(self, request): selected_objects = [] @@ -113,25 +118,25 @@ def post(self, request): with transaction.atomic(): count = 0 - for obj in self.model.objects.filter(pk__in=form.cleaned_data['pk']): + for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']): if obj.cable is None: continue obj.cable.delete() count += 1 messages.success(request, "Disconnected {} {}".format( - count, self.model._meta.verbose_name_plural + count, self.queryset.model._meta.verbose_name_plural )) return redirect(return_url) else: form = self.form(initial={'pk': request.POST.getlist('pk')}) - selected_objects = self.model.objects.filter(pk__in=form.initial['pk']) + selected_objects = self.queryset.filter(pk__in=form.initial['pk']) return render(request, self.template_name, { 'form': form, - 'obj_type_plural': self.model._meta.verbose_name_plural, + 'obj_type_plural': self.queryset.model._meta.verbose_name_plural, 'selected_objects': selected_objects, 'return_url': return_url, }) @@ -1285,15 +1290,13 @@ class ConsoleServerPortBulkEditView(BulkEditView): form = forms.ConsoleServerPortBulkEditForm -class ConsoleServerPortBulkRenameView(PermissionRequiredMixin, BulkRenameView): - permission_required = 'dcim.change_consoleserverport' +class ConsoleServerPortBulkRenameView(BulkRenameView): queryset = ConsoleServerPort.objects.all() form = forms.ConsoleServerPortBulkRenameForm -class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): - permission_required = 'dcim.change_consoleserverport' - model = ConsoleServerPort +class ConsoleServerPortBulkDisconnectView(BulkDisconnectView): + queryset = ConsoleServerPort.objects.all() form = forms.ConsoleServerPortBulkDisconnectForm @@ -1397,15 +1400,13 @@ class PowerOutletBulkEditView(BulkEditView): form = forms.PowerOutletBulkEditForm -class PowerOutletBulkRenameView(PermissionRequiredMixin, BulkRenameView): - permission_required = 'dcim.change_poweroutlet' +class PowerOutletBulkRenameView(BulkRenameView): queryset = PowerOutlet.objects.all() form = forms.PowerOutletBulkRenameForm -class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): - permission_required = 'dcim.change_poweroutlet' - model = PowerOutlet +class PowerOutletBulkDisconnectView(BulkDisconnectView): + queryset = PowerOutlet.objects.all() form = forms.PowerOutletBulkDisconnectForm @@ -1496,15 +1497,13 @@ class InterfaceBulkEditView(BulkEditView): form = forms.InterfaceBulkEditForm -class InterfaceBulkRenameView(PermissionRequiredMixin, BulkRenameView): - permission_required = 'dcim.change_interface' +class InterfaceBulkRenameView(BulkRenameView): queryset = Interface.objects.all() form = forms.InterfaceBulkRenameForm -class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): - permission_required = 'dcim.change_interface' - model = Interface +class InterfaceBulkDisconnectView(BulkDisconnectView): + queryset = Interface.objects.all() form = forms.InterfaceBulkDisconnectForm @@ -1558,15 +1557,13 @@ class FrontPortBulkEditView(BulkEditView): form = forms.FrontPortBulkEditForm -class FrontPortBulkRenameView(PermissionRequiredMixin, BulkRenameView): - permission_required = 'dcim.change_frontport' +class FrontPortBulkRenameView(BulkRenameView): queryset = FrontPort.objects.all() form = forms.FrontPortBulkRenameForm -class FrontPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): - permission_required = 'dcim.change_frontport' - model = FrontPort +class FrontPortBulkDisconnectView(BulkDisconnectView): + queryset = FrontPort.objects.all() form = forms.FrontPortBulkDisconnectForm @@ -1620,15 +1617,13 @@ class RearPortBulkEditView(BulkEditView): form = forms.RearPortBulkEditForm -class RearPortBulkRenameView(PermissionRequiredMixin, BulkRenameView): - permission_required = 'dcim.change_rearport' +class RearPortBulkRenameView(BulkRenameView): queryset = RearPort.objects.all() form = forms.RearPortBulkRenameForm -class RearPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): - permission_required = 'dcim.change_rearport' - model = RearPort +class RearPortBulkDisconnectView(BulkDisconnectView): + queryset = RearPort.objects.all() form = forms.RearPortBulkDisconnectForm @@ -1753,8 +1748,7 @@ class DeviceBayBulkEditView(BulkEditView): form = forms.DeviceBayBulkEditForm -class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView): - permission_required = 'dcim.change_devicebay' +class DeviceBayBulkRenameView(BulkRenameView): queryset = DeviceBay.objects.all() form = forms.DeviceBayBulkRenameForm From f36c797e98eb2345ee6927f3256e6b4ef701d3ed Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 May 2020 16:28:11 -0400 Subject: [PATCH 031/101] Transition ComponentCreateView to use ObjectPermissionRequiredMixin --- netbox/dcim/views.py | 90 ++++++++++++++-------------------- netbox/utilities/views.py | 49 ++++++++++++------ netbox/virtualization/views.py | 5 +- 3 files changed, 72 insertions(+), 72 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 34a482da82..41269d0e08 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -671,9 +671,8 @@ class DeviceTypeBulkDeleteView(BulkDeleteView): # Console port templates # -class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_consoleporttemplate' - model = ConsolePortTemplate +class ConsolePortTemplateCreateView(ComponentCreateView): + queryset = ConsolePortTemplate.objects.all() form = forms.ConsolePortTemplateCreateForm model_form = forms.ConsolePortTemplateForm template_name = 'dcim/device_component_add.html' @@ -703,9 +702,8 @@ class ConsolePortTemplateBulkDeleteView(BulkDeleteView): # Console server port templates # -class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_consoleserverporttemplate' - model = ConsoleServerPortTemplate +class ConsoleServerPortTemplateCreateView(ComponentCreateView): + queryset = ConsoleServerPortTemplate.objects.all() form = forms.ConsoleServerPortTemplateCreateForm model_form = forms.ConsoleServerPortTemplateForm template_name = 'dcim/device_component_add.html' @@ -735,9 +733,8 @@ class ConsoleServerPortTemplateBulkDeleteView(BulkDeleteView): # Power port templates # -class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_powerporttemplate' - model = PowerPortTemplate +class PowerPortTemplateCreateView(ComponentCreateView): + queryset = PowerPortTemplate.objects.all() form = forms.PowerPortTemplateCreateForm model_form = forms.PowerPortTemplateForm template_name = 'dcim/device_component_add.html' @@ -767,9 +764,8 @@ class PowerPortTemplateBulkDeleteView(BulkDeleteView): # Power outlet templates # -class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_poweroutlettemplate' - model = PowerOutletTemplate +class PowerOutletTemplateCreateView(ComponentCreateView): + queryset = PowerOutletTemplate.objects.all() form = forms.PowerOutletTemplateCreateForm model_form = forms.PowerOutletTemplateForm template_name = 'dcim/device_component_add.html' @@ -799,9 +795,8 @@ class PowerOutletTemplateBulkDeleteView(BulkDeleteView): # Interface templates # -class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_interfacetemplate' - model = InterfaceTemplate +class InterfaceTemplateCreateView(ComponentCreateView): + queryset = InterfaceTemplate.objects.all() form = forms.InterfaceTemplateCreateForm model_form = forms.InterfaceTemplateForm template_name = 'dcim/device_component_add.html' @@ -831,9 +826,8 @@ class InterfaceTemplateBulkDeleteView(BulkDeleteView): # Front port templates # -class FrontPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_frontporttemplate' - model = FrontPortTemplate +class FrontPortTemplateCreateView(ComponentCreateView): + queryset = FrontPortTemplate.objects.all() form = forms.FrontPortTemplateCreateForm model_form = forms.FrontPortTemplateForm template_name = 'dcim/device_component_add.html' @@ -863,9 +857,8 @@ class FrontPortTemplateBulkDeleteView(BulkDeleteView): # Rear port templates # -class RearPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_rearporttemplate' - model = RearPortTemplate +class RearPortTemplateCreateView(ComponentCreateView): + queryset = RearPortTemplate.objects.all() form = forms.RearPortTemplateCreateForm model_form = forms.RearPortTemplateForm template_name = 'dcim/device_component_add.html' @@ -895,9 +888,8 @@ class RearPortTemplateBulkDeleteView(BulkDeleteView): # Device bay templates # -class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_devicebaytemplate' - model = DeviceBayTemplate +class DeviceBayTemplateCreateView(ComponentCreateView): + queryset = DeviceBayTemplate.objects.all() form = forms.DeviceBayTemplateCreateForm model_form = forms.DeviceBayTemplateForm template_name = 'dcim/device_component_add.html' @@ -913,7 +905,6 @@ class DeviceBayTemplateDeleteView(ObjectDeleteView): # class DeviceBayTemplateBulkEditView(BulkEditView): -# permission_required = 'dcim.change_devicebaytemplate' # queryset = DeviceBayTemplate.objects.all() # table = tables.DeviceBayTemplateTable # form = forms.DeviceBayTemplateBulkEditForm @@ -1105,7 +1096,7 @@ def get(self, request, pk): }) -class DeviceLLDPNeighborsView(PermissionRequiredMixin, View): +class DeviceLLDPNeighborsView(ObjectView): permission_required = ('dcim.view_device', 'dcim.napalm_read') queryset = Device.objects.all() @@ -1123,7 +1114,7 @@ def get(self, request, pk): }) -class DeviceConfigView(PermissionRequiredMixin, View): +class DeviceConfigView(ObjectView): permission_required = ('dcim.view_device', 'dcim.napalm_read') queryset = Device.objects.all() @@ -1209,9 +1200,8 @@ class ConsolePortListView(ObjectListView): action_buttons = ('import', 'export') -class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_consoleport' - model = ConsolePort +class ConsolePortCreateView(ComponentCreateView): + queryset = ConsolePort.objects.all() form = forms.ConsolePortCreateForm model_form = forms.ConsolePortForm template_name = 'dcim/device_component_add.html' @@ -1259,9 +1249,8 @@ class ConsoleServerPortListView(ObjectListView): action_buttons = ('import', 'export') -class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_consoleserverport' - model = ConsoleServerPort +class ConsoleServerPortCreateView(ComponentCreateView): + queryset = ConsoleServerPort.objects.all() form = forms.ConsoleServerPortCreateForm model_form = forms.ConsoleServerPortForm template_name = 'dcim/device_component_add.html' @@ -1319,9 +1308,8 @@ class PowerPortListView(ObjectListView): action_buttons = ('import', 'export') -class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_powerport' - model = PowerPort +class PowerPortCreateView(ComponentCreateView): + queryset = PowerPort.objects.all() form = forms.PowerPortCreateForm model_form = forms.PowerPortForm template_name = 'dcim/device_component_add.html' @@ -1369,9 +1357,8 @@ class PowerOutletListView(ObjectListView): action_buttons = ('import', 'export') -class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_poweroutlet' - model = PowerOutlet +class PowerOutletCreateView(ComponentCreateView): + queryset = PowerOutlet.objects.all() form = forms.PowerOutletCreateForm model_form = forms.PowerOutletForm template_name = 'dcim/device_component_add.html' @@ -1465,9 +1452,8 @@ def get(self, request, pk): }) -class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_interface' - model = Interface +class InterfaceCreateView(ComponentCreateView): + queryset = Interface.objects.all() form = forms.InterfaceCreateForm model_form = forms.InterfaceForm template_name = 'dcim/device_component_add.html' @@ -1526,9 +1512,8 @@ class FrontPortListView(ObjectListView): action_buttons = ('import', 'export') -class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_frontport' - model = FrontPort +class FrontPortCreateView(ComponentCreateView): + queryset = FrontPort.objects.all() form = forms.FrontPortCreateForm model_form = forms.FrontPortForm template_name = 'dcim/device_component_add.html' @@ -1586,9 +1571,8 @@ class RearPortListView(ObjectListView): action_buttons = ('import', 'export') -class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_rearport' - model = RearPort +class RearPortCreateView(ComponentCreateView): + queryset = RearPort.objects.all() form = forms.RearPortCreateForm model_form = forms.RearPortForm template_name = 'dcim/device_component_add.html' @@ -1648,9 +1632,8 @@ class DeviceBayListView(ObjectListView): action_buttons = ('import', 'export') -class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_devicebay' - model = DeviceBay +class DeviceBayCreateView(ComponentCreateView): + queryset = DeviceBay.objects.all() form = forms.DeviceBayCreateForm model_form = forms.DeviceBayForm template_name = 'dcim/device_component_add.html' @@ -2144,9 +2127,8 @@ class InventoryItemEditView(ObjectEditView): model_form = forms.InventoryItemForm -class InventoryItemCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_inventoryitem' - model = InventoryItem +class InventoryItemCreateView(ComponentCreateView): + queryset = InventoryItem.objects.all() form = forms.InventoryItemCreateForm model_form = forms.InventoryItemForm template_name = 'dcim/device_component_add.html' diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index cc0c7596dc..c008b05017 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -1033,28 +1033,32 @@ class BulkDeleteForm(ConfirmationForm): # # TODO: Replace with BulkCreateView -class ComponentCreateView(GetReturnURLMixin, View): +class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine. """ - model = None + queryset = None form = None model_form = None template_name = None + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'add') + def get(self, request): form = self.form(initial=request.GET) return render(request, self.template_name, { - 'component_type': self.model._meta.verbose_name, + 'component_type': self.queryset.model._meta.verbose_name, 'form': form, 'return_url': self.get_return_url(request), }) def post(self, request): - + logger = logging.getLogger('netbox.views.ComponentCreateView') form = self.form(request.POST, initial=request.GET) + if form.is_valid(): new_components = [] @@ -1080,20 +1084,35 @@ def post(self, request): if not form.errors: - # Create the new components - for component_form in new_components: - component_form.save() + try: - messages.success(request, "Added {} {}".format( - len(new_components), self.model._meta.verbose_name_plural - )) - if '_addanother' in request.POST: - return redirect(request.get_full_path()) - else: - return redirect(self.get_return_url(request)) + with transaction.atomic(): + + # Create the new components + new_objs = [] + for component_form in new_components: + obj = component_form.save() + new_objs.append(obj) + + # Enforce object-level permissions + if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs): + raise ObjectDoesNotExist + + messages.success(request, "Added {} {}".format( + len(new_components), self.queryset.model._meta.verbose_name_plural + )) + if '_addanother' in request.POST: + return redirect(request.get_full_path()) + else: + return redirect(self.get_return_url(request)) + + except ObjectDoesNotExist: + msg = "Component creation failed due to object-level permissions violation" + logger.debug(msg) + form.add_error(None, msg) return render(request, self.template_name, { - 'component_type': self.model._meta.verbose_name, + 'component_type': self.queryset.model._meta.verbose_name, 'form': form, 'return_url': self.get_return_url(request), }) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 53fcf96975..f7cf523d9b 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -293,9 +293,8 @@ class VirtualMachineBulkDeleteView(BulkDeleteView): # VM interfaces # -class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_interface' - model = Interface +class InterfaceCreateView(ComponentCreateView): + queryset = Interface.objects.all() form = forms.InterfaceCreateForm model_form = forms.InterfaceForm template_name = 'virtualization/virtualmachine_component_add.html' From e7fde2795f9fdd4c223f397c7e4c8448f855e83b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 May 2020 16:34:15 -0400 Subject: [PATCH 032/101] Fix BulkDisconnectView --- netbox/dcim/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 41269d0e08..29d5498c6b 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -94,7 +94,7 @@ def post(self, request): }) -class BulkDisconnectView(GetReturnURLMixin, View): +class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ An extendable view for disconnection console/power/interface components in bulk. """ From 7e64d3e6536191e920cf998591bf6494f1a0d982 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 May 2020 09:23:00 -0400 Subject: [PATCH 033/101] Transition BulkComponentCreateView to use ObjectPermissionRequiredMixin --- netbox/dcim/views.py | 40 ++++++++++++++-------------------- netbox/utilities/views.py | 15 ++++++++++--- netbox/virtualization/views.py | 5 ++--- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 29d5498c6b..0f5ea01a9c 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1747,96 +1747,88 @@ class DeviceBayBulkDeleteView(BulkDeleteView): # Bulk Device component creation # -class DeviceBulkAddConsolePortView(PermissionRequiredMixin, BulkComponentCreateView): - permission_required = 'dcim.add_consoleport' +class DeviceBulkAddConsolePortView(BulkComponentCreateView): parent_model = Device parent_field = 'device' form = forms.ConsolePortBulkCreateForm - model = ConsolePort + queryset = ConsolePort.objects.all() model_form = forms.ConsolePortForm filterset = filters.DeviceFilterSet table = tables.DeviceTable default_return_url = 'dcim:device_list' -class DeviceBulkAddConsoleServerPortView(PermissionRequiredMixin, BulkComponentCreateView): - permission_required = 'dcim.add_consoleserverport' +class DeviceBulkAddConsoleServerPortView(BulkComponentCreateView): parent_model = Device parent_field = 'device' form = forms.ConsoleServerPortBulkCreateForm - model = ConsoleServerPort + queryset = ConsoleServerPort.objects.all() model_form = forms.ConsoleServerPortForm filterset = filters.DeviceFilterSet table = tables.DeviceTable default_return_url = 'dcim:device_list' -class DeviceBulkAddPowerPortView(PermissionRequiredMixin, BulkComponentCreateView): - permission_required = 'dcim.add_powerport' +class DeviceBulkAddPowerPortView(BulkComponentCreateView): parent_model = Device parent_field = 'device' form = forms.PowerPortBulkCreateForm - model = PowerPort + queryset = PowerPort.objects.all() model_form = forms.PowerPortForm filterset = filters.DeviceFilterSet table = tables.DeviceTable default_return_url = 'dcim:device_list' -class DeviceBulkAddPowerOutletView(PermissionRequiredMixin, BulkComponentCreateView): - permission_required = 'dcim.add_poweroutlet' +class DeviceBulkAddPowerOutletView(BulkComponentCreateView): parent_model = Device parent_field = 'device' form = forms.PowerOutletBulkCreateForm - model = PowerOutlet + queryset = PowerOutlet.objects.all() model_form = forms.PowerOutletForm filterset = filters.DeviceFilterSet table = tables.DeviceTable default_return_url = 'dcim:device_list' -class DeviceBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateView): - permission_required = 'dcim.add_interface' +class DeviceBulkAddInterfaceView(BulkComponentCreateView): parent_model = Device parent_field = 'device' form = forms.InterfaceBulkCreateForm - model = Interface + queryset = Interface.objects.all() model_form = forms.InterfaceForm filterset = filters.DeviceFilterSet table = tables.DeviceTable default_return_url = 'dcim:device_list' -# class DeviceBulkAddFrontPortView(PermissionRequiredMixin, BulkComponentCreateView): -# permission_required = 'dcim.add_frontport' +# class DeviceBulkAddFrontPortView(BulkComponentCreateView): # parent_model = Device # parent_field = 'device' # form = forms.FrontPortBulkCreateForm -# model = FrontPort +# queryset = FrontPort.objects.all() # model_form = forms.FrontPortForm # filterset = filters.DeviceFilterSet # table = tables.DeviceTable # default_return_url = 'dcim:device_list' -class DeviceBulkAddRearPortView(PermissionRequiredMixin, BulkComponentCreateView): - permission_required = 'dcim.add_rearport' +class DeviceBulkAddRearPortView(BulkComponentCreateView): parent_model = Device parent_field = 'device' form = forms.RearPortBulkCreateForm - model = RearPort + queryset = RearPort.objects.all() model_form = forms.RearPortForm filterset = filters.DeviceFilterSet table = tables.DeviceTable default_return_url = 'dcim:device_list' -class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateView): - permission_required = 'dcim.add_devicebay' +class DeviceBulkAddDeviceBayView(BulkComponentCreateView): parent_model = Device parent_field = 'device' form = forms.DeviceBayBulkCreateForm - model = DeviceBay + queryset = DeviceBay.objects.all() model_form = forms.DeviceBayForm filterset = filters.DeviceFilterSet table = tables.DeviceTable diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index c008b05017..87f63678a9 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -1118,14 +1118,14 @@ def post(self, request): }) -class BulkComponentCreateView(GetReturnURLMixin, View): +class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines. """ parent_model = None parent_field = None form = None - model = None + queryset = None model_form = None filterset = None table = None @@ -1134,7 +1134,7 @@ class BulkComponentCreateView(GetReturnURLMixin, View): def post(self, request): logger = logging.getLogger('netbox.views.BulkComponentCreateView') parent_model_name = self.parent_model._meta.verbose_name_plural - model_name = self.model._meta.verbose_name_plural + model_name = self.queryset.model._meta.verbose_name_plural # Are we editing *all* objects in the queryset or just a selected subset? if request.POST.get('_all') and self.filterset is not None: @@ -1179,9 +1179,18 @@ def post(self, request): for e in errors: form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e))) + # Enforce object-level permissions + if self.queryset.filter(pk__in=[obj.pk for obj in new_components]).count() != len(new_components): + raise ObjectDoesNotExist + except IntegrityError: pass + except ObjectDoesNotExist: + msg = "Component creation failed due to object-level permissions violation" + logger.debug(msg) + form.add_error(None, msg) + if not form.errors: msg = "Added {} {} to {} {}.".format( len(new_components), diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index f7cf523d9b..e6d4f4946f 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -325,12 +325,11 @@ class InterfaceBulkDeleteView(BulkDeleteView): # Bulk Device component creation # -class VirtualMachineBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateView): - permission_required = 'dcim.add_interface' +class VirtualMachineBulkAddInterfaceView(BulkComponentCreateView): parent_model = VirtualMachine parent_field = 'virtual_machine' form = forms.InterfaceBulkCreateForm - model = Interface + queryset = Interface.objects.all() model_form = forms.InterfaceForm filterset = filters.VirtualMachineFilterSet table = tables.VirtualMachineTable From 71d4b5c5df03bdc4479207670f763686e597cb3d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 May 2020 09:45:29 -0400 Subject: [PATCH 034/101] Enforce object-level permissions for circuit termination swap view --- netbox/circuits/urls.py | 3 +- netbox/circuits/views.py | 71 +++++++++++++++++++++++++--------------- 2 files changed, 46 insertions(+), 28 deletions(-) diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 1a7fa283b4..1c0f0715be 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -37,10 +37,9 @@ path('circuits//edit/', views.CircuitEditView.as_view(), name='circuit_edit'), path('circuits//delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'), path('circuits//changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}), - path('circuits//terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'), + path('circuits//terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'), # Circuit terminations - path('circuits//terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'), path('circuit-terminations//edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), path('circuit-terminations//delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 1f5f052306..bb4d787c8c 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -1,6 +1,5 @@ from django.conf import settings from django.contrib import messages -from django.contrib.auth.decorators import permission_required from django.db import transaction from django.db.models import Count, OuterRef from django.shortcuts import get_object_or_404, redirect, render @@ -191,25 +190,47 @@ class CircuitBulkDeleteView(BulkDeleteView): default_return_url = 'circuits:circuit_list' -@permission_required('circuits.change_circuittermination') -def circuit_terminations_swap(request, pk): +class CircuitSwapTerminations(ObjectEditView): + """ + Swap the A and Z terminations of a circuit. + """ + queryset = Circuit.objects.all() + + def get(self, request, pk): + circuit = get_object_or_404(self.queryset, pk=pk) + form = ConfirmationForm() - circuit = get_object_or_404(Circuit, pk=pk) - termination_a = CircuitTermination.objects.filter( - circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A - ).first() - termination_z = CircuitTermination.objects.filter( - circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z - ).first() - if not termination_a and not termination_z: - messages.error(request, "No terminations have been defined for circuit {}.".format(circuit)) - return redirect('circuits:circuit', pk=circuit.pk) + # Circuit must have at least one termination to swap + if not circuit.termination_a and not circuit.termination_z: + messages.error(request, "No terminations have been defined for circuit {}.".format(circuit)) + return redirect('circuits:circuit', pk=circuit.pk) + + return render(request, 'circuits/circuit_terminations_swap.html', { + 'circuit': circuit, + 'termination_a': circuit.termination_a, + 'termination_z': circuit.termination_z, + 'form': form, + 'panel_class': 'default', + 'button_class': 'primary', + 'return_url': circuit.get_absolute_url(), + }) - if request.method == 'POST': + def post(self, request, pk): + circuit = get_object_or_404(self.queryset, pk=pk) form = ConfirmationForm(request.POST) + if form.is_valid(): + + termination_a = CircuitTermination.objects.filter( + circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A + ).first() + termination_z = CircuitTermination.objects.filter( + circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z + ).first() + if termination_a and termination_z: # Use a placeholder to avoid an IntegrityError on the (circuit, term_side) unique constraint + print('swapping') with transaction.atomic(): termination_a.term_side = '_' termination_a.save() @@ -223,21 +244,19 @@ def circuit_terminations_swap(request, pk): else: termination_z.term_side = 'A' termination_z.save() + messages.success(request, "Swapped terminations for circuit {}.".format(circuit)) return redirect('circuits:circuit', pk=circuit.pk) - else: - form = ConfirmationForm() - - return render(request, 'circuits/circuit_terminations_swap.html', { - 'circuit': circuit, - 'termination_a': termination_a, - 'termination_z': termination_z, - 'form': form, - 'panel_class': 'default', - 'button_class': 'primary', - 'return_url': circuit.get_absolute_url(), - }) + return render(request, 'circuits/circuit_terminations_swap.html', { + 'circuit': circuit, + 'termination_a': circuit.termination_a, + 'termination_z': circuit.termination_z, + 'form': form, + 'panel_class': 'default', + 'button_class': 'primary', + 'return_url': circuit.get_absolute_url(), + }) # From ab60a5d73d1519df25182e89e56a9ef45e94b687 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 May 2020 09:51:57 -0400 Subject: [PATCH 035/101] Enforce object-level permissions for IPAddressAssignView, VLANGroupVLANsView --- netbox/ipam/views.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 476943b137..14c6a6864a 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1,6 +1,5 @@ import netaddr from django.conf import settings -from django.contrib.auth.mixins import PermissionRequiredMixin from django.db.models import Count, Q from django.db.models.expressions import RawSQL from django.shortcuts import get_object_or_404, redirect, render @@ -11,7 +10,7 @@ from utilities.paginator import EnhancedPaginator from utilities.views import ( BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, - ObjectListView, + ObjectListView, ObjectPermissionRequiredMixin, ) from virtualization.models import VirtualMachine from . import filters, forms, tables @@ -672,11 +671,11 @@ def alter_obj(self, obj, request, url_args, url_kwargs): return obj -class IPAddressAssignView(PermissionRequiredMixin, View): +class IPAddressAssignView(ObjectPermissionRequiredMixin, View): """ Search for IPAddresses to be assigned to an Interface. """ - permission_required = 'ipam.change_ipaddress' + queryset = IPAddress.objects.all() def dispatch(self, request, *args, **kwargs): @@ -687,7 +686,6 @@ def dispatch(self, request, *args, **kwargs): return super().dispatch(request, *args, **kwargs) def get(self, request): - form = forms.IPAddressAssignForm() return render(request, 'ipam/ipaddress_assign.html', { @@ -696,13 +694,12 @@ def get(self, request): }) def post(self, request): - form = forms.IPAddressAssignForm(request.POST) table = None if form.is_valid(): - addresses = IPAddress.objects.prefetch_related( + addresses = self.queryset.prefetch_related( 'vrf', 'tenant', 'interface__device', 'interface__virtual_machine' ) # Limit to 100 results @@ -784,12 +781,11 @@ class VLANGroupBulkDeleteView(BulkDeleteView): default_return_url = 'ipam:vlangroup_list' -class VLANGroupVLANsView(PermissionRequiredMixin, View): - permission_required = 'ipam.view_vlangroup' +class VLANGroupVLANsView(ObjectView): + queryset = VLANGroup.objects.all() def get(self, request, pk): - - vlan_group = get_object_or_404(VLANGroup.objects.all(), pk=pk) + vlan_group = get_object_or_404(self.queryset, pk=pk) vlans = VLAN.objects.filter(group_id=pk) vlans = add_available_vlans(vlan_group, vlans) From bae050e68952525d02d518024ceba992f8d86e5a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 May 2020 11:24:49 -0400 Subject: [PATCH 036/101] Replace legacy add/edit secret views with SecretEditView --- netbox/secrets/decorators.py | 24 ---- netbox/secrets/urls.py | 4 +- netbox/secrets/views.py | 135 ++++++++-------------- netbox/templates/secrets/secret_edit.html | 14 +-- 4 files changed, 58 insertions(+), 119 deletions(-) delete mode 100644 netbox/secrets/decorators.py diff --git a/netbox/secrets/decorators.py b/netbox/secrets/decorators.py deleted file mode 100644 index e2f44ac90f..0000000000 --- a/netbox/secrets/decorators.py +++ /dev/null @@ -1,24 +0,0 @@ -from django.contrib import messages -from django.shortcuts import redirect - -from .models import UserKey - - -def userkey_required(): - """ - Decorator for views which require that the user has an active UserKey (typically for encryption/decryption of - Secrets). - """ - def _decorator(view): - def wrapped_view(request, *args, **kwargs): - try: - uk = UserKey.objects.get(user=request.user) - except UserKey.DoesNotExist: - messages.warning(request, "This operation requires an active user key, but you don't have one.") - return redirect('user:userkey') - if not uk.is_active(): - messages.warning(request, "This operation is not available. Your user key has not been activated.") - return redirect('user:userkey') - return view(request, *args, **kwargs) - return wrapped_view - return _decorator diff --git a/netbox/secrets/urls.py b/netbox/secrets/urls.py index ac75a7ed4a..84c2da398b 100644 --- a/netbox/secrets/urls.py +++ b/netbox/secrets/urls.py @@ -17,12 +17,12 @@ # Secrets path('secrets/', views.SecretListView.as_view(), name='secret_list'), - path('secrets/add/', views.secret_add, name='secret_add'), + path('secrets/add/', views.SecretEditView.as_view(), name='secret_add'), path('secrets/import/', views.SecretBulkImportView.as_view(), name='secret_import'), path('secrets/edit/', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'), path('secrets/delete/', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'), path('secrets//', views.SecretView.as_view(), name='secret'), - path('secrets//edit/', views.secret_edit, name='secret_edit'), + path('secrets//edit/', views.SecretEditView.as_view(), name='secret_edit'), path('secrets//delete/', views.SecretDeleteView.as_view(), name='secret_delete'), path('secrets//changelog/', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}), diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index a2e627a7ca..a5aabaecd8 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -1,20 +1,17 @@ import base64 +import logging from django.contrib import messages -from django.contrib.auth.decorators import permission_required -from django.contrib.auth.mixins import PermissionRequiredMixin from django.db.models import Count from django.shortcuts import get_object_or_404, redirect, render -from django.urls import reverse -from django.views.generic import View +from django.utils.html import escape +from django.utils.safestring import mark_safe from utilities.views import ( - BulkDeleteView, BulkEditView, BulkImportView, GetReturnURLMixin, ObjectView, ObjectDeleteView, ObjectEditView, - ObjectListView, + BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView, ) from . import filters, forms, tables -from .decorators import userkey_required -from .models import SecretRole, Secret, SessionKey +from .models import SecretRole, Secret, SessionKey, UserKey def get_session_key(request): @@ -79,107 +76,73 @@ def get(self, request, pk): }) -@permission_required('secrets.add_secret') -@userkey_required() -def secret_add(request): +class SecretEditView(ObjectEditView): + queryset = Secret.objects.all() + model_form = forms.SecretForm + template_name = 'secrets/secret_edit.html' + + def dispatch(self, request, *args, **kwargs): + + # Check that the user has a valid UserKey + try: + uk = UserKey.objects.get(user=request.user) + except UserKey.DoesNotExist: + messages.warning(request, "This operation requires an active user key, but you don't have one.") + return redirect('user:userkey') + if not uk.is_active(): + messages.warning(request, "This operation is not available. Your user key has not been activated.") + return redirect('user:userkey') + + return super().dispatch(request, *args, **kwargs) - secret = Secret() - session_key = get_session_key(request) + def post(self, request, *args, **kwargs): + logger = logging.getLogger('netbox.views.ObjectEditView') + session_key = get_session_key(request) + secret = self.get_object(kwargs) + form = self.model_form(request.POST, instance=secret) - if request.method == 'POST': - form = forms.SecretForm(request.POST, instance=secret) if form.is_valid(): + logger.debug("Form validation was successful") - # We need a valid session key in order to create a Secret - if session_key is None: + # We must have a session key in order to create a secret or update the plaintext of an existing secret + if (form.cleaned_data['plaintext'] or secret.pk is None) and session_key is None: + logger.debug("Unable to proceed: No session key was provided with the request") form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.") - # Create and encrypt the new Secret else: master_key = None try: sk = SessionKey.objects.get(userkey__user=request.user) master_key = sk.get_master_key(session_key) except SessionKey.DoesNotExist: + logger.debug("Unable to proceed: User has no session key assigned") form.add_error(None, "No session key found for this user.") if master_key is not None: + logger.debug("Successfully resolved master key for encryption") secret = form.save(commit=False) - secret.plaintext = str(form.cleaned_data['plaintext']) + if form.cleaned_data['plaintext']: + secret.plaintext = str(form.cleaned_data['plaintext']) secret.encrypt(master_key) secret.save() form.save_m2m() - messages.success(request, "Added new secret: {}.".format(secret)) - if '_addanother' in request.POST: - return redirect('secrets:secret_add') - else: - return redirect('secrets:secret', pk=secret.pk) - - else: - initial_data = { - 'device': request.GET.get('device'), - } - form = forms.SecretForm(initial=initial_data) - - return render(request, 'secrets/secret_edit.html', { - 'secret': secret, - 'form': form, - 'return_url': GetReturnURLMixin().get_return_url(request, secret) - }) - - -@permission_required('secrets.change_secret') -@userkey_required() -def secret_edit(request, pk): - - secret = get_object_or_404(Secret, pk=pk) - session_key = get_session_key(request) - - if request.method == 'POST': - form = forms.SecretForm(request.POST, instance=secret) - if form.is_valid(): - - # Re-encrypt the Secret if a plaintext and session key have been provided. - if form.cleaned_data['plaintext'] and session_key is not None: - - # Retrieve the master key using the provided session key - master_key = None - try: - sk = SessionKey.objects.get(userkey__user=request.user) - master_key = sk.get_master_key(session_key) - except SessionKey.DoesNotExist: - form.add_error(None, "No session key found for this user.") + msg = '{} secret'.format('Created' if not form.instance.pk else 'Modified') + logger.info(f"{msg} {secret} (PK: {secret.pk})") + msg = '{} {}'.format(msg, secret.get_absolute_url(), escape(secret)) + messages.success(request, mark_safe(msg)) - # Create and encrypt the new Secret - if master_key is not None: - secret = form.save(commit=False) - secret.plaintext = form.cleaned_data['plaintext'] - secret.encrypt(master_key) - secret.save() - messages.success(request, "Modified secret {}.".format(secret)) - return redirect('secrets:secret', pk=secret.pk) - else: - form.add_error(None, "Invalid session key. Unable to encrypt secret data.") + return redirect(self.get_return_url(request, secret)) - # We can't save the plaintext without a session key. - elif form.cleaned_data['plaintext']: - form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.") + else: + logger.debug("Form validation failed") - # If no new plaintext was specified, a session key is not needed. - else: - secret = form.save() - messages.success(request, "Modified secret {}.".format(secret)) - return redirect('secrets:secret', pk=secret.pk) - - else: - form = forms.SecretForm(instance=secret) - - return render(request, 'secrets/secret_edit.html', { - 'secret': secret, - 'form': form, - 'return_url': reverse('secrets:secret', kwargs={'pk': secret.pk}), - }) + return render(request, self.template_name, { + 'obj': secret, + 'obj_type': self.queryset.model._meta.verbose_name, + 'form': form, + 'return_url': self.get_return_url(request, secret), + }) class SecretDeleteView(ObjectDeleteView): diff --git a/netbox/templates/secrets/secret_edit.html b/netbox/templates/secrets/secret_edit.html index cb3935521c..6893e2d140 100644 --- a/netbox/templates/secrets/secret_edit.html +++ b/netbox/templates/secrets/secret_edit.html @@ -9,7 +9,7 @@ {{ form.private_key }}
-

{% block title %}{% if secret.pk %}Editing {{ secret }}{% else %}Add a Secret{% endif %}{% endblock %}

+

{% block title %}{% if obj.pk %}Editing {{ obj }}{% else %}Add a Secret{% endif %}{% endblock %}

{% if form.non_field_errors %}
Errors
@@ -30,17 +30,17 @@

{% block title %}{% if secret.pk %}Editing {{ secret }}{% else %}Add a Secre
Secret Data
- {% if secret.pk and secret|decryptable_by:request.user %} + {% if obj.pk and obj|decryptable_by:request.user %}
-

********

+

********

- -
@@ -69,9 +69,9 @@

{% block title %}{% if secret.pk %}Editing {{ secret }}{% else %}Add a Secre
- {% if secret.pk %} + {% if obj.pk %} - Cancel + Cancel {% else %} From 5282ae2250adebf1c1cdb7e3581bc492025ff4d2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 May 2020 11:30:46 -0400 Subject: [PATCH 037/101] Enforce object-level permissions for cluster add/remove devices views --- netbox/virtualization/views.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index e6d4f4946f..20cd5e9b13 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -4,7 +4,6 @@ from django.db.models import Count from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse -from django.views.generic import View from dcim.models import Device, Interface from dcim.tables import DeviceTable @@ -137,14 +136,13 @@ class ClusterBulkDeleteView(BulkDeleteView): default_return_url = 'virtualization:cluster_list' -class ClusterAddDevicesView(PermissionRequiredMixin, View): - permission_required = 'virtualization.change_cluster' +class ClusterAddDevicesView(ObjectEditView): + queryset = Cluster.objects.all() form = forms.ClusterAddDevicesForm template_name = 'virtualization/cluster_add_devices.html' def get(self, request, pk): - - cluster = get_object_or_404(Cluster, pk=pk) + cluster = get_object_or_404(self.queryset, pk=pk) form = self.form(cluster, initial=request.GET) return render(request, self.template_name, { @@ -154,8 +152,7 @@ def get(self, request, pk): }) def post(self, request, pk): - - cluster = get_object_or_404(Cluster, pk=pk) + cluster = get_object_or_404(self.queryset, pk=pk) form = self.form(cluster, request.POST) if form.is_valid(): @@ -180,14 +177,14 @@ def post(self, request, pk): }) -class ClusterRemoveDevicesView(PermissionRequiredMixin, View): - permission_required = 'virtualization.change_cluster' +class ClusterRemoveDevicesView(ObjectEditView): + queryset = Cluster.objects.all() form = forms.ClusterRemoveDevicesForm template_name = 'utilities/obj_bulk_remove.html' def post(self, request, pk): - cluster = get_object_or_404(Cluster, pk=pk) + cluster = get_object_or_404(self.queryset, pk=pk) if '_confirm' in request.POST: form = self.form(request.POST) From 781334b6156df6f55590f2c92ce7253a41fc4281 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 May 2020 11:51:04 -0400 Subject: [PATCH 038/101] Enforce object-level permissions for RackElevationListView --- netbox/dcim/views.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 0f5ea01a9c..d7e0a336a4 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -329,16 +329,15 @@ class RackListView(ObjectListView): table = tables.RackDetailTable -class RackElevationListView(PermissionRequiredMixin, View): +class RackElevationListView(ObjectListView): """ Display a set of rack elevations side-by-side. """ - permission_required = 'dcim.view_rack' + queryset = Rack.objects.prefetch_related('role') def get(self, request): - racks = Rack.objects.prefetch_related('role') - racks = filters.RackFilterSet(request.GET, racks).qs + racks = filters.RackFilterSet(request.GET, self.queryset).qs total_count = racks.count() # Pagination From eb9147a5752e7288005a18e195b9ec0c8a2933d6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 May 2020 11:52:19 -0400 Subject: [PATCH 039/101] Enforce object-level permissions for DeviceBay population views --- netbox/dcim/views.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index d7e0a336a4..733571369e 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1647,12 +1647,11 @@ class DeviceBayDeleteView(ObjectDeleteView): queryset = DeviceBay.objects.all() -class DeviceBayPopulateView(PermissionRequiredMixin, View): - permission_required = 'dcim.change_devicebay' +class DeviceBayPopulateView(ObjectEditView): + queryset = DeviceBay.objects.all() def get(self, request, pk): - - device_bay = get_object_or_404(DeviceBay, pk=pk) + device_bay = get_object_or_404(self.queryset, pk=pk) form = forms.PopulateDeviceBayForm(device_bay) return render(request, 'dcim/devicebay_populate.html', { @@ -1662,8 +1661,7 @@ def get(self, request, pk): }) def post(self, request, pk): - - device_bay = get_object_or_404(DeviceBay, pk=pk) + device_bay = get_object_or_404(self.queryset, pk=pk) form = forms.PopulateDeviceBayForm(device_bay, request.POST) if form.is_valid(): @@ -1681,12 +1679,12 @@ def post(self, request, pk): }) -class DeviceBayDepopulateView(PermissionRequiredMixin, View): - permission_required = 'dcim.change_devicebay' +class DeviceBayDepopulateView(ObjectEditView): + queryset = DeviceBay.objects.all() def get(self, request, pk): - device_bay = get_object_or_404(DeviceBay, pk=pk) + device_bay = get_object_or_404(self.queryset, pk=pk) form = ConfirmationForm() return render(request, 'dcim/devicebay_depopulate.html', { @@ -1697,7 +1695,7 @@ def get(self, request, pk): def post(self, request, pk): - device_bay = get_object_or_404(DeviceBay, pk=pk) + device_bay = get_object_or_404(self.queryset, pk=pk) form = ConfirmationForm(request.POST) if form.is_valid(): From 1bce148be24216374f35fe486057b767a789465e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 May 2020 11:55:56 -0400 Subject: [PATCH 040/101] Enforce object-level permissions for ObjectConfigContextView --- netbox/dcim/views.py | 5 ++--- netbox/extras/views.py | 7 +++---- netbox/virtualization/views.py | 6 ++---- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 733571369e..f55d9fd960 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1127,9 +1127,8 @@ def get(self, request, pk): }) -class DeviceConfigContextView(PermissionRequiredMixin, ObjectConfigContextView): - permission_required = 'dcim.view_device' - object_class = Device +class DeviceConfigContextView(ObjectConfigContextView): + queryset = Device.objects.all() base_template = 'dcim/device.html' diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 78db8f24aa..77e5cb0e02 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -154,15 +154,14 @@ class ConfigContextBulkDeleteView(BulkDeleteView): default_return_url = 'extras:configcontext_list' -class ObjectConfigContextView(View): - object_class = None +class ObjectConfigContextView(ObjectView): base_template = None def get(self, request, pk): - obj = get_object_or_404(self.object_class, pk=pk) + obj = get_object_or_404(self.queryset, pk=pk) source_contexts = ConfigContext.objects.get_for_object(obj) - model_name = self.object_class._meta.model_name + model_name = self.queryset.model._meta.model_name # Determine user's preferred output format if request.GET.get('format') in ['json', 'yaml']: diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 20cd5e9b13..79a807c211 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -1,5 +1,4 @@ from django.contrib import messages -from django.contrib.auth.mixins import PermissionRequiredMixin from django.db import transaction from django.db.models import Count from django.shortcuts import get_object_or_404, redirect, render @@ -246,9 +245,8 @@ def get(self, request, pk): }) -class VirtualMachineConfigContextView(PermissionRequiredMixin, ObjectConfigContextView): - permission_required = 'virtualization.view_virtualmachine' - object_class = VirtualMachine +class VirtualMachineConfigContextView(ObjectConfigContextView): + queryset = VirtualMachine.objects.all() base_template = 'virtualization/virtualmachine.html' From 581dc4e0703adc996737bb0e2092623ff386c729 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 May 2020 12:05:34 -0400 Subject: [PATCH 041/101] Enforce object-level permissions for CableTraceView --- netbox/dcim/views.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index f55d9fd960..3c00108597 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1857,15 +1857,21 @@ def get(self, request, pk): }) -class CableTraceView(PermissionRequiredMixin, View): +class CableTraceView(ObjectPermissionRequiredMixin, View): """ Trace a cable path beginning from the given termination. """ permission_required = 'dcim.view_cable' - def get(self, request, model, pk): + def dispatch(self, request, *args, **kwargs): + model = kwargs.pop('model') + self.queryset = model.objects.all() + + return super().dispatch(request, *args, **kwargs) + + def get(self, request, pk): - obj = get_object_or_404(model, pk=pk) + obj = get_object_or_404(self.queryset, pk=pk) path, split_ends = obj.trace() total_length = sum( [entry[1]._abs_length for entry in path if entry[1] and entry[1]._abs_length] From 3ef4287d57b95462ff63f2cfb0eb8d0fcf4b8c8a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 May 2020 12:41:20 -0400 Subject: [PATCH 042/101] Add additional_permissions to ObjectPermissionRequiredMixin --- netbox/dcim/views.py | 12 +++++------- netbox/ipam/views.py | 4 +--- netbox/utilities/views.py | 16 ++++++++++------ 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 3c00108597..2dfe0f2073 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1082,7 +1082,7 @@ def get(self, request, pk): class DeviceStatusView(ObjectView): - permission_required = ('dcim.view_device', 'dcim.napalm_read') + additional_permissions = ['dcim.napalm_read'] queryset = Device.objects.all() def get(self, request, pk): @@ -1096,7 +1096,7 @@ def get(self, request, pk): class DeviceLLDPNeighborsView(ObjectView): - permission_required = ('dcim.view_device', 'dcim.napalm_read') + additional_permissions = ['dcim.napalm_read'] queryset = Device.objects.all() def get(self, request, pk): @@ -1114,7 +1114,7 @@ def get(self, request, pk): class DeviceConfigView(ObjectView): - permission_required = ('dcim.view_device', 'dcim.napalm_read') + additional_permissions = ['dcim.napalm_read'] queryset = Device.objects.all() def get(self, request, pk): @@ -1857,11 +1857,11 @@ def get(self, request, pk): }) -class CableTraceView(ObjectPermissionRequiredMixin, View): +class CableTraceView(ObjectView): """ Trace a cable path beginning from the given termination. """ - permission_required = 'dcim.view_cable' + additional_permissions = ['dcim.view_cable'] def dispatch(self, request, *args, **kwargs): model = kwargs.pop('model') @@ -2006,7 +2006,6 @@ class CableBulkDeleteView(BulkDeleteView): # class ConsoleConnectionsListView(ObjectListView): - permission_required = ('dcim.view_consoleport', 'dcim.view_consoleserverport') queryset = ConsolePort.objects.prefetch_related( 'device', 'connected_endpoint__device' ).filter( @@ -2038,7 +2037,6 @@ def queryset_to_csv(self): class PowerConnectionsListView(ObjectListView): - permission_required = ('dcim.view_powerport', 'dcim.view_poweroutlet') queryset = PowerPort.objects.prefetch_related( 'device', '_connected_poweroutlet__device' ).filter( diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 14c6a6864a..d3b604be65 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -671,7 +671,7 @@ def alter_obj(self, obj, request, url_args, url_kwargs): return obj -class IPAddressAssignView(ObjectPermissionRequiredMixin, View): +class IPAddressAssignView(ObjectView): """ Search for IPAddresses to be assigned to an Interface. """ @@ -719,7 +719,6 @@ class IPAddressDeleteView(ObjectDeleteView): class IPAddressBulkCreateView(BulkCreateView): - permission_required = 'ipam.add_ipaddress' form = forms.IPAddressBulkCreateForm model_form = forms.IPAddressBulkAddForm pattern_target = 'address' @@ -761,7 +760,6 @@ class VLANGroupListView(ObjectListView): class VLANGroupEditView(ObjectEditView): - permission_required = 'ipam.add_vlangroup' queryset = VLANGroup.objects.all() model_form = forms.VLANGroupForm default_return_url = 'ipam:vlangroup_list' diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 87f63678a9..b586342e11 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -43,18 +43,24 @@ class ObjectPermissionRequiredMixin(AccessMixin): Similar to Django's built-in PermissionRequiredMixin, but extended to check for both model-level and object-level permission assignments. If the user has only object-level permissions assigned, the view's queryset is filtered to return only those objects on which the user is permitted to perform the specified action. + + additional_permissions: An optional iterable of statically declared permissions to evaluate in addition to those + derived from the object type """ - permission_required = None + additional_permissions = list() def get_required_permission(self): - return self.permission_required + """ + Return the specific permission necessary to perform the requested action on an object. + """ + raise NotImplementedError(f"{self.__class__.__name__} must implement get_required_permission()") def has_permission(self): user = self.request.user permission_required = self.get_required_permission() - # First, check that the user is granted the required permission at either the model or object level. - if not user.has_perm(permission_required): + # First, check that the user is granted the required permission(s) at either the model or object level. + if not user.has_perms((permission_required, *self.additional_permissions)): return False # Superusers implicitly have all permissions @@ -148,8 +154,6 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): action_buttons = ('add', 'import', 'export') def get_required_permission(self): - if getattr(self, 'permission_required') is not None: - return self.permission_required return get_permission_for_model(self.queryset.model, 'view') def queryset_to_yaml(self): From ae7445ee8e6651bd0d99521ab6e21c014840ffab Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 May 2020 14:53:52 -0400 Subject: [PATCH 043/101] Test object permissions for individual/list model views --- netbox/utilities/testing/testcases.py | 288 ++++++++++++++++++++------ netbox/utilities/views.py | 1 + 2 files changed, 230 insertions(+), 59 deletions(-) diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py index de8b93232c..f6b5cdfd41 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -1,3 +1,4 @@ +from django.contrib.contenttypes.models import ContentType from django.contrib.auth.models import Permission, User from django.core.exceptions import ObjectDoesNotExist from django.forms.models import model_to_dict @@ -5,7 +6,8 @@ from django.urls import reverse, NoReverseMatch from rest_framework.test import APIClient -from users.models import Token +from users.models import ObjectPermission, Token +from utilities.permissions import get_permission_for_model from .utils import disable_warnings, post_data @@ -150,19 +152,41 @@ class GetObjectViewTestCase(ModelViewTestCase): Retrieve a single instance. """ @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_get_object(self): + def test_get_object_without_permission(self): instance = self.model.objects.first() - # Attempt to make the request without required permissions + # Try GET without permission with disable_warnings('django.request'): self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 403) - # Assign the required permission and submit again - self.add_permissions( - '{}.view_{}'.format(self.model._meta.app_label, self.model._meta.model_name) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_get_object_with_model_permission(self): + instance = self.model.objects.first() + + # Add model-level permission + self.add_permissions(get_permission_for_model(self.model, 'view')) + + # Try GET with model-level permission + self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_get_object_with_object_permission(self): + instance1, instance2 = self.model.objects.all()[:2] + + # Add object-level permission + obj_perm = ObjectPermission( + model=ContentType.objects.get_for_model(self.model), + attrs={'pk': instance1.pk}, + can_view=True ) - response = self.client.get(instance.get_absolute_url()) - self.assertHttpStatus(response, 200) + obj_perm.save() + obj_perm.users.add(self.user) + + # Try GET to permitted object + self.assertHttpStatus(self.client.get(instance1.get_absolute_url()), 200) + + # Try GET to non-permitted object + self.assertHttpStatus(self.client.get(instance2.get_absolute_url()), 404) class CreateObjectViewTestCase(ModelViewTestCase): """ @@ -171,33 +195,74 @@ class CreateObjectViewTestCase(ModelViewTestCase): form_data = {} @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_create_object(self): + def test_create_object_without_permission(self): # Try GET without permission with disable_warnings('django.request'): self.assertHttpStatus(self.client.post(self._get_url('add')), 403) - # Try GET with permission - self.add_permissions( - '{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name) - ) - response = self.client.get(path=self._get_url('add')) - self.assertHttpStatus(response, 200) + # Try POST without permission + request = { + 'path': self._get_url('add'), + 'data': post_data(self.form_data), + } + response = self.client.post(**request) + with disable_warnings('django.request'): + self.assertHttpStatus(response, 403) - # Try POST with permission + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_create_object_with_model_permission(self): initial_count = self.model.objects.count() + + # Assign model-level permission + self.add_permissions(get_permission_for_model(self.model, 'add')) + + # Try GET with model-level permission + self.assertHttpStatus(self.client.get(self._get_url('add')), 200) + + # Try POST with model-level permission request = { 'path': self._get_url('add'), 'data': post_data(self.form_data), - 'follow': False, # Do not follow 302 redirects } - response = self.client.post(**request) - self.assertHttpStatus(response, 302) + self.assertHttpStatus(self.client.post(**request), 302) + self.assertEqual(initial_count + 1, self.model.objects.count()) + self.assertInstanceEqual(self.model.objects.order_by('pk').last(), self.form_data) - # Validate object creation + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_create_object_with_object_permission(self): + initial_count = self.model.objects.count() + next_pk = self.model.objects.order_by('pk').last().pk + 1 + + # Assign object-level permission + obj_perm = ObjectPermission( + model=ContentType.objects.get_for_model(self.model), + attrs={'pk__gt': next_pk}, + can_add=True + ) + obj_perm.save() + obj_perm.users.add(self.user) + + # Try GET with object-level permission + self.assertHttpStatus(self.client.get(self._get_url('add')), 200) + + # Try to create permitted object + request = { + 'path': self._get_url('add'), + 'data': post_data(self.form_data), + } + self.assertHttpStatus(self.client.post(**request), 302) self.assertEqual(initial_count + 1, self.model.objects.count()) - instance = self.model.objects.order_by('-pk').first() - self.assertInstanceEqual(instance, self.form_data) + self.assertInstanceEqual(self.model.objects.order_by('pk').last(), self.form_data) + + # Try to create a non-permitted object + initial_count = self.model.objects.count() + request = { + 'path': self._get_url('add'), + 'data': post_data(self.form_data), + } + self.assertHttpStatus(self.client.post(**request), 200) + self.assertEqual(initial_count, self.model.objects.count()) # Check that no object was created class EditObjectViewTestCase(ModelViewTestCase): """ @@ -206,80 +271,167 @@ class EditObjectViewTestCase(ModelViewTestCase): form_data = {} @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_edit_object(self): + def test_edit_object_without_permission(self): instance = self.model.objects.first() # Try GET without permission with disable_warnings('django.request'): self.assertHttpStatus(self.client.post(self._get_url('edit', instance)), 403) - # Try GET with permission - self.add_permissions( - '{}.change_{}'.format(self.model._meta.app_label, self.model._meta.model_name) - ) - response = self.client.get(path=self._get_url('edit', instance)) - self.assertHttpStatus(response, 200) + # Try POST without permission + request = { + 'path': self._get_url('edit', instance), + 'data': post_data(self.form_data), + } + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.post(**request), 403) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_edit_object_with_model_permission(self): + instance = self.model.objects.first() + + # Assign model-level permission + self.add_permissions(get_permission_for_model(self.model, 'change')) + + # Try GET with model-level permission + self.assertHttpStatus(self.client.get(self._get_url('edit', instance)), 200) - # Try POST with permission + # Try POST with model-level permission request = { 'path': self._get_url('edit', instance), 'data': post_data(self.form_data), - 'follow': False, # Do not follow 302 redirects } - response = self.client.post(**request) - self.assertHttpStatus(response, 302) + self.assertHttpStatus(self.client.post(**request), 302) + self.assertInstanceEqual(self.model.objects.get(pk=instance.pk), self.form_data) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_edit_object_with_object_permission(self): + instance1, instance2 = self.model.objects.all()[:2] + + # Assign object-level permission + obj_perm = ObjectPermission( + model=ContentType.objects.get_for_model(self.model), + attrs={'pk': instance1.pk}, + can_change=True + ) + obj_perm.save() + obj_perm.users.add(self.user) + + # Try GET with a permitted object + self.assertHttpStatus(self.client.get(self._get_url('edit', instance1)), 200) + + # Try GET with a non-permitted object + self.assertHttpStatus(self.client.get(self._get_url('edit', instance2)), 404) - # Validate object modifications - instance = self.model.objects.get(pk=instance.pk) - self.assertInstanceEqual(instance, self.form_data) + # Try to edit a permitted object + request = { + 'path': self._get_url('edit', instance1), + 'data': post_data(self.form_data), + } + self.assertHttpStatus(self.client.post(**request), 302) + self.assertInstanceEqual(self.model.objects.get(pk=instance1.pk), self.form_data) + + # Try to edit a non-permitted object + request = { + 'path': self._get_url('edit', instance2), + 'data': post_data(self.form_data), + } + self.assertHttpStatus(self.client.post(**request), 404) class DeleteObjectViewTestCase(ModelViewTestCase): """ Delete a single instance. """ @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_delete_object(self): + def test_delete_object_without_permission(self): instance = self.model.objects.first() - # Try GET without permissions + # Try GET without permission with disable_warnings('django.request'): - self.assertHttpStatus(self.client.post(self._get_url('delete', instance)), 403) - - # Try GET with permission - self.add_permissions( - '{}.delete_{}'.format(self.model._meta.app_label, self.model._meta.model_name) - ) - response = self.client.get(path=self._get_url('delete', instance)) - self.assertHttpStatus(response, 200) + self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 403) + # Try POST without permission request = { 'path': self._get_url('delete', instance), - 'data': {'confirm': True}, - 'follow': False, # Do not follow 302 redirects + 'data': post_data({'confirm': True}), } - response = self.client.post(**request) - self.assertHttpStatus(response, 302) + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.post(**request), 403) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_delete_object_with_model_permission(self): + instance = self.model.objects.first() + + # Assign model-level permission + self.add_permissions(get_permission_for_model(self.model, 'delete')) - # Validate object deletion + # Try GET with model-level permission + self.assertHttpStatus(self.client.get(self._get_url('delete', instance)), 200) + + # Try POST with model-level permission + request = { + 'path': self._get_url('delete', instance), + 'data': post_data({'confirm': True}), + } + self.assertHttpStatus(self.client.post(**request), 302) with self.assertRaises(ObjectDoesNotExist): self.model.objects.get(pk=instance.pk) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_delete_object_with_object_permission(self): + instance1, instance2 = self.model.objects.all()[:2] + + # Assign object-level permission + obj_perm = ObjectPermission( + model=ContentType.objects.get_for_model(self.model), + attrs={'pk': instance1.pk}, + can_delete=True + ) + obj_perm.save() + obj_perm.users.add(self.user) + + # Try GET with a permitted object + self.assertHttpStatus(self.client.get(self._get_url('delete', instance1)), 200) + + # Try GET with a non-permitted object + self.assertHttpStatus(self.client.get(self._get_url('delete', instance2)), 404) + + # Try to delete a permitted object + request = { + 'path': self._get_url('delete', instance1), + 'data': post_data({'confirm': True}), + } + self.assertHttpStatus(self.client.post(**request), 302) + with self.assertRaises(ObjectDoesNotExist): + self.model.objects.get(pk=instance1.pk) + + # Try to delete a non-permitted object + request = { + 'path': self._get_url('delete', instance2), + 'data': post_data({'confirm': True}), + } + self.assertHttpStatus(self.client.post(**request), 404) + self.assertTrue(self.model.objects.filter(pk=instance2.pk).exists()) + class ListObjectsViewTestCase(ModelViewTestCase): """ Retrieve multiple instances. """ @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_list_objects(self): - # Attempt to make the request without required permissions + def test_list_objects_without_permission(self): + + # Try GET without permission with disable_warnings('django.request'): self.assertHttpStatus(self.client.get(self._get_url('list')), 403) - # Assign the required permission and submit again - self.add_permissions( - '{}.view_{}'.format(self.model._meta.app_label, self.model._meta.model_name) - ) - response = self.client.get(self._get_url('list')) - self.assertHttpStatus(response, 200) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_list_objects_with_model_permission(self): + + # Add model-level permission + self.add_permissions(get_permission_for_model(self.model, 'view')) + + # Try GET with model-level permission + self.assertHttpStatus(self.client.get(self._get_url('list')), 200) # Built-in CSV export if hasattr(self.model, 'csv_headers'): @@ -287,6 +439,24 @@ def test_list_objects(self): self.assertHttpStatus(response, 200) self.assertEqual(response.get('Content-Type'), 'text/csv') + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_list_objects_with_object_permission(self): + instance1, instance2 = self.model.objects.all()[:2] + + # Add object-level permission + obj_perm = ObjectPermission( + model=ContentType.objects.get_for_model(self.model), + attrs={'pk': instance1.pk}, + can_view=True + ) + obj_perm.save() + obj_perm.users.add(self.user) + + # Try GET with object-level permission + self.assertHttpStatus(self.client.get(self._get_url('list')), 200) + + # TODO: Verify that only the permitted object is returned + class BulkCreateObjectsViewTestCase(ModelViewTestCase): """ Create multiple instances using a single form. Expects the creation of three new instances by default. diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index b586342e11..5bba3fbe9f 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -347,6 +347,7 @@ def get(self, request, *args, **kwargs): def post(self, request, *args, **kwargs): logger = logging.getLogger('netbox.views.ObjectEditView') + obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs) form = self.model_form( data=request.POST, files=request.FILES, From 5273b9d0ee2384a70141f39b2fd0ba1243c046d4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 May 2020 14:57:35 -0400 Subject: [PATCH 044/101] Rename ImportObjectsViewTestCase --- netbox/utilities/testing/testcases.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py index f6b5cdfd41..1da5e28ac8 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -488,7 +488,7 @@ def test_bulk_create_objects(self): for instance in self.model.objects.order_by('-pk')[:self.bulk_create_count]: self.assertInstanceEqual(instance, self.bulk_create_data) - class ImportObjectsViewTestCase(ModelViewTestCase): + class BulkImportObjectsViewTestCase(ModelViewTestCase): """ Create multiple instances from imported data. """ @@ -598,7 +598,7 @@ class PrimaryObjectViewTestCase( EditObjectViewTestCase, DeleteObjectViewTestCase, ListObjectsViewTestCase, - ImportObjectsViewTestCase, + BulkImportObjectsViewTestCase, BulkEditObjectsViewTestCase, BulkDeleteObjectsViewTestCase, ): @@ -611,7 +611,7 @@ class OrganizationalObjectViewTestCase( CreateObjectViewTestCase, EditObjectViewTestCase, ListObjectsViewTestCase, - ImportObjectsViewTestCase, + BulkImportObjectsViewTestCase, BulkDeleteObjectsViewTestCase, ): """ @@ -636,7 +636,7 @@ class DeviceComponentViewTestCase( DeleteObjectViewTestCase, ListObjectsViewTestCase, BulkCreateObjectsViewTestCase, - ImportObjectsViewTestCase, + BulkImportObjectsViewTestCase, BulkEditObjectsViewTestCase, BulkDeleteObjectsViewTestCase, ): From 77a49fa40e3c8bee8007acbcdbd464f685992114 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 May 2020 16:04:43 -0400 Subject: [PATCH 045/101] Extend bulk import/edit/delete view tests to support object-level permissions --- netbox/utilities/testing/testcases.py | 202 +++++++++++++++++++------- 1 file changed, 146 insertions(+), 56 deletions(-) diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py index 1da5e28ac8..ca9df4ac88 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -232,12 +232,11 @@ def test_create_object_with_model_permission(self): @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_create_object_with_object_permission(self): initial_count = self.model.objects.count() - next_pk = self.model.objects.order_by('pk').last().pk + 1 # Assign object-level permission obj_perm = ObjectPermission( model=ContentType.objects.get_for_model(self.model), - attrs={'pk__gt': next_pk}, + attrs={'pk__gt': 0}, # Dummy permission to allow all can_add=True ) obj_perm.save() @@ -255,6 +254,10 @@ def test_create_object_with_object_permission(self): self.assertEqual(initial_count + 1, self.model.objects.count()) self.assertInstanceEqual(self.model.objects.order_by('pk').last(), self.form_data) + # Nullify ObjectPermission to disallow new object creation + obj_perm.attrs = {'pk': 0} + obj_perm.save() + # Try to create a non-permitted object initial_count = self.model.objects.count() request = { @@ -470,7 +473,6 @@ def test_bulk_create_objects(self): request = { 'path': self._get_url('add'), 'data': post_data(self.bulk_create_data), - 'follow': False, # Do not follow 302 redirects } # Attempt to make the request without required permissions @@ -494,35 +496,63 @@ class BulkImportObjectsViewTestCase(ModelViewTestCase): """ csv_data = () + def _get_csv_data(self): + return '\n'.join(self.csv_data) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_import_objects(self): + def test_bulk_import_objects_without_permission(self): + data = { + 'csv': self._get_csv_data(), + } # Test GET without permission with disable_warnings('django.request'): self.assertHttpStatus(self.client.get(self._get_url('import')), 403) - # Test GET with permission - self.add_permissions( - '{}.view_{}'.format(self.model._meta.app_label, self.model._meta.model_name), - '{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name) - ) - response = self.client.get(self._get_url('import')) - self.assertHttpStatus(response, 200) + # Try POST without permission + response = self.client.post(self._get_url('import'), data) + with disable_warnings('django.request'): + self.assertHttpStatus(response, 403) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_bulk_import_objects_with_model_permission(self): + initial_count = self.model.objects.count() + data = { + 'csv': self._get_csv_data(), + } + + # Assign model-level permission + self.add_permissions(get_permission_for_model(self.model, 'add')) + + # Try GET with model-level permission + self.assertHttpStatus(self.client.get(self._get_url('import')), 200) # Test POST with permission + self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) + self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_bulk_import_objects_with_object_permission(self): initial_count = self.model.objects.count() - request = { - 'path': self._get_url('import'), - 'data': { - 'csv': '\n'.join(self.csv_data) - } + data = { + 'csv': self._get_csv_data(), } - response = self.client.post(**request) - self.assertHttpStatus(response, 200) - # Validate import of new objects + # Assign object-level permission + obj_perm = ObjectPermission( + model=ContentType.objects.get_for_model(self.model), + attrs={'pk__gt': 0}, # Dummy permission to allow all + can_add=True + ) + obj_perm.save() + obj_perm.users.add(self.user) + + # Test import with object-level permission + self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1) + # TODO: Test importing non-permitted objects + class BulkEditObjectsViewTestCase(ModelViewTestCase): """ Edit multiple instances. @@ -530,68 +560,128 @@ class BulkEditObjectsViewTestCase(ModelViewTestCase): bulk_edit_data = {} @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_bulk_edit_objects(self): - # Bulk edit the first three objects only + def test_bulk_edit_objects_without_permission(self): pk_list = self.model.objects.values_list('pk', flat=True)[:3] + data = { + 'pk': pk_list, + '_apply': True, # Form button + } - request = { - 'path': self._get_url('bulk_edit'), - 'data': { - 'pk': pk_list, - '_apply': True, # Form button - }, - 'follow': False, # Do not follow 302 redirects + # Test GET without permission + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.get(self._get_url('bulk_edit')), 403) + + # Try POST without permission + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 403) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_bulk_edit_objects_with_model_permission(self): + pk_list = self.model.objects.values_list('pk', flat=True)[:3] + data = { + 'pk': pk_list, + '_apply': True, # Form button } # Append the form data to the request - request['data'].update(post_data(self.bulk_edit_data)) + data.update(post_data(self.bulk_edit_data)) - # Attempt to make the request without required permissions - with disable_warnings('django.request'): - self.assertHttpStatus(self.client.post(**request), 403) + # Assign model-level permission + self.add_permissions(get_permission_for_model(self.model, 'change')) - # Assign the required permission and submit again - self.add_permissions( - '{}.change_{}'.format(self.model._meta.app_label, self.model._meta.model_name) + # Try POST with model-level permission + self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302) + for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)): + self.assertInstanceEqual(instance, self.bulk_edit_data) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_bulk_edit_objects_with_object_permission(self): + pk_list = self.model.objects.values_list('pk', flat=True)[:3] + data = { + 'pk': pk_list, + '_apply': True, # Form button + } + + # Append the form data to the request + data.update(post_data(self.bulk_edit_data)) + + # Assign object-level permission + obj_perm = ObjectPermission( + model=ContentType.objects.get_for_model(self.model), + attrs={'pk__in': list(pk_list)}, + can_change=True ) - response = self.client.post(**request) - self.assertHttpStatus(response, 302) + obj_perm.save() + obj_perm.users.add(self.user) + # Try POST with model-level permission + self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302) for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)): self.assertInstanceEqual(instance, self.bulk_edit_data) + # TODO: Test editing non-permitted objects + class BulkDeleteObjectsViewTestCase(ModelViewTestCase): """ Delete multiple instances. """ @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_bulk_delete_objects(self): - pk_list = self.model.objects.values_list('pk', flat=True) - - request = { - 'path': self._get_url('bulk_delete'), - 'data': { - 'pk': pk_list, - 'confirm': True, - '_confirm': True, # Form button - }, - 'follow': False, # Do not follow 302 redirects + def test_bulk_delete_objects_without_permission(self): + pk_list = self.model.objects.values_list('pk', flat=True)[:3] + data = { + 'pk': pk_list, + 'confirm': True, + '_confirm': True, # Form button } - # Attempt to make the request without required permissions + # Test GET without permission with disable_warnings('django.request'): - self.assertHttpStatus(self.client.post(**request), 403) + self.assertHttpStatus(self.client.get(self._get_url('bulk_delete')), 403) - # Assign the required permission and submit again - self.add_permissions( - '{}.delete_{}'.format(self.model._meta.app_label, self.model._meta.model_name) + # Try POST without permission + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 403) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_bulk_delete_objects_with_model_permission(self): + pk_list = self.model.objects.values_list('pk', flat=True) + data = { + 'pk': pk_list, + 'confirm': True, + '_confirm': True, # Form button + } + + # Assign model-level permission + self.add_permissions(get_permission_for_model(self.model, 'delete')) + + # Try POST with model-level permission + self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302) + self.assertEqual(self.model.objects.count(), 0) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_bulk_delete_objects_with_object_permission(self): + pk_list = self.model.objects.values_list('pk', flat=True) + data = { + 'pk': pk_list, + 'confirm': True, + '_confirm': True, # Form button + } + + # Assign object-level permission + obj_perm = ObjectPermission( + model=ContentType.objects.get_for_model(self.model), + attrs={'pk__in': list(pk_list)}, + can_delete=True ) - response = self.client.post(**request) - self.assertHttpStatus(response, 302) + obj_perm.save() + obj_perm.users.add(self.user) - # Check that all objects were deleted + # Try POST with object-level permission + self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302) self.assertEqual(self.model.objects.count(), 0) + # TODO: Test deleting non-permitted objects + class PrimaryObjectViewTestCase( GetObjectViewTestCase, CreateObjectViewTestCase, From 635fefcb5c5bad6a224e62b2a6a47b3bf2561415 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 May 2020 16:33:56 -0400 Subject: [PATCH 046/101] Update exempted tests --- netbox/dcim/tests/test_views.py | 20 +++++++++++++++----- netbox/extras/tests/test_views.py | 20 +++++++++++++++----- netbox/ipam/tests/test_views.py | 4 +++- netbox/secrets/tests/test_views.py | 8 ++++++-- netbox/utilities/testing/testcases.py | 2 +- netbox/virtualization/tests/test_views.py | 8 ++++++-- 6 files changed, 46 insertions(+), 16 deletions(-) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 65f37c1d53..ef8bd3d5fb 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -794,7 +794,9 @@ class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas model = DeviceBayTemplate # Disable inapplicable views - test_bulk_edit_objects = None + test_bulk_edit_objects_without_permission = None + test_bulk_edit_objects_with_model_permission = None + test_bulk_edit_objects_with_object_permission = None @classmethod def setUpTestData(cls): @@ -1439,7 +1441,9 @@ class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Cable # TODO: Creation URL needs termination context - test_create_object = None + test_create_object_without_permission = None + test_create_object_with_model_permission = None + test_create_object_with_object_permission = None @classmethod def setUpTestData(cls): @@ -1513,11 +1517,17 @@ class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = VirtualChassis # Disable inapplicable tests - test_import_objects = None + test_bulk_import_objects_without_permission = None + test_bulk_import_objects_with_model_permission = None + test_bulk_import_objects_with_object_permission = None # TODO: Requires special form handling - test_create_object = None - test_edit_object = None + test_create_object_without_permission = None + test_create_object_with_model_permission = None + test_create_object_with_object_permission = None + test_edit_object_without_permission = None + test_edit_object_with_model_permission = None + test_edit_object_with_object_permission = None @classmethod def setUpTestData(cls): diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 370055b26d..f52054cc15 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -14,8 +14,12 @@ class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Tag # Disable inapplicable tests - test_create_object = None - test_import_objects = None + test_create_object_without_permission = None + test_create_object_with_model_permission = None + test_create_object_with_object_permission = None + test_bulk_import_objects_without_permission = None + test_bulk_import_objects_with_model_permission = None + test_bulk_import_objects_with_object_permission = None @classmethod def setUpTestData(cls): @@ -42,11 +46,17 @@ class ConfigContextTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = ConfigContext # Disable inapplicable tests - test_import_objects = None + test_bulk_import_objects_without_permission = None + test_bulk_import_objects_with_model_permission = None + test_bulk_import_objects_with_object_permission = None # TODO: Resolve model discrepancies when creating/editing ConfigContexts - test_create_object = None - test_edit_object = None + test_create_object_without_permission = None + test_create_object_with_model_permission = None + test_create_object_with_object_permission = None + test_edit_object_without_permission = None + test_edit_object_with_model_permission = None + test_edit_object_with_object_permission = None @classmethod def setUpTestData(cls): diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 8867a6b430..bbd2524731 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -337,7 +337,9 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Service # TODO: Resolve URL for Service creation - test_create_object = None + test_create_object_without_permission = None + test_create_object_with_model_permission = None + test_create_object_with_object_permission = None @classmethod def setUpTestData(cls): diff --git a/netbox/secrets/tests/test_views.py b/netbox/secrets/tests/test_views.py index 96439a10d1..7796be63d1 100644 --- a/netbox/secrets/tests/test_views.py +++ b/netbox/secrets/tests/test_views.py @@ -40,10 +40,14 @@ class SecretTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Secret # Disable inapplicable tests - test_create_object = None + test_create_object_without_permission = None + test_create_object_with_model_permission = None + test_create_object_with_object_permission = None # TODO: Check permissions enforcement on secrets.views.secret_edit - test_edit_object = None + test_edit_object_without_permission = None + test_edit_object_with_model_permission = None + test_edit_object_with_object_permission = None @classmethod def setUpTestData(cls): diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py index ca9df4ac88..475cdb09f3 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -351,7 +351,7 @@ def test_delete_object_without_permission(self): # Try GET without permission with disable_warnings('django.request'): - self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 403) + self.assertHttpStatus(self.client.get(self._get_url('delete', instance)), 403) # Try POST without permission request = { diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index e7bb19285f..006db34d60 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -192,8 +192,12 @@ class InterfaceTestCase( model = Interface # Disable inapplicable tests - test_list_objects = None - test_import_objects = None + test_list_objects_without_permission = None + test_list_objects_with_model_permission = None + test_list_objects_with_object_permission = None + test_bulk_import_objects_without_permission = None + test_bulk_import_objects_with_model_permission = None + test_bulk_import_objects_with_object_permission = None def _get_base_url(self): # Interface belongs to the DCIM app, so we have to override the base URL From 5dddf6846b22ef7d085981c23c560a8c3b9f1b6d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 27 May 2020 10:48:56 -0400 Subject: [PATCH 047/101] Disable built-in model permissions --- netbox/netbox/settings.py | 2 +- .../users/migrations/0007_objectpermission.py | 8 +- netbox/users/models.py | 17 +- netbox/utilities/auth_backends.py | 84 +++--- netbox/utilities/testing/testcases.py | 250 +++++++++--------- netbox/utilities/views.py | 26 +- 6 files changed, 197 insertions(+), 190 deletions(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 266f1afd74..f4ee6fff23 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -339,7 +339,7 @@ def _setting(name, default=None): # Set up authentication backends AUTHENTICATION_BACKENDS = [ 'utilities.auth_backends.ObjectPermissionBackend', - REMOTE_AUTH_BACKEND, + # REMOTE_AUTH_BACKEND, ] # Internationalization diff --git a/netbox/users/migrations/0007_objectpermission.py b/netbox/users/migrations/0007_objectpermission.py index d805c3379e..1fadcc9a54 100644 --- a/netbox/users/migrations/0007_objectpermission.py +++ b/netbox/users/migrations/0007_objectpermission.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.6 on 2020-05-08 20:18 +# Generated by Django 3.0.6 on 2020-05-27 14:17 from django.conf import settings import django.contrib.postgres.fields.jsonb @@ -9,9 +9,9 @@ class Migration(migrations.Migration): dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('auth', '0011_update_proxy_permissions'), ('contenttypes', '0002_remove_content_type_name'), + ('auth', '0011_update_proxy_permissions'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('users', '0006_create_userconfigs'), ] @@ -20,7 +20,7 @@ class Migration(migrations.Migration): name='ObjectPermission', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), - ('attrs', django.contrib.postgres.fields.jsonb.JSONField()), + ('attrs', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)), ('can_view', models.BooleanField(default=False)), ('can_add', models.BooleanField(default=False)), ('can_change', models.BooleanField(default=False)), diff --git a/netbox/users/models.py b/netbox/users/models.py index 70e7254e64..b9ab6cbb59 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -240,6 +240,8 @@ class ObjectPermission(models.Model): on_delete=models.CASCADE ) attrs = JSONField( + blank=True, + null=True, verbose_name='Attributes' ) can_view = models.BooleanField( @@ -264,10 +266,11 @@ def clean(self): # Validate the specified model attributes by attempting to execute a query. We don't care whether the query # returns anything; we just want to make sure the specified attributes are valid. - model = self.model.model_class() - try: - model.objects.filter(**self.attrs).exists() - except FieldError as e: - raise ValidationError({ - 'attrs': f'Invalid attributes for {model}: {e}' - }) + if self.attrs: + model = self.model.model_class() + try: + model.objects.filter(**self.attrs).exists() + except FieldError as e: + raise ValidationError({ + 'attrs': f'Invalid attributes for {model}: {e}' + }) diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py index e540a04e06..8cf8b621cc 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -12,43 +12,53 @@ class ObjectPermissionBackend(ModelBackend): def get_object_permissions(self, user_obj): """ - Return all model-level permissions granted to the user by an ObjectPermission. + Return all permissions granted to the user by an ObjectPermission. """ if not hasattr(user_obj, '_object_perm_cache'): - # Cache all assigned ObjectPermissions on the User instance - perms = set() - for obj_perm in ObjectPermission.objects.filter( + # Retrieve all assigned ObjectPermissions + object_permissions = ObjectPermission.objects.filter( Q(users=user_obj) | Q(groups__user=user_obj) - ).prefetch_related('model'): + ).prefetch_related('model') + + # Create a dictionary mapping permissions to their attributes + perms = dict() + for obj_perm in object_permissions: for action in ['view', 'add', 'change', 'delete']: if getattr(obj_perm, f"can_{action}"): - perms.add(f"{obj_perm.model.app_label}.{action}_{obj_perm.model.model}") + perm_name = f"{obj_perm.model.app_label}.{action}_{obj_perm.model.model}" + if perm_name in perms: + perms[perm_name].append(obj_perm.attrs) + else: + perms[perm_name] = [obj_perm.attrs] + + # Cache resolved permissions on the User instance setattr(user_obj, '_object_perm_cache', perms) return user_obj._object_perm_cache - def get_all_permissions(self, user_obj, obj=None): - - # Handle inactive/anonymous users - if not user_obj.is_active or user_obj.is_anonymous: - return set() - - # Cache model-level permissions on the User instance - if not hasattr(user_obj, '_perm_cache'): - user_obj._perm_cache = { - *self.get_user_permissions(user_obj, obj=obj), - *self.get_group_permissions(user_obj, obj=obj), - *self.get_object_permissions(user_obj) - } - - return user_obj._perm_cache + # def get_all_permissions(self, user_obj, obj=None): + # + # # Handle inactive/anonymous users + # if not user_obj.is_active or user_obj.is_anonymous: + # return set() + # + # # Cache object permissions on the User instance + # if not hasattr(user_obj, '_perm_cache'): + # user_obj._perm_cache = self.get_object_permissions(user_obj) + # + # return user_obj._perm_cache def has_perm(self, user_obj, perm, obj=None): + # print(f'has_perm({perm})') app_label, codename = perm.split('.') action, model_name = codename.split('_') + # Superusers implicitly have all permissions + if user_obj.is_active and user_obj.is_superuser: + return True + # If this is a view permission, check whether the model has been exempted from enforcement if action == 'view': if ( @@ -60,29 +70,29 @@ def has_perm(self, user_obj, perm, obj=None): ): return True - # If no object is specified, evaluate model-level permissions. The presence of a permission in this set tells - # us that the user has permission for *some* objects, but not necessarily a specific object. + # Handle inactive/anonymous users + if not user_obj.is_active or user_obj.is_anonymous: + return False + + # If no applicable ObjectPermissions have been created for this user/permission, deny permission + if perm not in self.get_object_permissions(user_obj): + return False + + # If no object has been specified, grant permission. (The presence of a permission in this set tells + # us that the user has permission for *some* objects, but not necessarily a specific object.) if obj is None: - return perm in self.get_all_permissions(user_obj) + return True # Sanity check: Ensure that the requested permission applies to the specified object model = obj._meta.model if model._meta.label_lower != '.'.join((app_label, model_name)): raise ValueError(f"Invalid permission {perm} for model {model}") - # If the user has been granted model-level permission for the object, return True - model_perms = { - *self.get_user_permissions(user_obj), - *self.get_group_permissions(user_obj), - } - if perm in model_perms: - return True - - # Gather all ObjectPermissions pertinent to the requested permission. If none are found, the User has no - # applicable permissions. - attrs = ObjectPermission.objects.get_attr_constraints(user_obj, perm) - if not attrs: - return False + # Compile a query filter that matches all instances of the specified model + obj_perm_attrs = self.get_object_permissions(user_obj)[perm] + attrs = Q() + for perm_attrs in obj_perm_attrs: + attrs |= Q(**perm_attrs.attrs) # Permission to perform the requested action on the object depends on whether the specified object matches # the specified attributes. Note that this check is made against the *database* record representing the object, diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py index 6f878986bc..3d0ad1ef3c 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -159,15 +159,15 @@ def test_get_object_without_permission(self): with disable_warnings('django.request'): self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 403) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_get_object_with_model_permission(self): - instance = self.model.objects.first() - - # Add model-level permission - self.add_permissions(get_permission_for_model(self.model, 'view')) - - # Try GET with model-level permission - self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 200) + # @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + # def test_get_object_with_model_permission(self): + # instance = self.model.objects.first() + # + # # Add model-level permission + # self.add_permissions(get_permission_for_model(self.model, 'view')) + # + # # Try GET with model-level permission + # self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 200) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_get_object_with_object_permission(self): @@ -217,24 +217,24 @@ def test_create_object_without_permission(self): with disable_warnings('django.request'): self.assertHttpStatus(response, 403) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_create_object_with_model_permission(self): - initial_count = self.model.objects.count() - - # Assign model-level permission - self.add_permissions(get_permission_for_model(self.model, 'add')) - - # Try GET with model-level permission - self.assertHttpStatus(self.client.get(self._get_url('add')), 200) - - # Try POST with model-level permission - request = { - 'path': self._get_url('add'), - 'data': post_data(self.form_data), - } - self.assertHttpStatus(self.client.post(**request), 302) - self.assertEqual(initial_count + 1, self.model.objects.count()) - self.assertInstanceEqual(self.model.objects.order_by('pk').last(), self.form_data) + # @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + # def test_create_object_with_model_permission(self): + # initial_count = self.model.objects.count() + # + # # Assign model-level permission + # self.add_permissions(get_permission_for_model(self.model, 'add')) + # + # # Try GET with model-level permission + # self.assertHttpStatus(self.client.get(self._get_url('add')), 200) + # + # # Try POST with model-level permission + # request = { + # 'path': self._get_url('add'), + # 'data': post_data(self.form_data), + # } + # self.assertHttpStatus(self.client.post(**request), 302) + # self.assertEqual(initial_count + 1, self.model.objects.count()) + # self.assertInstanceEqual(self.model.objects.order_by('pk').last(), self.form_data) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_create_object_with_object_permission(self): @@ -296,23 +296,23 @@ def test_edit_object_without_permission(self): with disable_warnings('django.request'): self.assertHttpStatus(self.client.post(**request), 403) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_edit_object_with_model_permission(self): - instance = self.model.objects.first() - - # Assign model-level permission - self.add_permissions(get_permission_for_model(self.model, 'change')) - - # Try GET with model-level permission - self.assertHttpStatus(self.client.get(self._get_url('edit', instance)), 200) - - # Try POST with model-level permission - request = { - 'path': self._get_url('edit', instance), - 'data': post_data(self.form_data), - } - self.assertHttpStatus(self.client.post(**request), 302) - self.assertInstanceEqual(self.model.objects.get(pk=instance.pk), self.form_data) + # @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + # def test_edit_object_with_model_permission(self): + # instance = self.model.objects.first() + # + # # Assign model-level permission + # self.add_permissions(get_permission_for_model(self.model, 'change')) + # + # # Try GET with model-level permission + # self.assertHttpStatus(self.client.get(self._get_url('edit', instance)), 200) + # + # # Try POST with model-level permission + # request = { + # 'path': self._get_url('edit', instance), + # 'data': post_data(self.form_data), + # } + # self.assertHttpStatus(self.client.post(**request), 302) + # self.assertInstanceEqual(self.model.objects.get(pk=instance.pk), self.form_data) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_edit_object_with_object_permission(self): @@ -368,24 +368,24 @@ def test_delete_object_without_permission(self): with disable_warnings('django.request'): self.assertHttpStatus(self.client.post(**request), 403) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_delete_object_with_model_permission(self): - instance = self.model.objects.first() - - # Assign model-level permission - self.add_permissions(get_permission_for_model(self.model, 'delete')) - - # Try GET with model-level permission - self.assertHttpStatus(self.client.get(self._get_url('delete', instance)), 200) - - # Try POST with model-level permission - request = { - 'path': self._get_url('delete', instance), - 'data': post_data({'confirm': True}), - } - self.assertHttpStatus(self.client.post(**request), 302) - with self.assertRaises(ObjectDoesNotExist): - self.model.objects.get(pk=instance.pk) + # @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + # def test_delete_object_with_model_permission(self): + # instance = self.model.objects.first() + # + # # Assign model-level permission + # self.add_permissions(get_permission_for_model(self.model, 'delete')) + # + # # Try GET with model-level permission + # self.assertHttpStatus(self.client.get(self._get_url('delete', instance)), 200) + # + # # Try POST with model-level permission + # request = { + # 'path': self._get_url('delete', instance), + # 'data': post_data({'confirm': True}), + # } + # self.assertHttpStatus(self.client.post(**request), 302) + # with self.assertRaises(ObjectDoesNotExist): + # self.model.objects.get(pk=instance.pk) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_delete_object_with_object_permission(self): @@ -434,20 +434,20 @@ def test_list_objects_without_permission(self): with disable_warnings('django.request'): self.assertHttpStatus(self.client.get(self._get_url('list')), 403) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_list_objects_with_model_permission(self): - - # Add model-level permission - self.add_permissions(get_permission_for_model(self.model, 'view')) - - # Try GET with model-level permission - self.assertHttpStatus(self.client.get(self._get_url('list')), 200) - - # Built-in CSV export - if hasattr(self.model, 'csv_headers'): - response = self.client.get('{}?export'.format(self._get_url('list'))) - self.assertHttpStatus(response, 200) - self.assertEqual(response.get('Content-Type'), 'text/csv') + # @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + # def test_list_objects_with_model_permission(self): + # + # # Add model-level permission + # self.add_permissions(get_permission_for_model(self.model, 'view')) + # + # # Try GET with model-level permission + # self.assertHttpStatus(self.client.get(self._get_url('list')), 200) + # + # # Built-in CSV export + # if hasattr(self.model, 'csv_headers'): + # response = self.client.get('{}?export'.format(self._get_url('list'))) + # self.assertHttpStatus(response, 200) + # self.assertEqual(response.get('Content-Type'), 'text/csv') @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_list_objects_with_object_permission(self): @@ -528,22 +528,22 @@ def test_bulk_import_objects_without_permission(self): with disable_warnings('django.request'): self.assertHttpStatus(response, 403) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_bulk_import_objects_with_model_permission(self): - initial_count = self.model.objects.count() - data = { - 'csv': self._get_csv_data(), - } - - # Assign model-level permission - self.add_permissions(get_permission_for_model(self.model, 'add')) - - # Try GET with model-level permission - self.assertHttpStatus(self.client.get(self._get_url('import')), 200) - - # Test POST with permission - self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) - self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1) + # @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + # def test_bulk_import_objects_with_model_permission(self): + # initial_count = self.model.objects.count() + # data = { + # 'csv': self._get_csv_data(), + # } + # + # # Assign model-level permission + # self.add_permissions(get_permission_for_model(self.model, 'add')) + # + # # Try GET with model-level permission + # self.assertHttpStatus(self.client.get(self._get_url('import')), 200) + # + # # Test POST with permission + # self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) + # self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_bulk_import_objects_with_object_permission(self): @@ -589,24 +589,24 @@ def test_bulk_edit_objects_without_permission(self): with disable_warnings('django.request'): self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 403) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_bulk_edit_objects_with_model_permission(self): - pk_list = self.model.objects.values_list('pk', flat=True)[:3] - data = { - 'pk': pk_list, - '_apply': True, # Form button - } - - # Append the form data to the request - data.update(post_data(self.bulk_edit_data)) - - # Assign model-level permission - self.add_permissions(get_permission_for_model(self.model, 'change')) - - # Try POST with model-level permission - self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302) - for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)): - self.assertInstanceEqual(instance, self.bulk_edit_data) + # @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + # def test_bulk_edit_objects_with_model_permission(self): + # pk_list = self.model.objects.values_list('pk', flat=True)[:3] + # data = { + # 'pk': pk_list, + # '_apply': True, # Form button + # } + # + # # Append the form data to the request + # data.update(post_data(self.bulk_edit_data)) + # + # # Assign model-level permission + # self.add_permissions(get_permission_for_model(self.model, 'change')) + # + # # Try POST with model-level permission + # self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302) + # for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)): + # self.assertInstanceEqual(instance, self.bulk_edit_data) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_bulk_edit_objects_with_object_permission(self): @@ -656,21 +656,21 @@ def test_bulk_delete_objects_without_permission(self): with disable_warnings('django.request'): self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 403) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_bulk_delete_objects_with_model_permission(self): - pk_list = self.model.objects.values_list('pk', flat=True) - data = { - 'pk': pk_list, - 'confirm': True, - '_confirm': True, # Form button - } - - # Assign model-level permission - self.add_permissions(get_permission_for_model(self.model, 'delete')) - - # Try POST with model-level permission - self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302) - self.assertEqual(self.model.objects.count(), 0) + # @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + # def test_bulk_delete_objects_with_model_permission(self): + # pk_list = self.model.objects.values_list('pk', flat=True) + # data = { + # 'pk': pk_list, + # 'confirm': True, + # '_confirm': True, # Form button + # } + # + # # Assign model-level permission + # self.add_permissions(get_permission_for_model(self.model, 'delete')) + # + # # Try POST with model-level permission + # self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302) + # self.assertEqual(self.model.objects.count(), 0) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_bulk_delete_objects_with_object_permission(self): diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index cbedecd4da..6e93c23698 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -8,7 +8,7 @@ from django.contrib.auth.mixins import AccessMixin from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured, ObjectDoesNotExist, ValidationError from django.db import transaction, IntegrityError -from django.db.models import ManyToManyField, ProtectedError +from django.db.models import ManyToManyField, ProtectedError, Q from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea from django.http import HttpResponse, HttpResponseServerError from django.shortcuts import get_object_or_404, redirect, render @@ -65,22 +65,16 @@ def has_permission(self): if not user.has_perms((permission_required, *self.additional_permissions)): return False - # Superusers implicitly have all permissions - if user.is_superuser: - return True - - # Determine whether the permission is model-level or object-level. Model-level permissions grant the - # specified action to *all* objects, so no further action is needed. - if permission_required in {*user._user_perm_cache, *user._group_perm_cache}: - return True - - # If the permission is granted only at the object level, filter the view's queryset to return only objects - # on which the user is permitted to perform the specified action. - attrs = ObjectPermission.objects.get_attr_constraints(user, permission_required) - if attrs: - # Update the view's QuerySet to filter only the permitted objects + # Update the view's QuerySet to filter only the permitted objects + if user.is_authenticated: + obj_perm_attrs = user._object_perm_cache[permission_required] + attrs = Q() + for perm_attrs in obj_perm_attrs: + if perm_attrs: + attrs |= Q(**perm_attrs) self.queryset = self.queryset.filter(attrs) - return True + + return True def dispatch(self, request, *args, **kwargs): From 4cee506710fd9862542044bbac5fd8198482b104 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 27 May 2020 10:52:59 -0400 Subject: [PATCH 048/101] Rebase RemoteUserBackend on BaseBackend --- netbox/netbox/settings.py | 2 +- netbox/utilities/auth_backends.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index f4ee6fff23..266f1afd74 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -339,7 +339,7 @@ def _setting(name, default=None): # Set up authentication backends AUTHENTICATION_BACKENDS = [ 'utilities.auth_backends.ObjectPermissionBackend', - # REMOTE_AUTH_BACKEND, + REMOTE_AUTH_BACKEND, ] # Internationalization diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py index 8cf8b621cc..3d5ec18301 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -1,7 +1,7 @@ import logging from django.conf import settings -from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as RemoteUserBackend_ +from django.contrib.auth.backends import BaseBackend, ModelBackend from django.contrib.auth.models import Group, Permission from django.db.models import Q @@ -100,7 +100,7 @@ def has_perm(self, user_obj, perm, obj=None): return model.objects.filter(attrs, pk=obj.pk).exists() -class RemoteUserBackend(RemoteUserBackend_): +class RemoteUserBackend(BaseBackend): """ Custom implementation of Django's RemoteUserBackend which provides configuration hooks for basic customization. """ From a6a88a0d2ead5f011bce4ce2a0f73dc0e0d50244 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 27 May 2020 11:30:36 -0400 Subject: [PATCH 049/101] Delete extraneous test case --- netbox/users/tests/test_permissions.py | 62 -------------------------- 1 file changed, 62 deletions(-) delete mode 100644 netbox/users/tests/test_permissions.py diff --git a/netbox/users/tests/test_permissions.py b/netbox/users/tests/test_permissions.py deleted file mode 100644 index 487543bd3f..0000000000 --- a/netbox/users/tests/test_permissions.py +++ /dev/null @@ -1,62 +0,0 @@ -from django.contrib.contenttypes.models import ContentType -from django.contrib.auth.models import User -from django.test import TestCase, override_settings - -from dcim.models import Site -from tenancy.models import Tenant -from users.models import ObjectPermission - - -class ObjectPermissionTest(TestCase): - - def setUp(self): - - self.user = User.objects.create_user(username='testuser') - - @classmethod - def setUpTestData(cls): - - tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1') - Site.objects.bulk_create(( - Site(name='Site 1', slug='site-1'), - Site(name='Site 2', slug='site-2', tenant=tenant), - Site(name='Site 3', slug='site-3'), - )) - - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_permission_view_object(self): - - # Sanity check to ensure the user has no model-level permission - self.assertFalse(self.user.has_perm('dcim.view_site')) - - # The permission check for a specific object should fail. - sites = Site.objects.all() - self.assertFalse(self.user.has_perm('dcim.view_site', sites[0])) - - # Create and assign a new ObjectPermission specifying the first site by name. - ct = ContentType.objects.get_for_model(sites[0]) - object_perm = ObjectPermission( - model=ct, - attrs={'name': 'Site 1'}, - can_view=True - ) - object_perm.save() - object_perm.users.add(self.user) - - # The test user should have permission to view only the first site. - self.assertTrue(self.user.has_perm('dcim.view_site', sites[0])) - self.assertFalse(self.user.has_perm('dcim.view_site', sites[1])) - - # Create a second ObjectPermission matching sites by assigned tenant. - object_perm = ObjectPermission( - model=ct, - attrs={'tenant__name': 'Tenant 1'}, - can_view=True - ) - object_perm.save() - object_perm.users.add(self.user) - - # The user should now able to view the first two sites, but not the third. - self.assertTrue(self.user.has_perm('dcim.view_site', sites[0])) - self.assertTrue(self.user.has_perm('dcim.view_site', sites[1])) - self.assertFalse(self.user.has_perm('dcim.view_site', sites[2])) From fb7446487e7e54db8a8feac14f4e69c506fd22e7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 27 May 2020 11:31:07 -0400 Subject: [PATCH 050/101] Fix up permissions evaluation --- netbox/utilities/api.py | 11 +++++------ netbox/utilities/auth_backends.py | 14 +------------- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 745f812ff6..2d7ae2385e 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -340,12 +340,11 @@ def initial(self, request, *args, **kwargs): permission_required = TokenPermissions.perms_map[request.method][0] % kwargs # Enforce object-level permissions - if permission_required not in {*request.user._user_perm_cache, *request.user._group_perm_cache}: - attrs = ObjectPermission.objects.get_attr_constraints(request.user, permission_required) - if attrs: - # Update the view's QuerySet to filter only the permitted objects - self.queryset = self.queryset.filter(attrs) - return True + attrs = ObjectPermission.objects.get_attr_constraints(request.user, permission_required) + if attrs: + # Update the view's QuerySet to filter only the permitted objects + self.queryset = self.queryset.filter(attrs) + return True def dispatch(self, request, *args, **kwargs): logger = logging.getLogger('netbox.api.views.ModelViewSet') diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py index 3d5ec18301..bcf2fa119b 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -38,18 +38,6 @@ def get_object_permissions(self, user_obj): return user_obj._object_perm_cache - # def get_all_permissions(self, user_obj, obj=None): - # - # # Handle inactive/anonymous users - # if not user_obj.is_active or user_obj.is_anonymous: - # return set() - # - # # Cache object permissions on the User instance - # if not hasattr(user_obj, '_perm_cache'): - # user_obj._perm_cache = self.get_object_permissions(user_obj) - # - # return user_obj._perm_cache - def has_perm(self, user_obj, perm, obj=None): # print(f'has_perm({perm})') app_label, codename = perm.split('.') @@ -92,7 +80,7 @@ def has_perm(self, user_obj, perm, obj=None): obj_perm_attrs = self.get_object_permissions(user_obj)[perm] attrs = Q() for perm_attrs in obj_perm_attrs: - attrs |= Q(**perm_attrs.attrs) + attrs |= Q(**perm_attrs) # Permission to perform the requested action on the object depends on whether the specified object matches # the specified attributes. Note that this check is made against the *database* record representing the object, From ce46512c74f6e27cd73213bcd85310c5e437d390 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 27 May 2020 16:53:30 -0400 Subject: [PATCH 051/101] Fix permission assignment in tests --- netbox/extras/tests/test_customfields.py | 18 ++++-------- netbox/netbox/settings.py | 2 +- netbox/utilities/auth_backends.py | 8 ++++-- netbox/utilities/testing/testcases.py | 35 ++++++++++++++++++------ 4 files changed, 38 insertions(+), 25 deletions(-) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index c94d8cd3f1..4df06e12f2 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -1,7 +1,6 @@ from datetime import date from django.contrib.contenttypes.models import ContentType -from django.test import Client, TestCase from django.urls import reverse from rest_framework import status @@ -9,7 +8,7 @@ from dcim.models import Site from extras.choices import * from extras.models import CustomField, CustomFieldValue, CustomFieldChoice -from utilities.testing import APITestCase, create_test_user +from utilities.testing import APITestCase, TestCase from virtualization.models import VirtualMachine @@ -470,17 +469,10 @@ def test_list_cfc(self): class CustomFieldImportTest(TestCase): - - def setUp(self): - - user = create_test_user( - permissions=[ - 'dcim.view_site', - 'dcim.add_site', - ] - ) - self.client = Client() - self.client.force_login(user) + user_permissions = ( + 'dcim.view_site', + 'dcim.add_site', + ) @classmethod def setUpTestData(cls): diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 266f1afd74..3b345638b1 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -338,8 +338,8 @@ def _setting(name, default=None): # Set up authentication backends AUTHENTICATION_BACKENDS = [ - 'utilities.auth_backends.ObjectPermissionBackend', REMOTE_AUTH_BACKEND, + 'utilities.auth_backends.ObjectPermissionBackend', ] # Internationalization diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py index bcf2fa119b..41d7033afe 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -1,7 +1,7 @@ import logging from django.conf import settings -from django.contrib.auth.backends import BaseBackend, ModelBackend +from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _RemoteUserBackend from django.contrib.auth.models import Group, Permission from django.db.models import Q @@ -88,7 +88,7 @@ def has_perm(self, user_obj, perm, obj=None): return model.objects.filter(attrs, pk=obj.pk).exists() -class RemoteUserBackend(BaseBackend): +class RemoteUserBackend(_RemoteUserBackend): """ Custom implementation of Django's RemoteUserBackend which provides configuration hooks for basic customization. """ @@ -124,7 +124,11 @@ def configure_user(self, request, user): "._. (Example: dcim.add_site)" ) if permissions_list: + # TODO: Create an ObjectPermission user.user_permissions.add(*permissions_list) logger.debug(f"Assigned permissions to remotely-authenticated user {user}: {permissions_list}") return user + + def has_perm(self, user_obj, perm, obj=None): + return False diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py index 3d0ad1ef3c..8346f5d048 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -33,18 +33,31 @@ def add_permissions(self, *names): Assign a set of permissions to the test user. Accepts permission names in the form ._. """ for name in names: - app, codename = name.split('.') - perm = Permission.objects.get(content_type__app_label=app, codename=codename) - self.user.user_permissions.add(perm) + app_label, codename = name.split('.') + action, model_name = codename.split('_') + + kwargs = { + 'model': ContentType.objects.get(app_label=app_label, model=model_name), + f'can_{action}': True + } + obj_perm = ObjectPermission(**kwargs) + obj_perm.save() + obj_perm.users.add(self.user) def remove_permissions(self, *names): """ Remove a set of permissions from the test user, if assigned. """ for name in names: - app, codename = name.split('.') - perm = Permission.objects.get(content_type__app_label=app, codename=codename) - self.user.user_permissions.remove(perm) + app_label, codename = name.split('.') + action, model_name = codename.split('_') + + kwargs = { + 'user': self.user, + 'model': ContentType.objects.get(app_label=app_label, model=model_name), + f'can_{action}': True + } + ObjectPermission.objects.filter(**kwargs).delete() # # Convenience methods @@ -493,10 +506,14 @@ def test_bulk_create_objects(self): with disable_warnings('django.request'): self.assertHttpStatus(self.client.post(**request), 403) - # Assign the required permission and submit again - self.add_permissions( - '{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name) + # Assign object-level permission + obj_perm = ObjectPermission( + model=ContentType.objects.get_for_model(self.model), + can_add=True ) + obj_perm.save() + obj_perm.users.add(self.user) + response = self.client.post(**request) self.assertHttpStatus(response, 302) From a261d10bfd0440a68b68f811eebddc07641f6d1b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 27 May 2020 17:10:45 -0400 Subject: [PATCH 052/101] Fix permissions assignment for SecretTest --- netbox/secrets/tests/test_api.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/netbox/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py index 339c370d81..c21ac9d729 100644 --- a/netbox/secrets/tests/test_api.py +++ b/netbox/secrets/tests/test_api.py @@ -122,18 +122,15 @@ def test_delete_secretrole(self): class SecretTest(APITestCase): + user_permissions = ( + 'secrets.add_secret', + 'secrets.change_secret', + 'secrets.delete_secret', + 'secrets.view_secret', + ) def setUp(self): - - # Create a non-superuser test user - self.user = create_test_user('testuser', permissions=( - 'secrets.add_secret', - 'secrets.change_secret', - 'secrets.delete_secret', - 'secrets.view_secret', - )) - self.token = Token.objects.create(user=self.user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)} + super().setUp() userkey = UserKey(user=self.user, public_key=PUBLIC_KEY) userkey.save() From 814aff78b580a14c13df4e2ee58df3c7e7495576 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 May 2020 09:39:27 -0400 Subject: [PATCH 053/101] Update ObjectPermission evaluation to support null attrs --- netbox/utilities/api.py | 15 ++++++++------- netbox/utilities/auth_backends.py | 7 ++++++- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 2d7ae2385e..41002dd205 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -6,7 +6,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist, PermissionDenied from django.db import transaction -from django.db.models import ManyToManyField, ProtectedError +from django.db.models import ManyToManyField, ProtectedError, Q from django.urls import reverse from rest_framework.exceptions import APIException from rest_framework.permissions import BasePermission @@ -339,12 +339,13 @@ def initial(self, request, *args, **kwargs): } permission_required = TokenPermissions.perms_map[request.method][0] % kwargs - # Enforce object-level permissions - attrs = ObjectPermission.objects.get_attr_constraints(request.user, permission_required) - if attrs: - # Update the view's QuerySet to filter only the permitted objects - self.queryset = self.queryset.filter(attrs) - return True + # Update the view's QuerySet to filter only the permitted objects + obj_perm_attrs = request.user._object_perm_cache[permission_required] + attrs = Q() + for perm_attrs in obj_perm_attrs: + if perm_attrs: + attrs |= Q(**perm_attrs) + self.queryset = self.queryset.filter(attrs) def dispatch(self, request, *args, **kwargs): logger = logging.getLogger('netbox.api.views.ModelViewSet') diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py index 41d7033afe..6d34678bec 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -80,7 +80,12 @@ def has_perm(self, user_obj, perm, obj=None): obj_perm_attrs = self.get_object_permissions(user_obj)[perm] attrs = Q() for perm_attrs in obj_perm_attrs: - attrs |= Q(**perm_attrs) + if perm_attrs: + attrs |= Q(**perm_attrs) + else: + # Found ObjectPermission with null attrs; allow model-level access + attrs = Q() + break # Permission to perform the requested action on the object depends on whether the specified object matches # the specified attributes. Note that this check is made against the *database* record representing the object, From 00ce3588d3f9e8b74e2564879256b5952ebfdcec Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 May 2020 09:51:02 -0400 Subject: [PATCH 054/101] Fix secrets API tests --- netbox/secrets/models.py | 1 - netbox/secrets/tests/test_api.py | 42 ++++++++++++++++++++------------ 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 830e910961..61d8adb6ba 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -1,5 +1,4 @@ import os -import sys from Crypto.Cipher import AES from Crypto.PublicKey import RSA diff --git a/netbox/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py index c21ac9d729..8d716a4657 100644 --- a/netbox/secrets/tests/test_api.py +++ b/netbox/secrets/tests/test_api.py @@ -5,8 +5,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from secrets.models import Secret, SecretRole, SessionKey, UserKey -from users.models import Token -from utilities.testing import APITestCase, create_test_user +from utilities.testing import APITestCase from .constants import PRIVATE_KEY, PUBLIC_KEY @@ -122,16 +121,19 @@ def test_delete_secretrole(self): class SecretTest(APITestCase): - user_permissions = ( - 'secrets.add_secret', - 'secrets.change_secret', - 'secrets.delete_secret', - 'secrets.view_secret', - ) def setUp(self): super().setUp() + self.user.is_superuser = False + self.user.save() + self.add_permissions( + 'secrets.add_secret', + 'secrets.change_secret', + 'secrets.delete_secret', + 'secrets.view_secret', + ) + userkey = UserKey(user=self.user, public_key=PUBLIC_KEY) userkey.save() self.master_key = userkey.get_master_key(PRIVATE_KEY) @@ -175,24 +177,25 @@ def setUp(self): self.secret3.save() def test_get_secret(self): - url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk}) - # Secret plaintext not be decrypted as the user has not been assigned to the role + # Secret plaintext should not be decrypted as the user has not been assigned to the role response = self.client.get(url, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertIsNone(response.data['plaintext']) # The plaintext should be present once the user has been assigned to the role self.secretrole1.users.add(self.user) response = self.client.get(url, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(response.data['plaintext'], self.plaintexts[0]) def test_list_secrets(self): - url = reverse('secrets-api:secret-list') - # Secret plaintext not be decrypted as the user has not been assigned to the role + # Secret plaintext should not be decrypted as the user has not been assigned to the role response = self.client.get(url, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(response.data['count'], 3) for secret in response.data['results']: self.assertIsNone(secret['plaintext']) @@ -200,12 +203,12 @@ def test_list_secrets(self): # The plaintext should be present once the user has been assigned to the role self.secretrole1.users.add(self.user) response = self.client.get(url, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(response.data['count'], 3) for i, secret in enumerate(response.data['results']): self.assertEqual(secret['plaintext'], self.plaintexts[i]) def test_create_secret(self): - data = { 'device': self.device.pk, 'role': self.secretrole1.pk, @@ -213,6 +216,9 @@ def test_create_secret(self): 'plaintext': 'Secret #4 Plaintext', } + # Assign test user to secret role + self.secretrole1.users.add(self.user) + url = reverse('secrets-api:secret-list') response = self.client.post(url, data, format='json', **self.header) @@ -225,7 +231,6 @@ def test_create_secret(self): self.assertEqual(secret4.plaintext, data['plaintext']) def test_create_secret_bulk(self): - data = [ { 'device': self.device.pk, @@ -247,6 +252,9 @@ def test_create_secret_bulk(self): }, ] + # Assign test user to secret role + self.secretrole1.users.add(self.user) + url = reverse('secrets-api:secret-list') response = self.client.post(url, data, format='json', **self.header) @@ -257,13 +265,15 @@ def test_create_secret_bulk(self): self.assertEqual(response.data[2]['plaintext'], data[2]['plaintext']) def test_update_secret(self): - data = { 'device': self.device.pk, 'role': self.secretrole2.pk, 'plaintext': 'NewPlaintext', } + # Assign test user to secret role + self.secretrole1.users.add(self.user) + url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk}) response = self.client.put(url, data, format='json', **self.header) @@ -276,6 +286,8 @@ def test_update_secret(self): self.assertEqual(secret1.plaintext, data['plaintext']) def test_delete_secret(self): + # Assign test user to secret role + self.secretrole1.users.add(self.user) url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk}) response = self.client.delete(url, **self.header) From b2ba9d68c9b82e7dd0869a5641e76c251be69ded Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 May 2020 10:04:19 -0400 Subject: [PATCH 055/101] Fix default permissions assignment under RemoteUserBackend --- netbox/utilities/auth_backends.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py index 6d34678bec..99e4f559a8 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -2,7 +2,8 @@ from django.conf import settings from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _RemoteUserBackend -from django.contrib.auth.models import Group, Permission +from django.contrib.auth.models import Group +from django.contrib.contenttypes.models import ContentType from django.db.models import Q from users.models import ObjectPermission @@ -115,22 +116,27 @@ def configure_user(self, request, user): user.groups.add(*group_list) logger.debug(f"Assigned groups to remotely-authenticated user {user}: {group_list}") - # Assign default permissions to the user + # Assign default object permissions to the user permissions_list = [] for permission_name in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS: try: app_label, codename = permission_name.split('.') - permissions_list.append( - Permission.objects.get(content_type__app_label=app_label, codename=codename) - ) - except (ValueError, Permission.DoesNotExist): + action, model_name = codename.split('_') + + kwargs = { + 'model': ContentType.objects.get(app_label=app_label, model=model_name), + f'can_{action}': True + } + obj_perm = ObjectPermission(**kwargs) + obj_perm.save() + obj_perm.users.add(user) + permissions_list.append(permission_name) + except ValueError: logging.error( "Invalid permission name: '{permission_name}'. Permissions must be in the form " "._. (Example: dcim.add_site)" ) if permissions_list: - # TODO: Create an ObjectPermission - user.user_permissions.add(*permissions_list) logger.debug(f"Assigned permissions to remotely-authenticated user {user}: {permissions_list}") return user From ca199cdefe9acd8ecfb7b266ccf45566fef6ea84 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 May 2020 10:27:25 -0400 Subject: [PATCH 056/101] Reduce ObjectPermission creation boilerplate --- netbox/netbox/tests/test_authentication.py | 60 ++++++---------------- netbox/utilities/auth_backends.py | 8 +-- netbox/utilities/testing/testcases.py | 16 ++---- 3 files changed, 22 insertions(+), 62 deletions(-) diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 39e82df619..74f4c411af 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -201,13 +201,11 @@ def test_get_object(self): self.assertHttpStatus(response, 403) # Assign object permission - obj_perm = ObjectPermission( + self.user.object_permissions.create( model=ContentType.objects.get_for_model(Prefix), attrs={'site__name': 'Site 1'}, can_view=True ) - obj_perm.save() - obj_perm.users.add(self.user) # Retrieve permitted object response = self.client.get(self.prefixes[0].get_absolute_url()) @@ -225,13 +223,11 @@ def test_list_objects(self): self.assertHttpStatus(response, 403) # Assign object permission - obj_perm = ObjectPermission( + self.user.object_permissions.create( model=ContentType.objects.get_for_model(Prefix), attrs={'site__name': 'Site 1'}, can_view=True ) - obj_perm.save() - obj_perm.users.add(self.user) # Retrieve all objects. Only permitted objects should be returned. response = self.client.get(reverse('ipam:prefix_list')) @@ -259,14 +255,12 @@ def test_create_object(self): self.assertEqual(initial_count, Prefix.objects.count()) # Assign object permission - obj_perm = ObjectPermission( + self.user.object_permissions.create( model=ContentType.objects.get_for_model(Prefix), attrs={'site__name': 'Site 1'}, can_view=True, can_add=True ) - obj_perm.save() - obj_perm.users.add(self.user) # Attempt to create a non-permitted object request = { @@ -307,14 +301,12 @@ def test_edit_object(self): self.assertHttpStatus(response, 403) # Assign object permission - obj_perm = ObjectPermission( + self.user.object_permissions.create( model=ContentType.objects.get_for_model(Prefix), attrs={'site__name': 'Site 1'}, can_view=True, can_change=True ) - obj_perm.save() - obj_perm.users.add(self.user) # Attempt to edit a non-permitted object request = { @@ -351,14 +343,12 @@ def test_delete_object(self): self.assertHttpStatus(response, 403) # Assign object permission - obj_perm = ObjectPermission( + self.user.object_permissions.create( model=ContentType.objects.get_for_model(Prefix), attrs={'site__name': 'Site 1'}, can_view=True, can_delete=True ) - obj_perm.save() - obj_perm.users.add(self.user) # Delete permitted object request = { @@ -400,13 +390,11 @@ def test_bulk_import_objects(self): self.assertEqual(initial_count, Prefix.objects.count()) # Assign object permission - obj_perm = ObjectPermission( + self.user.object_permissions.create( model=ContentType.objects.get_for_model(Prefix), attrs={'site__name': 'Site 1'}, can_add=True ) - obj_perm.save() - obj_perm.users.add(self.user) # Attempt to create non-permitted objects request = { @@ -449,13 +437,11 @@ def test_bulk_edit_objects(self): self.assertHttpStatus(response, 403) # Assign object permission - obj_perm = ObjectPermission( + self.user.object_permissions.create( model=ContentType.objects.get_for_model(Prefix), attrs={'site__name': 'Site 1'}, can_change=True ) - obj_perm.save() - obj_perm.users.add(self.user) # Attempt to edit non-permitted objects request = { @@ -493,14 +479,12 @@ def test_bulk_delete_objects(self): self.assertHttpStatus(response, 403) # Assign object permission - obj_perm = ObjectPermission( + self.user.object_permissions.create( model=ContentType.objects.get_for_model(Prefix), attrs={'site__name': 'Site 1'}, can_view=True, can_delete=True ) - obj_perm.save() - obj_perm.users.add(self.user) # Attempt to delete non-permitted object request = { @@ -565,15 +549,11 @@ def test_get_object(self): self.assertEqual(response.status_code, 403) # Assign object permission - obj_perm = ObjectPermission( + self.user.object_permissions.create( model=ContentType.objects.get_for_model(Prefix), - attrs={ - 'site__name': 'Site 1', - }, + attrs={'site__name': 'Site 1'}, can_view=True ) - obj_perm.save() - obj_perm.users.add(self.user) # Retrieve permitted object url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk}) @@ -594,15 +574,11 @@ def test_list_objects(self): self.assertEqual(response.status_code, 403) # Assign object permission - obj_perm = ObjectPermission( + self.user.object_permissions.create( model=ContentType.objects.get_for_model(Prefix), - attrs={ - 'site__name': 'Site 1', - }, + attrs={'site__name': 'Site 1'}, can_view=True ) - obj_perm.save() - obj_perm.users.add(self.user) # Retrieve all objects. Only permitted objects should be returned. response = self.client.get(url, **self.header) @@ -623,13 +599,11 @@ def test_create_object(self): self.assertEqual(response.status_code, 403) # Assign object permission - obj_perm = ObjectPermission( + self.user.object_permissions.create( model=ContentType.objects.get_for_model(Prefix), attrs={'site__name': 'Site 1'}, can_add=True ) - obj_perm.save() - obj_perm.users.add(self.user) # Attempt to create a non-permitted object response = self.client.post(url, data, format='json', **self.header) @@ -652,13 +626,11 @@ def test_edit_object(self): self.assertEqual(response.status_code, 403) # Assign object permission - obj_perm = ObjectPermission( + self.user.object_permissions.create( model=ContentType.objects.get_for_model(Prefix), attrs={'site__name': 'Site 1'}, can_change=True ) - obj_perm.save() - obj_perm.users.add(self.user) # Attempt to edit a non-permitted object data = {'site': self.sites[0].pk} @@ -687,13 +659,11 @@ def test_delete_object(self): self.assertEqual(response.status_code, 403) # Assign object permission - obj_perm = ObjectPermission( + self.user.object_permissions.create( model=ContentType.objects.get_for_model(Prefix), attrs={'site__name': 'Site 1'}, can_delete=True ) - obj_perm.save() - obj_perm.users.add(self.user) # Attempt to delete a non-permitted object url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[3].pk}) diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py index 99e4f559a8..bb705a6dfb 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -122,14 +122,10 @@ def configure_user(self, request, user): try: app_label, codename = permission_name.split('.') action, model_name = codename.split('_') - - kwargs = { + user.object_permissions.create(**{ 'model': ContentType.objects.get(app_label=app_label, model=model_name), f'can_{action}': True - } - obj_perm = ObjectPermission(**kwargs) - obj_perm.save() - obj_perm.users.add(user) + }) permissions_list.append(permission_name) except ValueError: logging.error( diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py index 8346f5d048..86f4653640 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -1,5 +1,5 @@ from django.contrib.contenttypes.models import ContentType -from django.contrib.auth.models import Permission, User +from django.contrib.auth.models import User from django.core.exceptions import ObjectDoesNotExist from django.forms.models import model_to_dict from django.test import Client, TestCase as _TestCase, override_settings @@ -7,7 +7,6 @@ from rest_framework.test import APIClient from users.models import ObjectPermission, Token -from utilities.permissions import get_permission_for_model from .utils import disable_warnings, post_data @@ -36,13 +35,10 @@ def add_permissions(self, *names): app_label, codename = name.split('.') action, model_name = codename.split('_') - kwargs = { + self.user.object_permissions.create(**{ 'model': ContentType.objects.get(app_label=app_label, model=model_name), f'can_{action}': True - } - obj_perm = ObjectPermission(**kwargs) - obj_perm.save() - obj_perm.users.add(self.user) + }) def remove_permissions(self, *names): """ @@ -52,12 +48,10 @@ def remove_permissions(self, *names): app_label, codename = name.split('.') action, model_name = codename.split('_') - kwargs = { - 'user': self.user, + self.user.object_permissions.filter(**{ 'model': ContentType.objects.get(app_label=app_label, model=model_name), f'can_{action}': True - } - ObjectPermission.objects.filter(**kwargs).delete() + }).delete() # # Convenience methods From dc56e49410c00821260cc5870dddd258dd4cd65c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 May 2020 10:35:59 -0400 Subject: [PATCH 057/101] Introduce resolve_permission() utility function --- netbox/users/models.py | 5 ++--- netbox/utilities/auth_backends.py | 7 +++---- netbox/utilities/permissions.py | 20 ++++++++++++++++++++ netbox/utilities/testing/testcases.py | 13 +++++-------- 4 files changed, 30 insertions(+), 15 deletions(-) diff --git a/netbox/users/models.py b/netbox/users/models.py index b9ab6cbb59..17c5a3a658 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -12,6 +12,7 @@ from django.dispatch import receiver from django.utils import timezone +from utilities.permissions import resolve_permission from utilities.utils import flatten_dict @@ -202,11 +203,9 @@ def get_attr_constraints(self, user, perm): 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. """ - app_label, codename = perm.split('.') - action, model_name = codename.split('_') + content_type, action = resolve_permission(perm) assert action in ['view', 'add', 'change', 'delete'], f"Invalid action: {action}" - content_type = ContentType.objects.get(app_label=app_label, model=model_name) qs = self.get_queryset().filter( Q(users=user) | Q(groups__user=user), model=content_type, diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py index bb705a6dfb..a490115bbe 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -7,6 +7,7 @@ from django.db.models import Q from users.models import ObjectPermission +from utilities.permissions import resolve_permission class ObjectPermissionBackend(ModelBackend): @@ -40,7 +41,6 @@ def get_object_permissions(self, user_obj): return user_obj._object_perm_cache def has_perm(self, user_obj, perm, obj=None): - # print(f'has_perm({perm})') app_label, codename = perm.split('.') action, model_name = codename.split('_') @@ -120,10 +120,9 @@ def configure_user(self, request, user): permissions_list = [] for permission_name in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS: try: - app_label, codename = permission_name.split('.') - action, model_name = codename.split('_') + content_type, action = resolve_permission(permission_name) user.object_permissions.create(**{ - 'model': ContentType.objects.get(app_label=app_label, model=model_name), + 'model': content_type, f'can_{action}': True }) permissions_list.append(permission_name) diff --git a/netbox/utilities/permissions.py b/netbox/utilities/permissions.py index 516d6fe5b2..80d564db4f 100644 --- a/netbox/utilities/permissions.py +++ b/netbox/utilities/permissions.py @@ -1,3 +1,6 @@ +from django.contrib.contenttypes.models import ContentType + + def get_permission_for_model(model, action): """ Resolve the named permission for a given model (or instance) and action (e.g. view or add). @@ -13,3 +16,20 @@ def get_permission_for_model(model, action): action, model._meta.model_name ) + + +def resolve_permission(name): + """ + Given a permission name, return the relevant ContentType and action. For example, "dcim.view_site" returns + (Site, "view"). + + :param name: Permission name in the format ._ + """ + app_label, codename = name.split('.') + action, model_name = codename.split('_') + try: + content_type = ContentType.objects.get(app_label=app_label, model=model_name) + except ContentType.DoesNotExist: + raise ValueError(f"Unknown app/model for {name}") + + return content_type, action diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py index 86f4653640..a505e6e03f 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -7,6 +7,7 @@ from rest_framework.test import APIClient from users.models import ObjectPermission, Token +from utilities.permissions import resolve_permission from .utils import disable_warnings, post_data @@ -32,11 +33,9 @@ def add_permissions(self, *names): Assign a set of permissions to the test user. Accepts permission names in the form ._. """ for name in names: - app_label, codename = name.split('.') - action, model_name = codename.split('_') - + ct, action = resolve_permission(name) self.user.object_permissions.create(**{ - 'model': ContentType.objects.get(app_label=app_label, model=model_name), + 'model': ct, f'can_{action}': True }) @@ -45,11 +44,9 @@ def remove_permissions(self, *names): Remove a set of permissions from the test user, if assigned. """ for name in names: - app_label, codename = name.split('.') - action, model_name = codename.split('_') - + ct, action = resolve_permission(name) self.user.object_permissions.filter(**{ - 'model': ContentType.objects.get(app_label=app_label, model=model_name), + 'model': ct, f'can_{action}': True }).delete() From 5d36d81ae1fbd466a0e4f5331defb5562176a8f6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 May 2020 11:08:35 -0400 Subject: [PATCH 058/101] Restore model-level permission tests --- netbox/users/models.py | 1 + netbox/utilities/testing/testcases.py | 306 ++++++++++++---------- netbox/virtualization/tests/test_views.py | 1 + 3 files changed, 166 insertions(+), 142 deletions(-) diff --git a/netbox/users/models.py b/netbox/users/models.py index 17c5a3a658..721ca2f265 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -17,6 +17,7 @@ __all__ = ( + 'ObjectPermission', 'Token', 'UserConfig', ) diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py index a505e6e03f..e665b22771 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -155,6 +155,13 @@ class GetObjectViewTestCase(ModelViewTestCase): """ Retrieve a single instance. """ + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_get_object_anonymous(self): + # Make the request as an unauthenticated user + self.client.logout() + response = self.client.get(self.model.objects.first().get_absolute_url()) + self.assertHttpStatus(response, 200) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_get_object_without_permission(self): instance = self.model.objects.first() @@ -163,28 +170,29 @@ def test_get_object_without_permission(self): with disable_warnings('django.request'): self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 403) - # @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - # def test_get_object_with_model_permission(self): - # instance = self.model.objects.first() - # - # # Add model-level permission - # self.add_permissions(get_permission_for_model(self.model, 'view')) - # - # # Try GET with model-level permission - # self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 200) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_get_object_with_model_permission(self): + instance = self.model.objects.first() + + # Add model-level permission + self.user.object_permissions.create( + model=ContentType.objects.get_for_model(self.model), + can_view=True + ) + + # Try GET with model-level permission + self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 200) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_get_object_with_object_permission(self): instance1, instance2 = self.model.objects.all()[:2] # Add object-level permission - obj_perm = ObjectPermission( + self.user.object_permissions.create( model=ContentType.objects.get_for_model(self.model), attrs={'pk': instance1.pk}, can_view=True ) - obj_perm.save() - obj_perm.users.add(self.user) # Try GET to permitted object self.assertHttpStatus(self.client.get(instance1.get_absolute_url()), 200) @@ -192,13 +200,6 @@ def test_get_object_with_object_permission(self): # Try GET to non-permitted object self.assertHttpStatus(self.client.get(instance2.get_absolute_url()), 404) - @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) - def test_list_objects_anonymous(self): - # Make the request as an unauthenticated user - self.client.logout() - response = self.client.get(self.model.objects.first().get_absolute_url()) - self.assertHttpStatus(response, 200) - class CreateObjectViewTestCase(ModelViewTestCase): """ Create a single new instance. @@ -221,24 +222,27 @@ def test_create_object_without_permission(self): with disable_warnings('django.request'): self.assertHttpStatus(response, 403) - # @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - # def test_create_object_with_model_permission(self): - # initial_count = self.model.objects.count() - # - # # Assign model-level permission - # self.add_permissions(get_permission_for_model(self.model, 'add')) - # - # # Try GET with model-level permission - # self.assertHttpStatus(self.client.get(self._get_url('add')), 200) - # - # # Try POST with model-level permission - # request = { - # 'path': self._get_url('add'), - # 'data': post_data(self.form_data), - # } - # self.assertHttpStatus(self.client.post(**request), 302) - # self.assertEqual(initial_count + 1, self.model.objects.count()) - # self.assertInstanceEqual(self.model.objects.order_by('pk').last(), self.form_data) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_create_object_with_model_permission(self): + initial_count = self.model.objects.count() + + # Assign model-level permission + self.user.object_permissions.create( + model=ContentType.objects.get_for_model(self.model), + can_add=True + ) + + # Try GET with model-level permission + self.assertHttpStatus(self.client.get(self._get_url('add')), 200) + + # Try POST with model-level permission + request = { + 'path': self._get_url('add'), + 'data': post_data(self.form_data), + } + self.assertHttpStatus(self.client.post(**request), 302) + self.assertEqual(initial_count + 1, self.model.objects.count()) + self.assertInstanceEqual(self.model.objects.order_by('pk').last(), self.form_data) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_create_object_with_object_permission(self): @@ -300,23 +304,26 @@ def test_edit_object_without_permission(self): with disable_warnings('django.request'): self.assertHttpStatus(self.client.post(**request), 403) - # @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - # def test_edit_object_with_model_permission(self): - # instance = self.model.objects.first() - # - # # Assign model-level permission - # self.add_permissions(get_permission_for_model(self.model, 'change')) - # - # # Try GET with model-level permission - # self.assertHttpStatus(self.client.get(self._get_url('edit', instance)), 200) - # - # # Try POST with model-level permission - # request = { - # 'path': self._get_url('edit', instance), - # 'data': post_data(self.form_data), - # } - # self.assertHttpStatus(self.client.post(**request), 302) - # self.assertInstanceEqual(self.model.objects.get(pk=instance.pk), self.form_data) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_edit_object_with_model_permission(self): + instance = self.model.objects.first() + + # Assign model-level permission + self.user.object_permissions.create( + model=ContentType.objects.get_for_model(self.model), + can_change=True + ) + + # Try GET with model-level permission + self.assertHttpStatus(self.client.get(self._get_url('edit', instance)), 200) + + # Try POST with model-level permission + request = { + 'path': self._get_url('edit', instance), + 'data': post_data(self.form_data), + } + self.assertHttpStatus(self.client.post(**request), 302) + self.assertInstanceEqual(self.model.objects.get(pk=instance.pk), self.form_data) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_edit_object_with_object_permission(self): @@ -372,24 +379,27 @@ def test_delete_object_without_permission(self): with disable_warnings('django.request'): self.assertHttpStatus(self.client.post(**request), 403) - # @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - # def test_delete_object_with_model_permission(self): - # instance = self.model.objects.first() - # - # # Assign model-level permission - # self.add_permissions(get_permission_for_model(self.model, 'delete')) - # - # # Try GET with model-level permission - # self.assertHttpStatus(self.client.get(self._get_url('delete', instance)), 200) - # - # # Try POST with model-level permission - # request = { - # 'path': self._get_url('delete', instance), - # 'data': post_data({'confirm': True}), - # } - # self.assertHttpStatus(self.client.post(**request), 302) - # with self.assertRaises(ObjectDoesNotExist): - # self.model.objects.get(pk=instance.pk) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_delete_object_with_model_permission(self): + instance = self.model.objects.first() + + # Assign model-level permission + self.user.object_permissions.create( + model=ContentType.objects.get_for_model(self.model), + can_delete=True + ) + + # Try GET with model-level permission + self.assertHttpStatus(self.client.get(self._get_url('delete', instance)), 200) + + # Try POST with model-level permission + request = { + 'path': self._get_url('delete', instance), + 'data': post_data({'confirm': True}), + } + self.assertHttpStatus(self.client.post(**request), 302) + with self.assertRaises(ObjectDoesNotExist): + self.model.objects.get(pk=instance.pk) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_delete_object_with_object_permission(self): @@ -431,6 +441,13 @@ class ListObjectsViewTestCase(ModelViewTestCase): """ Retrieve multiple instances. """ + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_list_objects_anonymous(self): + # Make the request as an unauthenticated user + self.client.logout() + response = self.client.get(self._get_url('list')) + self.assertHttpStatus(response, 200) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_list_objects_without_permission(self): @@ -438,20 +455,23 @@ def test_list_objects_without_permission(self): with disable_warnings('django.request'): self.assertHttpStatus(self.client.get(self._get_url('list')), 403) - # @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - # def test_list_objects_with_model_permission(self): - # - # # Add model-level permission - # self.add_permissions(get_permission_for_model(self.model, 'view')) - # - # # Try GET with model-level permission - # self.assertHttpStatus(self.client.get(self._get_url('list')), 200) - # - # # Built-in CSV export - # if hasattr(self.model, 'csv_headers'): - # response = self.client.get('{}?export'.format(self._get_url('list'))) - # self.assertHttpStatus(response, 200) - # self.assertEqual(response.get('Content-Type'), 'text/csv') + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_list_objects_with_model_permission(self): + + # Add model-level permission + self.user.object_permissions.create( + model=ContentType.objects.get_for_model(self.model), + can_view=True + ) + + # Try GET with model-level permission + self.assertHttpStatus(self.client.get(self._get_url('list')), 200) + + # Built-in CSV export + if hasattr(self.model, 'csv_headers'): + response = self.client.get('{}?export'.format(self._get_url('list'))) + self.assertHttpStatus(response, 200) + self.assertEqual(response.get('Content-Type'), 'text/csv') @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_list_objects_with_object_permission(self): @@ -471,13 +491,6 @@ def test_list_objects_with_object_permission(self): # TODO: Verify that only the permitted object is returned - @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) - def test_list_objects_anonymous(self): - # Make the request as an unauthenticated user - self.client.logout() - response = self.client.get(self._get_url('list')) - self.assertHttpStatus(response, 200) - class BulkCreateObjectsViewTestCase(ModelViewTestCase): """ Create multiple instances using a single form. Expects the creation of three new instances by default. @@ -536,22 +549,25 @@ def test_bulk_import_objects_without_permission(self): with disable_warnings('django.request'): self.assertHttpStatus(response, 403) - # @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - # def test_bulk_import_objects_with_model_permission(self): - # initial_count = self.model.objects.count() - # data = { - # 'csv': self._get_csv_data(), - # } - # - # # Assign model-level permission - # self.add_permissions(get_permission_for_model(self.model, 'add')) - # - # # Try GET with model-level permission - # self.assertHttpStatus(self.client.get(self._get_url('import')), 200) - # - # # Test POST with permission - # self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) - # self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_bulk_import_objects_with_model_permission(self): + initial_count = self.model.objects.count() + data = { + 'csv': self._get_csv_data(), + } + + # Assign model-level permission + self.user.object_permissions.create( + model=ContentType.objects.get_for_model(self.model), + can_add=True + ) + + # Try GET with model-level permission + self.assertHttpStatus(self.client.get(self._get_url('import')), 200) + + # Test POST with permission + self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) + self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_bulk_import_objects_with_object_permission(self): @@ -597,24 +613,27 @@ def test_bulk_edit_objects_without_permission(self): with disable_warnings('django.request'): self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 403) - # @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - # def test_bulk_edit_objects_with_model_permission(self): - # pk_list = self.model.objects.values_list('pk', flat=True)[:3] - # data = { - # 'pk': pk_list, - # '_apply': True, # Form button - # } - # - # # Append the form data to the request - # data.update(post_data(self.bulk_edit_data)) - # - # # Assign model-level permission - # self.add_permissions(get_permission_for_model(self.model, 'change')) - # - # # Try POST with model-level permission - # self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302) - # for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)): - # self.assertInstanceEqual(instance, self.bulk_edit_data) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_bulk_edit_objects_with_model_permission(self): + pk_list = self.model.objects.values_list('pk', flat=True)[:3] + data = { + 'pk': pk_list, + '_apply': True, # Form button + } + + # Append the form data to the request + data.update(post_data(self.bulk_edit_data)) + + # Assign model-level permission + self.user.object_permissions.create( + model=ContentType.objects.get_for_model(self.model), + can_change=True + ) + + # Try POST with model-level permission + self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302) + for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)): + self.assertInstanceEqual(instance, self.bulk_edit_data) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_bulk_edit_objects_with_object_permission(self): @@ -664,21 +683,24 @@ def test_bulk_delete_objects_without_permission(self): with disable_warnings('django.request'): self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 403) - # @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - # def test_bulk_delete_objects_with_model_permission(self): - # pk_list = self.model.objects.values_list('pk', flat=True) - # data = { - # 'pk': pk_list, - # 'confirm': True, - # '_confirm': True, # Form button - # } - # - # # Assign model-level permission - # self.add_permissions(get_permission_for_model(self.model, 'delete')) - # - # # Try POST with model-level permission - # self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302) - # self.assertEqual(self.model.objects.count(), 0) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_bulk_delete_objects_with_model_permission(self): + pk_list = self.model.objects.values_list('pk', flat=True) + data = { + 'pk': pk_list, + 'confirm': True, + '_confirm': True, # Form button + } + + # Assign model-level permission + self.user.object_permissions.create( + model=ContentType.objects.get_for_model(self.model), + can_delete=True + ) + + # Try POST with model-level permission + self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302) + self.assertEqual(self.model.objects.count(), 0) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_bulk_delete_objects_with_object_permission(self): diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 006db34d60..0676066488 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -192,6 +192,7 @@ class InterfaceTestCase( model = Interface # Disable inapplicable tests + test_list_objects_anonymous = None test_list_objects_without_permission = None test_list_objects_with_model_permission = None test_list_objects_with_object_permission = None From 486f1a74abdef2561800c994ede207e7e4f96823 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 May 2020 12:05:54 -0400 Subject: [PATCH 059/101] Standardize base classes for view test cases --- netbox/dcim/tests/test_views.py | 62 +++++++++++++---------- netbox/extras/tests/test_views.py | 40 +++++++-------- netbox/ipam/tests/test_views.py | 16 +++--- netbox/secrets/tests/test_views.py | 19 +++---- netbox/virtualization/tests/test_views.py | 16 +++--- 5 files changed, 76 insertions(+), 77 deletions(-) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 6e28700c83..cfbb2b95fe 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -321,7 +321,16 @@ def setUpTestData(cls): ) -class DeviceTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase): +# TODO: Change base class to PrimaryObjectViewTestCase +class DeviceTypeTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.CreateObjectViewTestCase, + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase +): model = DeviceType @classmethod @@ -792,14 +801,15 @@ def setUpTestData(cls): } -class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): +# TODO: Change base class to DeviceComponentTemplateViewTestCase +class DeviceBayTemplateTestCase( + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.BulkCreateObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase +): model = DeviceBayTemplate - # Disable inapplicable views - test_bulk_edit_objects_without_permission = None - test_bulk_edit_objects_with_model_permission = None - test_bulk_edit_objects_with_object_permission = None - @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -1439,14 +1449,18 @@ def setUpTestData(cls): ) -class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase): +# TODO: Change base class to PrimaryObjectViewTestCase +class CableTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkImportObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase +): model = Cable - # TODO: Creation URL needs termination context - test_create_object_without_permission = None - test_create_object_with_model_permission = None - test_create_object_with_object_permission = None - @classmethod def setUpTestData(cls): @@ -1515,22 +1529,16 @@ def setUpTestData(cls): } -class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase): +# TODO: Change base class to PrimaryObjectViewTestCase +class VirtualChassisTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase +): model = VirtualChassis - # Disable inapplicable tests - test_bulk_import_objects_without_permission = None - test_bulk_import_objects_with_model_permission = None - test_bulk_import_objects_with_object_permission = None - - # TODO: Requires special form handling - test_create_object_without_permission = None - test_create_object_with_model_permission = None - test_create_object_with_object_permission = None - test_edit_object_without_permission = None - test_edit_object_with_model_permission = None - test_edit_object_with_object_permission = None - @classmethod def setUpTestData(cls): diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index f52054cc15..6d41886fc5 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -10,17 +10,17 @@ from utilities.testing import ViewTestCases, TestCase -class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase): +# TODO: Change base class to PrimaryObjectViewTestCase +class TagTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase +): model = Tag - # Disable inapplicable tests - test_create_object_without_permission = None - test_create_object_with_model_permission = None - test_create_object_with_object_permission = None - test_bulk_import_objects_without_permission = None - test_bulk_import_objects_with_model_permission = None - test_bulk_import_objects_with_object_permission = None - @classmethod def setUpTestData(cls): @@ -42,22 +42,16 @@ def setUpTestData(cls): } -class ConfigContextTestCase(ViewTestCases.PrimaryObjectViewTestCase): +# TODO: Change base class to PrimaryObjectViewTestCase +class ConfigContextTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase +): model = ConfigContext - # Disable inapplicable tests - test_bulk_import_objects_without_permission = None - test_bulk_import_objects_with_model_permission = None - test_bulk_import_objects_with_object_permission = None - - # TODO: Resolve model discrepancies when creating/editing ConfigContexts - test_create_object_without_permission = None - test_create_object_with_model_permission = None - test_create_object_with_object_permission = None - test_edit_object_without_permission = None - test_edit_object_with_model_permission = None - test_edit_object_with_object_permission = None - @classmethod def setUpTestData(cls): diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index bbd2524731..794284dbab 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -333,14 +333,18 @@ def setUpTestData(cls): } -class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): +# TODO: Update base class to PrimaryObjectViewTestCase +class ServiceTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkImportObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase +): model = Service - # TODO: Resolve URL for Service creation - test_create_object_without_permission = None - test_create_object_with_model_permission = None - test_create_object_with_object_permission = None - @classmethod def setUpTestData(cls): diff --git a/netbox/secrets/tests/test_views.py b/netbox/secrets/tests/test_views.py index 7796be63d1..577ba4ef4e 100644 --- a/netbox/secrets/tests/test_views.py +++ b/netbox/secrets/tests/test_views.py @@ -36,19 +36,16 @@ def setUpTestData(cls): ) -class SecretTestCase(ViewTestCases.PrimaryObjectViewTestCase): +# TODO: Change base class to PrimaryObjectViewTestCase +class SecretTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase +): model = Secret - # Disable inapplicable tests - test_create_object_without_permission = None - test_create_object_with_model_permission = None - test_create_object_with_object_permission = None - - # TODO: Check permissions enforcement on secrets.views.secret_edit - test_edit_object_without_permission = None - test_edit_object_with_model_permission = None - test_edit_object_with_object_permission = None - @classmethod def setUpTestData(cls): diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 0676066488..9fde121865 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -185,21 +185,17 @@ def setUpTestData(cls): } +# TODO: Update base class to DeviceComponentViewTestCase class InterfaceTestCase( ViewTestCases.GetObjectViewTestCase, - ViewTestCases.DeviceComponentViewTestCase, + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.BulkCreateObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase, ): model = Interface - # Disable inapplicable tests - test_list_objects_anonymous = None - test_list_objects_without_permission = None - test_list_objects_with_model_permission = None - test_list_objects_with_object_permission = None - test_bulk_import_objects_without_permission = None - test_bulk_import_objects_with_model_permission = None - test_bulk_import_objects_with_object_permission = None - def _get_base_url(self): # Interface belongs to the DCIM app, so we have to override the base URL return 'virtualization:interface_{}' From 73b7eb0c7fab5d304da88e04e3fb95c0b5819621 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 May 2020 13:25:12 -0400 Subject: [PATCH 060/101] Skip queryset filtering for superusers --- netbox/utilities/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 6e93c23698..e73a55dc75 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -66,7 +66,7 @@ def has_permission(self): return False # Update the view's QuerySet to filter only the permitted objects - if user.is_authenticated: + if user.is_authenticated and not user.is_superuser: obj_perm_attrs = user._object_perm_cache[permission_required] attrs = Q() for perm_attrs in obj_perm_attrs: From a8ed04c4d20b352b713ca191de9d1ba62f20a7af Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 May 2020 13:25:37 -0400 Subject: [PATCH 061/101] Expose assigned ObjectPermissions on User instance --- netbox/utilities/auth_backends.py | 53 ++++++++++++++++--------------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py index a490115bbe..ecb3ea652d 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -3,7 +3,6 @@ from django.conf import settings from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _RemoteUserBackend from django.contrib.auth.models import Group -from django.contrib.contenttypes.models import ContentType from django.db.models import Q from users.models import ObjectPermission @@ -12,33 +11,35 @@ class ObjectPermissionBackend(ModelBackend): + def get_all_permissions(self, user_obj, obj=None): + if not user_obj.is_active or user_obj.is_anonymous: + return set() + if not hasattr(user_obj, '_object_perm_cache'): + user_obj._object_perm_cache = self.get_object_permissions(user_obj) + return user_obj._object_perm_cache + def get_object_permissions(self, user_obj): """ Return all permissions granted to the user by an ObjectPermission. """ - if not hasattr(user_obj, '_object_perm_cache'): - - # Retrieve all assigned ObjectPermissions - object_permissions = ObjectPermission.objects.filter( - Q(users=user_obj) | - Q(groups__user=user_obj) - ).prefetch_related('model') - - # Create a dictionary mapping permissions to their attributes - perms = dict() - for obj_perm in object_permissions: - for action in ['view', 'add', 'change', 'delete']: - if getattr(obj_perm, f"can_{action}"): - perm_name = f"{obj_perm.model.app_label}.{action}_{obj_perm.model.model}" - if perm_name in perms: - perms[perm_name].append(obj_perm.attrs) - else: - perms[perm_name] = [obj_perm.attrs] - - # Cache resolved permissions on the User instance - setattr(user_obj, '_object_perm_cache', perms) - - return user_obj._object_perm_cache + # Retrieve all assigned ObjectPermissions + object_permissions = ObjectPermission.objects.filter( + Q(users=user_obj) | + Q(groups__user=user_obj) + ).prefetch_related('model') + + # Create a dictionary mapping permissions to their attributes + perms = dict() + for obj_perm in object_permissions: + for action in ['view', 'add', 'change', 'delete']: + if getattr(obj_perm, f"can_{action}"): + perm_name = f"{obj_perm.model.app_label}.{action}_{obj_perm.model.model}" + if perm_name in perms: + perms[perm_name].append(obj_perm.attrs) + else: + perms[perm_name] = [obj_perm.attrs] + + return perms def has_perm(self, user_obj, perm, obj=None): app_label, codename = perm.split('.') @@ -64,7 +65,7 @@ def has_perm(self, user_obj, perm, obj=None): return False # If no applicable ObjectPermissions have been created for this user/permission, deny permission - if perm not in self.get_object_permissions(user_obj): + if perm not in self.get_all_permissions(user_obj): return False # If no object has been specified, grant permission. (The presence of a permission in this set tells @@ -78,7 +79,7 @@ def has_perm(self, user_obj, perm, obj=None): raise ValueError(f"Invalid permission {perm} for model {model}") # Compile a query filter that matches all instances of the specified model - obj_perm_attrs = self.get_object_permissions(user_obj)[perm] + obj_perm_attrs = self.get_all_permissions(user_obj)[perm] attrs = Q() for perm_attrs in obj_perm_attrs: if perm_attrs: From f8e29ea66a3415050e988643b9601b2d741cd09b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 May 2020 13:47:52 -0400 Subject: [PATCH 062/101] Remove ObjectPermissionManager --- netbox/users/models.py | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/netbox/users/models.py b/netbox/users/models.py index 721ca2f265..cf2ee3953e 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -197,29 +197,6 @@ def is_expired(self): return True -class ObjectPermissionManager(models.Manager): - - def get_attr_constraints(self, user, perm): - """ - 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. - """ - content_type, action = resolve_permission(perm) - assert action in ['view', 'add', 'change', 'delete'], f"Invalid action: {action}" - - qs = self.get_queryset().filter( - Q(users=user) | Q(groups__user=user), - model=content_type, - **{f'can_{action}': True} - ) - - attrs = Q() - for perm in qs: - attrs |= Q(**perm.attrs) - - return attrs - - 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 @@ -257,8 +234,6 @@ class ObjectPermission(models.Model): default=False ) - objects = ObjectPermissionManager() - class Meta: unique_together = ('model', 'attrs') From 65bd3fbddb5769b07335bb00ffc8e44cfb33cf27 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 May 2020 14:03:08 -0400 Subject: [PATCH 063/101] Remove built-in permission assignment from admin UI --- netbox/users/admin.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/netbox/users/admin.py b/netbox/users/admin.py index fcaeb4ef07..8ea33514a1 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -1,14 +1,26 @@ from django import forms from django.contrib import admin from django.contrib.auth.admin import UserAdmin as UserAdmin_ -from django.contrib.auth.models import User +from django.contrib.auth.models import Group, User from .models import ObjectPermission, Token, UserConfig -# Unregister the built-in UserAdmin so that we can use our custom admin view below +# Unregister the built-in GroupAdmin and UserAdmin classes so that we can use our custom admin classes below +admin.site.unregister(Group) admin.site.unregister(User) +@admin.register(Group) +class GroupAdmin(admin.ModelAdmin): + fields = ('name',) + list_display = ('name', 'user_count') + ordering = ('name',) + search_fields = ('name',) + + def user_count(self, obj): + return obj.user_set.count() + + class UserConfigInline(admin.TabularInline): model = UserConfig readonly_fields = ('data',) @@ -21,6 +33,14 @@ class UserAdmin(UserAdmin_): list_display = [ 'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active' ] + fieldsets = ( + (None, {'fields': ('username', 'password')}), + ('Personal info', {'fields': ('first_name', 'last_name', 'email')}), + ('Permissions', { + 'fields': ('is_active', 'is_staff', 'is_superuser'), + }), + ('Important dates', {'fields': ('last_login', 'date_joined')}), + ) inlines = (UserConfigInline,) From bdfc0364d520e93fd38c51a412d805eff01c3a89 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 May 2020 14:20:18 -0400 Subject: [PATCH 064/101] Fix up ObjectPermission content type assignment --- netbox/users/admin.py | 29 +++++++++++++++++++ .../users/migrations/0007_objectpermission.py | 2 +- netbox/users/models.py | 5 ++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/netbox/users/admin.py b/netbox/users/admin.py index 8ea33514a1..e13904eea4 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -3,8 +3,14 @@ from django.contrib.auth.admin import UserAdmin as UserAdmin_ from django.contrib.auth.models import Group, User +from extras.admin import order_content_types from .models import ObjectPermission, Token, UserConfig + +# +# Users & groups +# + # Unregister the built-in GroupAdmin and UserAdmin classes so that we can use our custom admin classes below admin.site.unregister(Group) admin.site.unregister(User) @@ -44,6 +50,10 @@ class UserAdmin(UserAdmin_): inlines = (UserConfigInline,) +# +# REST API tokens +# + class TokenAdminForm(forms.ModelForm): key = forms.CharField( required=False, @@ -65,8 +75,27 @@ class TokenAdmin(admin.ModelAdmin): ] +# +# Permissions +# + +class ObjectPermissionForm(forms.ModelForm): + + class Meta: + model = ObjectPermission + exclude = [] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Format ContentType choices + order_content_types(self.fields['model']) + self.fields['model'].choices.insert(0, ('', '---------')) + + @admin.register(ObjectPermission) class ObjectPermissionAdmin(admin.ModelAdmin): + form = ObjectPermissionForm list_display = [ 'model', 'can_view', 'can_add', 'can_change', 'can_delete' ] diff --git a/netbox/users/migrations/0007_objectpermission.py b/netbox/users/migrations/0007_objectpermission.py index 1fadcc9a54..da176dd5d8 100644 --- a/netbox/users/migrations/0007_objectpermission.py +++ b/netbox/users/migrations/0007_objectpermission.py @@ -26,7 +26,7 @@ class Migration(migrations.Migration): ('can_change', models.BooleanField(default=False)), ('can_delete', models.BooleanField(default=False)), ('groups', models.ManyToManyField(blank=True, related_name='object_permissions', to='auth.Group')), - ('model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ('model', models.ForeignKey(limit_choices_to={'app_label__in': ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'virtualization']}, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), ('users', models.ManyToManyField(blank=True, related_name='object_permissions', to=settings.AUTH_USER_MODEL)), ], options={ diff --git a/netbox/users/models.py b/netbox/users/models.py index cf2ee3953e..6de7bf01a6 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -214,6 +214,11 @@ class ObjectPermission(models.Model): ) model = models.ForeignKey( to=ContentType, + limit_choices_to={ + 'app_label__in': [ + 'circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'virtualization', + ], + }, on_delete=models.CASCADE ) attrs = JSONField( From f65b2278f0a64ea0bf747d00b532a7aa5cb45812 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 28 May 2020 15:04:46 -0400 Subject: [PATCH 065/101] Enable many-to-many model assignment for ObjectPermissions --- netbox/netbox/tests/test_authentication.py | 78 ++++++++++------ netbox/users/admin.py | 4 +- .../users/migrations/0007_objectpermission.py | 8 +- netbox/users/models.py | 8 +- netbox/utilities/auth_backends.py | 25 ++--- netbox/utilities/testing/testcases.py | 93 ++++++++++--------- 6 files changed, 122 insertions(+), 94 deletions(-) diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 74f4c411af..ad900bdc0c 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -201,11 +201,13 @@ def test_get_object(self): self.assertHttpStatus(response, 403) # Assign object permission - self.user.object_permissions.create( - model=ContentType.objects.get_for_model(Prefix), + obj_perm = ObjectPermission( attrs={'site__name': 'Site 1'}, can_view=True ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix)) # Retrieve permitted object response = self.client.get(self.prefixes[0].get_absolute_url()) @@ -223,11 +225,13 @@ def test_list_objects(self): self.assertHttpStatus(response, 403) # Assign object permission - self.user.object_permissions.create( - model=ContentType.objects.get_for_model(Prefix), + obj_perm = ObjectPermission( attrs={'site__name': 'Site 1'}, can_view=True ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix)) # Retrieve all objects. Only permitted objects should be returned. response = self.client.get(reverse('ipam:prefix_list')) @@ -255,12 +259,14 @@ def test_create_object(self): self.assertEqual(initial_count, Prefix.objects.count()) # Assign object permission - self.user.object_permissions.create( - model=ContentType.objects.get_for_model(Prefix), + obj_perm = ObjectPermission( attrs={'site__name': 'Site 1'}, can_view=True, can_add=True ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix)) # Attempt to create a non-permitted object request = { @@ -301,12 +307,14 @@ def test_edit_object(self): self.assertHttpStatus(response, 403) # Assign object permission - self.user.object_permissions.create( - model=ContentType.objects.get_for_model(Prefix), + obj_perm = ObjectPermission( attrs={'site__name': 'Site 1'}, can_view=True, can_change=True ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix)) # Attempt to edit a non-permitted object request = { @@ -343,12 +351,14 @@ def test_delete_object(self): self.assertHttpStatus(response, 403) # Assign object permission - self.user.object_permissions.create( - model=ContentType.objects.get_for_model(Prefix), + obj_perm = ObjectPermission( attrs={'site__name': 'Site 1'}, can_view=True, can_delete=True ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix)) # Delete permitted object request = { @@ -390,11 +400,13 @@ def test_bulk_import_objects(self): self.assertEqual(initial_count, Prefix.objects.count()) # Assign object permission - self.user.object_permissions.create( - model=ContentType.objects.get_for_model(Prefix), + obj_perm = ObjectPermission( attrs={'site__name': 'Site 1'}, can_add=True ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix)) # Attempt to create non-permitted objects request = { @@ -437,11 +449,13 @@ def test_bulk_edit_objects(self): self.assertHttpStatus(response, 403) # Assign object permission - self.user.object_permissions.create( - model=ContentType.objects.get_for_model(Prefix), + obj_perm = ObjectPermission( attrs={'site__name': 'Site 1'}, can_change=True ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix)) # Attempt to edit non-permitted objects request = { @@ -479,12 +493,14 @@ def test_bulk_delete_objects(self): self.assertHttpStatus(response, 403) # Assign object permission - self.user.object_permissions.create( - model=ContentType.objects.get_for_model(Prefix), + obj_perm = ObjectPermission( attrs={'site__name': 'Site 1'}, can_view=True, can_delete=True ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix)) # Attempt to delete non-permitted object request = { @@ -549,11 +565,13 @@ def test_get_object(self): self.assertEqual(response.status_code, 403) # Assign object permission - self.user.object_permissions.create( - model=ContentType.objects.get_for_model(Prefix), + obj_perm = ObjectPermission( attrs={'site__name': 'Site 1'}, can_view=True ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix)) # Retrieve permitted object url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk}) @@ -574,11 +592,13 @@ def test_list_objects(self): self.assertEqual(response.status_code, 403) # Assign object permission - self.user.object_permissions.create( - model=ContentType.objects.get_for_model(Prefix), + obj_perm = ObjectPermission( attrs={'site__name': 'Site 1'}, can_view=True ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix)) # Retrieve all objects. Only permitted objects should be returned. response = self.client.get(url, **self.header) @@ -599,11 +619,13 @@ def test_create_object(self): self.assertEqual(response.status_code, 403) # Assign object permission - self.user.object_permissions.create( - model=ContentType.objects.get_for_model(Prefix), + obj_perm = ObjectPermission( attrs={'site__name': 'Site 1'}, can_add=True ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix)) # Attempt to create a non-permitted object response = self.client.post(url, data, format='json', **self.header) @@ -626,11 +648,13 @@ def test_edit_object(self): self.assertEqual(response.status_code, 403) # Assign object permission - self.user.object_permissions.create( - model=ContentType.objects.get_for_model(Prefix), + obj_perm = ObjectPermission( attrs={'site__name': 'Site 1'}, can_change=True ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix)) # Attempt to edit a non-permitted object data = {'site': self.sites[0].pk} @@ -659,11 +683,13 @@ def test_delete_object(self): self.assertEqual(response.status_code, 403) # Assign object permission - self.user.object_permissions.create( - model=ContentType.objects.get_for_model(Prefix), + obj_perm = ObjectPermission( attrs={'site__name': 'Site 1'}, can_delete=True ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix)) # Attempt to delete a non-permitted object url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[3].pk}) diff --git a/netbox/users/admin.py b/netbox/users/admin.py index e13904eea4..89aa3f49ab 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -89,8 +89,8 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Format ContentType choices - order_content_types(self.fields['model']) - self.fields['model'].choices.insert(0, ('', '---------')) + order_content_types(self.fields['content_types']) + self.fields['content_types'].choices.insert(0, ('', '---------')) @admin.register(ObjectPermission) diff --git a/netbox/users/migrations/0007_objectpermission.py b/netbox/users/migrations/0007_objectpermission.py index da176dd5d8..2052ffbb2c 100644 --- a/netbox/users/migrations/0007_objectpermission.py +++ b/netbox/users/migrations/0007_objectpermission.py @@ -1,9 +1,8 @@ -# Generated by Django 3.0.6 on 2020-05-27 14:17 +# Generated by Django 3.0.6 on 2020-05-28 18:24 from django.conf import settings import django.contrib.postgres.fields.jsonb from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): @@ -25,12 +24,9 @@ class Migration(migrations.Migration): ('can_add', models.BooleanField(default=False)), ('can_change', models.BooleanField(default=False)), ('can_delete', models.BooleanField(default=False)), + ('content_types', models.ManyToManyField(limit_choices_to={'app_label__in': ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'virtualization']}, related_name='object_permissions', to='contenttypes.ContentType')), ('groups', models.ManyToManyField(blank=True, related_name='object_permissions', to='auth.Group')), - ('model', models.ForeignKey(limit_choices_to={'app_label__in': ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'virtualization']}, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), ('users', models.ManyToManyField(blank=True, related_name='object_permissions', to=settings.AUTH_USER_MODEL)), ], - options={ - 'unique_together': {('model', 'attrs')}, - }, ), ] diff --git a/netbox/users/models.py b/netbox/users/models.py index 6de7bf01a6..bddae2ff7d 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -212,14 +212,14 @@ class ObjectPermission(models.Model): blank=True, related_name='object_permissions' ) - model = models.ForeignKey( + content_types = models.ManyToManyField( to=ContentType, limit_choices_to={ 'app_label__in': [ 'circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'virtualization', ], }, - on_delete=models.CASCADE + related_name='object_permissions' ) attrs = JSONField( blank=True, @@ -239,8 +239,8 @@ class ObjectPermission(models.Model): default=False ) - class Meta: - unique_together = ('model', 'attrs') + def __str__(self): + return "Object permission" def clean(self): diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py index ecb3ea652d..36796194e2 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -26,18 +26,19 @@ def get_object_permissions(self, user_obj): object_permissions = ObjectPermission.objects.filter( Q(users=user_obj) | Q(groups__user=user_obj) - ).prefetch_related('model') + ).prefetch_related('content_types') # Create a dictionary mapping permissions to their attributes perms = dict() for obj_perm in object_permissions: - for action in ['view', 'add', 'change', 'delete']: - if getattr(obj_perm, f"can_{action}"): - perm_name = f"{obj_perm.model.app_label}.{action}_{obj_perm.model.model}" - if perm_name in perms: - perms[perm_name].append(obj_perm.attrs) - else: - perms[perm_name] = [obj_perm.attrs] + for content_type in obj_perm.content_types.all(): + for action in ['view', 'add', 'change', 'delete']: + if getattr(obj_perm, f"can_{action}"): + perm_name = f"{content_type.app_label}.{action}_{content_type.model}" + if perm_name in perms: + perms[perm_name].append(obj_perm.attrs) + else: + perms[perm_name] = [obj_perm.attrs] return perms @@ -122,10 +123,10 @@ def configure_user(self, request, user): for permission_name in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS: try: content_type, action = resolve_permission(permission_name) - user.object_permissions.create(**{ - 'model': content_type, - f'can_{action}': True - }) + obj_perm = ObjectPermission(**{f'can_{action}': True}) + obj_perm.save() + obj_perm.users.add(user) + obj_perm.content_types.add(content_type) permissions_list.append(permission_name) except ValueError: logging.error( diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py index e665b22771..cde3944227 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -34,21 +34,10 @@ def add_permissions(self, *names): """ for name in names: ct, action = resolve_permission(name) - self.user.object_permissions.create(**{ - 'model': ct, - f'can_{action}': True - }) - - def remove_permissions(self, *names): - """ - Remove a set of permissions from the test user, if assigned. - """ - for name in names: - ct, action = resolve_permission(name) - self.user.object_permissions.filter(**{ - 'model': ct, - f'can_{action}': True - }).delete() + obj_perm = ObjectPermission(**{f'can_{action}': True}) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.content_types.add(ct) # # Convenience methods @@ -175,10 +164,12 @@ def test_get_object_with_model_permission(self): instance = self.model.objects.first() # Add model-level permission - self.user.object_permissions.create( - model=ContentType.objects.get_for_model(self.model), + obj_perm = ObjectPermission( can_view=True ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) # Try GET with model-level permission self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 200) @@ -188,11 +179,13 @@ def test_get_object_with_object_permission(self): instance1, instance2 = self.model.objects.all()[:2] # Add object-level permission - self.user.object_permissions.create( - model=ContentType.objects.get_for_model(self.model), + obj_perm = ObjectPermission( attrs={'pk': instance1.pk}, can_view=True ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) # Try GET to permitted object self.assertHttpStatus(self.client.get(instance1.get_absolute_url()), 200) @@ -227,10 +220,12 @@ def test_create_object_with_model_permission(self): initial_count = self.model.objects.count() # Assign model-level permission - self.user.object_permissions.create( - model=ContentType.objects.get_for_model(self.model), + obj_perm = ObjectPermission( can_add=True ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) # Try GET with model-level permission self.assertHttpStatus(self.client.get(self._get_url('add')), 200) @@ -250,12 +245,12 @@ def test_create_object_with_object_permission(self): # Assign object-level permission obj_perm = ObjectPermission( - model=ContentType.objects.get_for_model(self.model), attrs={'pk__gt': 0}, # Dummy permission to allow all can_add=True ) obj_perm.save() obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) # Try GET with object-level permission self.assertHttpStatus(self.client.get(self._get_url('add')), 200) @@ -309,10 +304,12 @@ def test_edit_object_with_model_permission(self): instance = self.model.objects.first() # Assign model-level permission - self.user.object_permissions.create( - model=ContentType.objects.get_for_model(self.model), + obj_perm = ObjectPermission( can_change=True ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) # Try GET with model-level permission self.assertHttpStatus(self.client.get(self._get_url('edit', instance)), 200) @@ -331,12 +328,12 @@ def test_edit_object_with_object_permission(self): # Assign object-level permission obj_perm = ObjectPermission( - model=ContentType.objects.get_for_model(self.model), attrs={'pk': instance1.pk}, can_change=True ) obj_perm.save() obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) # Try GET with a permitted object self.assertHttpStatus(self.client.get(self._get_url('edit', instance1)), 200) @@ -384,10 +381,12 @@ def test_delete_object_with_model_permission(self): instance = self.model.objects.first() # Assign model-level permission - self.user.object_permissions.create( - model=ContentType.objects.get_for_model(self.model), + obj_perm = ObjectPermission( can_delete=True ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) # Try GET with model-level permission self.assertHttpStatus(self.client.get(self._get_url('delete', instance)), 200) @@ -407,12 +406,12 @@ def test_delete_object_with_object_permission(self): # Assign object-level permission obj_perm = ObjectPermission( - model=ContentType.objects.get_for_model(self.model), attrs={'pk': instance1.pk}, can_delete=True ) obj_perm.save() obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) # Try GET with a permitted object self.assertHttpStatus(self.client.get(self._get_url('delete', instance1)), 200) @@ -459,10 +458,12 @@ def test_list_objects_without_permission(self): def test_list_objects_with_model_permission(self): # Add model-level permission - self.user.object_permissions.create( - model=ContentType.objects.get_for_model(self.model), + obj_perm = ObjectPermission( can_view=True ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) # Try GET with model-level permission self.assertHttpStatus(self.client.get(self._get_url('list')), 200) @@ -479,12 +480,12 @@ def test_list_objects_with_object_permission(self): # Add object-level permission obj_perm = ObjectPermission( - model=ContentType.objects.get_for_model(self.model), attrs={'pk': instance1.pk}, can_view=True ) obj_perm.save() obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) # Try GET with object-level permission self.assertHttpStatus(self.client.get(self._get_url('list')), 200) @@ -511,12 +512,10 @@ def test_bulk_create_objects(self): self.assertHttpStatus(self.client.post(**request), 403) # Assign object-level permission - obj_perm = ObjectPermission( - model=ContentType.objects.get_for_model(self.model), - can_add=True - ) + obj_perm = ObjectPermission(can_add=True) obj_perm.save() obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) response = self.client.post(**request) self.assertHttpStatus(response, 302) @@ -557,10 +556,12 @@ def test_bulk_import_objects_with_model_permission(self): } # Assign model-level permission - self.user.object_permissions.create( - model=ContentType.objects.get_for_model(self.model), + obj_perm = ObjectPermission( can_add=True ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) # Try GET with model-level permission self.assertHttpStatus(self.client.get(self._get_url('import')), 200) @@ -578,12 +579,12 @@ def test_bulk_import_objects_with_object_permission(self): # Assign object-level permission obj_perm = ObjectPermission( - model=ContentType.objects.get_for_model(self.model), attrs={'pk__gt': 0}, # Dummy permission to allow all can_add=True ) obj_perm.save() obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) # Test import with object-level permission self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) @@ -625,10 +626,12 @@ def test_bulk_edit_objects_with_model_permission(self): data.update(post_data(self.bulk_edit_data)) # Assign model-level permission - self.user.object_permissions.create( - model=ContentType.objects.get_for_model(self.model), + obj_perm = ObjectPermission( can_change=True ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) # Try POST with model-level permission self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302) @@ -648,12 +651,12 @@ def test_bulk_edit_objects_with_object_permission(self): # Assign object-level permission obj_perm = ObjectPermission( - model=ContentType.objects.get_for_model(self.model), attrs={'pk__in': list(pk_list)}, can_change=True ) obj_perm.save() obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) # Try POST with model-level permission self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302) @@ -693,10 +696,12 @@ def test_bulk_delete_objects_with_model_permission(self): } # Assign model-level permission - self.user.object_permissions.create( - model=ContentType.objects.get_for_model(self.model), + obj_perm = ObjectPermission( can_delete=True ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) # Try POST with model-level permission self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302) @@ -713,12 +718,12 @@ def test_bulk_delete_objects_with_object_permission(self): # Assign object-level permission obj_perm = ObjectPermission( - model=ContentType.objects.get_for_model(self.model), attrs={'pk__in': list(pk_list)}, can_delete=True ) obj_perm.save() obj_perm.users.add(self.user) + obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) # Try POST with object-level permission self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302) From 90828cedae230d75b2bec4cb568db27161207aa4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 29 May 2020 10:31:34 -0400 Subject: [PATCH 066/101] Introduce proxy models for User and Group to organize admin UI --- netbox/users/admin.py | 8 ++-- .../users/migrations/0007_objectpermission.py | 32 -------------- .../users/migrations/0007_proxy_group_user.py | 44 +++++++++++++++++++ netbox/users/models.py | 36 +++++++++++++-- 4 files changed, 81 insertions(+), 39 deletions(-) delete mode 100644 netbox/users/migrations/0007_objectpermission.py create mode 100644 netbox/users/migrations/0007_proxy_group_user.py diff --git a/netbox/users/admin.py b/netbox/users/admin.py index 89aa3f49ab..9482efd5ca 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -1,10 +1,10 @@ from django import forms from django.contrib import admin from django.contrib.auth.admin import UserAdmin as UserAdmin_ -from django.contrib.auth.models import Group, User +from django.contrib.auth.models import Group as StockGroup, User as StockUser from extras.admin import order_content_types -from .models import ObjectPermission, Token, UserConfig +from .models import Group, User, ObjectPermission, Token, UserConfig # @@ -12,8 +12,8 @@ # # Unregister the built-in GroupAdmin and UserAdmin classes so that we can use our custom admin classes below -admin.site.unregister(Group) -admin.site.unregister(User) +admin.site.unregister(StockGroup) +admin.site.unregister(StockUser) @admin.register(Group) diff --git a/netbox/users/migrations/0007_objectpermission.py b/netbox/users/migrations/0007_objectpermission.py deleted file mode 100644 index 2052ffbb2c..0000000000 --- a/netbox/users/migrations/0007_objectpermission.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-28 18:24 - -from django.conf import settings -import django.contrib.postgres.fields.jsonb -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), - ('auth', '0011_update_proxy_permissions'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('users', '0006_create_userconfigs'), - ] - - operations = [ - migrations.CreateModel( - name='ObjectPermission', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), - ('attrs', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)), - ('can_view', models.BooleanField(default=False)), - ('can_add', models.BooleanField(default=False)), - ('can_change', models.BooleanField(default=False)), - ('can_delete', models.BooleanField(default=False)), - ('content_types', models.ManyToManyField(limit_choices_to={'app_label__in': ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'virtualization']}, related_name='object_permissions', to='contenttypes.ContentType')), - ('groups', models.ManyToManyField(blank=True, related_name='object_permissions', to='auth.Group')), - ('users', models.ManyToManyField(blank=True, related_name='object_permissions', to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/netbox/users/migrations/0007_proxy_group_user.py b/netbox/users/migrations/0007_proxy_group_user.py new file mode 100644 index 0000000000..4a72eedd2c --- /dev/null +++ b/netbox/users/migrations/0007_proxy_group_user.py @@ -0,0 +1,44 @@ +# Generated by Django 3.0.6 on 2020-05-29 14:30 + +import django.contrib.auth.models +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0011_update_proxy_permissions'), + ('users', '0006_create_userconfigs'), + ] + + operations = [ + migrations.CreateModel( + name='Group', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('auth.group',), + managers=[ + ('objects', django.contrib.auth.models.GroupManager()), + ], + ), + migrations.CreateModel( + name='User', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('auth.user',), + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/netbox/users/models.py b/netbox/users/models.py index bddae2ff7d..d2a4a152a5 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -1,18 +1,16 @@ import binascii import os -from django.contrib.auth.models import Group, User +from django.contrib.auth.models import Group as Group_, User as User_ from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import JSONField from django.core.exceptions import FieldError, ValidationError from django.core.validators import MinLengthValidator from django.db import models -from django.db.models import Q from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone -from utilities.permissions import resolve_permission from utilities.utils import flatten_dict @@ -23,6 +21,30 @@ ) +# +# Proxy models for admin +# + +class Group(Group_): + """ + Proxy contrib.auth.models.Group for the admin UI + """ + class Meta: + proxy = True + + +class User(User_): + """ + Proxy contrib.auth.models.User for the admin UI + """ + class Meta: + proxy = True + + +# +# User preferences +# + class UserConfig(models.Model): """ This model stores arbitrary user-specific preferences in a JSON data structure. @@ -143,6 +165,10 @@ def create_userconfig(instance, created, **kwargs): UserConfig(user=instance).save() +# +# REST API +# + class Token(models.Model): """ An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens. @@ -197,6 +223,10 @@ def is_expired(self): return True +# +# Permissions +# + 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 From 02687453f2cf8068d6ca999cbf27eb5b761f4ca3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 29 May 2020 11:18:22 -0400 Subject: [PATCH 067/101] Add ArrayField on ObjectPermission to store actions --- netbox/netbox/tests/test_authentication.py | 30 ++++++-------- netbox/users/admin.py | 40 +++++++++++++++++-- .../users/migrations/0007_proxy_group_user.py | 4 +- .../users/migrations/0008_objectpermission.py | 33 +++++++++++++++ netbox/users/models.py | 39 +++++------------- netbox/utilities/auth_backends.py | 16 ++++---- netbox/utilities/testing/testcases.py | 36 ++++++++--------- 7 files changed, 120 insertions(+), 78 deletions(-) create mode 100644 netbox/users/migrations/0008_objectpermission.py diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index ad900bdc0c..bef8f004a5 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -203,7 +203,7 @@ def test_get_object(self): # Assign object permission obj_perm = ObjectPermission( attrs={'site__name': 'Site 1'}, - can_view=True + actions=['view'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -227,7 +227,7 @@ def test_list_objects(self): # Assign object permission obj_perm = ObjectPermission( attrs={'site__name': 'Site 1'}, - can_view=True + actions=['view'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -261,8 +261,7 @@ def test_create_object(self): # Assign object permission obj_perm = ObjectPermission( attrs={'site__name': 'Site 1'}, - can_view=True, - can_add=True + actions=['view', 'add'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -309,8 +308,7 @@ def test_edit_object(self): # Assign object permission obj_perm = ObjectPermission( attrs={'site__name': 'Site 1'}, - can_view=True, - can_change=True + actions=['view', 'change'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -353,8 +351,7 @@ def test_delete_object(self): # Assign object permission obj_perm = ObjectPermission( attrs={'site__name': 'Site 1'}, - can_view=True, - can_delete=True + actions=['view', 'delete'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -402,7 +399,7 @@ def test_bulk_import_objects(self): # Assign object permission obj_perm = ObjectPermission( attrs={'site__name': 'Site 1'}, - can_add=True + actions=['add'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -451,7 +448,7 @@ def test_bulk_edit_objects(self): # Assign object permission obj_perm = ObjectPermission( attrs={'site__name': 'Site 1'}, - can_change=True + actions=['change'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -495,8 +492,7 @@ def test_bulk_delete_objects(self): # Assign object permission obj_perm = ObjectPermission( attrs={'site__name': 'Site 1'}, - can_view=True, - can_delete=True + actions=['view', 'delete'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -567,7 +563,7 @@ def test_get_object(self): # Assign object permission obj_perm = ObjectPermission( attrs={'site__name': 'Site 1'}, - can_view=True + actions=['view'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -594,7 +590,7 @@ def test_list_objects(self): # Assign object permission obj_perm = ObjectPermission( attrs={'site__name': 'Site 1'}, - can_view=True + actions=['view'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -621,7 +617,7 @@ def test_create_object(self): # Assign object permission obj_perm = ObjectPermission( attrs={'site__name': 'Site 1'}, - can_add=True + actions=['add'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -650,7 +646,7 @@ def test_edit_object(self): # Assign object permission obj_perm = ObjectPermission( attrs={'site__name': 'Site 1'}, - can_change=True + actions=['change'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -685,7 +681,7 @@ def test_delete_object(self): # Assign object permission obj_perm = ObjectPermission( attrs={'site__name': 'Site 1'}, - can_delete=True + actions=['delete'] ) obj_perm.save() obj_perm.users.add(self.user) diff --git a/netbox/users/admin.py b/netbox/users/admin.py index 9482efd5ca..507b75869b 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -2,9 +2,10 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin as UserAdmin_ from django.contrib.auth.models import Group as StockGroup, User as StockUser +from django.core.exceptions import FieldError, ValidationError from extras.admin import order_content_types -from .models import Group, User, ObjectPermission, Token, UserConfig +from .models import AdminGroup, AdminUser, ObjectPermission, Token, UserConfig # @@ -16,7 +17,7 @@ admin.site.unregister(StockUser) -@admin.register(Group) +@admin.register(AdminGroup) class GroupAdmin(admin.ModelAdmin): fields = ('name',) list_display = ('name', 'user_count') @@ -34,7 +35,7 @@ class UserConfigInline(admin.TabularInline): verbose_name = 'Preferences' -@admin.register(User) +@admin.register(AdminUser) class UserAdmin(UserAdmin_): list_display = [ 'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active' @@ -92,10 +93,41 @@ def __init__(self, *args, **kwargs): order_content_types(self.fields['content_types']) self.fields['content_types'].choices.insert(0, ('', '---------')) + def clean(self): + content_types = self.cleaned_data['content_types'] + attrs = self.cleaned_data['attrs'] + + # Validate the specified model attributes by attempting to execute a query. We don't care whether the query + # returns anything; we just want to make sure the specified attributes are valid. + if attrs: + for ct in content_types: + model = ct.model_class() + try: + model.objects.filter(**attrs).exists() + except FieldError as e: + raise ValidationError({ + 'attrs': f'Invalid attributes for {model}: {e}' + }) + @admin.register(ObjectPermission) class ObjectPermissionAdmin(admin.ModelAdmin): form = ObjectPermissionForm list_display = [ - 'model', 'can_view', 'can_add', 'can_change', 'can_delete' + 'list_models', 'list_users', 'list_groups', 'actions', 'attrs', ] + + def get_queryset(self, request): + return super().get_queryset(request).prefetch_related('content_types', 'users', 'groups') + + def list_models(self, obj): + return ', '.join([f"{ct}" for ct in obj.content_types.all()]) + list_models.short_description = 'Models' + + def list_users(self, obj): + return ', '.join([u.username for u in obj.users.all()]) + list_users.short_description = 'Users' + + def list_groups(self, obj): + return ', '.join([g.name for g in obj.groups.all()]) + list_groups.short_description = 'Groups' diff --git a/netbox/users/migrations/0007_proxy_group_user.py b/netbox/users/migrations/0007_proxy_group_user.py index 4a72eedd2c..dfd0512bde 100644 --- a/netbox/users/migrations/0007_proxy_group_user.py +++ b/netbox/users/migrations/0007_proxy_group_user.py @@ -14,7 +14,7 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Group', + name='AdminGroup', fields=[ ], options={ @@ -28,7 +28,7 @@ class Migration(migrations.Migration): ], ), migrations.CreateModel( - name='User', + name='AdminUser', fields=[ ], options={ diff --git a/netbox/users/migrations/0008_objectpermission.py b/netbox/users/migrations/0008_objectpermission.py new file mode 100644 index 0000000000..f2ecb98b03 --- /dev/null +++ b/netbox/users/migrations/0008_objectpermission.py @@ -0,0 +1,33 @@ +# Generated by Django 3.0.6 on 2020-05-29 14:59 + +from django.conf import settings +import django.contrib.postgres.fields +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('auth', '0011_update_proxy_permissions'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('users', '0007_proxy_group_user'), + ] + + operations = [ + migrations.CreateModel( + name='ObjectPermission', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('attrs', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)), + ('actions', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=30), size=None)), + ('content_types', models.ManyToManyField(limit_choices_to={'app_label__in': ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'virtualization']}, related_name='object_permissions', to='contenttypes.ContentType')), + ('groups', models.ManyToManyField(blank=True, related_name='object_permissions', to='auth.Group')), + ('users', models.ManyToManyField(blank=True, related_name='object_permissions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Permission', + }, + ), + ] diff --git a/netbox/users/models.py b/netbox/users/models.py index d2a4a152a5..1c8775699d 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -1,10 +1,9 @@ import binascii import os -from django.contrib.auth.models import Group as Group_, User as User_ +from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType -from django.contrib.postgres.fields import JSONField -from django.core.exceptions import FieldError, ValidationError +from django.contrib.postgres.fields import ArrayField, JSONField from django.core.validators import MinLengthValidator from django.db import models from django.db.models.signals import post_save @@ -25,7 +24,7 @@ # Proxy models for admin # -class Group(Group_): +class AdminGroup(Group): """ Proxy contrib.auth.models.Group for the admin UI """ @@ -33,7 +32,7 @@ class Meta: proxy = True -class User(User_): +class AdminUser(User): """ Proxy contrib.auth.models.User for the admin UI """ @@ -256,31 +255,13 @@ class ObjectPermission(models.Model): null=True, verbose_name='Attributes' ) - can_view = models.BooleanField( - default=False - ) - can_add = models.BooleanField( - default=False - ) - can_change = models.BooleanField( - default=False - ) - can_delete = models.BooleanField( - default=False + actions = ArrayField( + base_field=models.CharField(max_length=30), + help_text="The list of actions granted by this permission" ) + class Meta: + verbose_name = "Permission" + def __str__(self): return "Object permission" - - def clean(self): - - # Validate the specified model attributes by attempting to execute a query. We don't care whether the query - # returns anything; we just want to make sure the specified attributes are valid. - if self.attrs: - model = self.model.model_class() - try: - model.objects.filter(**self.attrs).exists() - except FieldError as e: - raise ValidationError({ - 'attrs': f'Invalid attributes for {model}: {e}' - }) diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py index 36796194e2..bc263480f4 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -32,13 +32,12 @@ def get_object_permissions(self, user_obj): perms = dict() for obj_perm in object_permissions: for content_type in obj_perm.content_types.all(): - for action in ['view', 'add', 'change', 'delete']: - if getattr(obj_perm, f"can_{action}"): - perm_name = f"{content_type.app_label}.{action}_{content_type.model}" - if perm_name in perms: - perms[perm_name].append(obj_perm.attrs) - else: - perms[perm_name] = [obj_perm.attrs] + for action in obj_perm.actions: + perm_name = f"{content_type.app_label}.{action}_{content_type.model}" + if perm_name in perms: + perms[perm_name].append(obj_perm.attrs) + else: + perms[perm_name] = [obj_perm.attrs] return perms @@ -123,7 +122,8 @@ def configure_user(self, request, user): for permission_name in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS: try: content_type, action = resolve_permission(permission_name) - obj_perm = ObjectPermission(**{f'can_{action}': True}) + # TODO: Merge multiple actions into a single ObjectPermission per content type + obj_perm = ObjectPermission(actions=[action]) obj_perm.save() obj_perm.users.add(user) obj_perm.content_types.add(content_type) diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py index cde3944227..3514f90605 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -34,7 +34,7 @@ def add_permissions(self, *names): """ for name in names: ct, action = resolve_permission(name) - obj_perm = ObjectPermission(**{f'can_{action}': True}) + obj_perm = ObjectPermission(actions=[action]) obj_perm.save() obj_perm.users.add(self.user) obj_perm.content_types.add(ct) @@ -165,7 +165,7 @@ def test_get_object_with_model_permission(self): # Add model-level permission obj_perm = ObjectPermission( - can_view=True + actions=['view'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -181,7 +181,7 @@ def test_get_object_with_object_permission(self): # Add object-level permission obj_perm = ObjectPermission( attrs={'pk': instance1.pk}, - can_view=True + actions=['view'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -221,7 +221,7 @@ def test_create_object_with_model_permission(self): # Assign model-level permission obj_perm = ObjectPermission( - can_add=True + actions=['add'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -246,7 +246,7 @@ def test_create_object_with_object_permission(self): # Assign object-level permission obj_perm = ObjectPermission( attrs={'pk__gt': 0}, # Dummy permission to allow all - can_add=True + actions=['add'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -305,7 +305,7 @@ def test_edit_object_with_model_permission(self): # Assign model-level permission obj_perm = ObjectPermission( - can_change=True + actions=['change'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -329,7 +329,7 @@ def test_edit_object_with_object_permission(self): # Assign object-level permission obj_perm = ObjectPermission( attrs={'pk': instance1.pk}, - can_change=True + actions=['change'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -382,7 +382,7 @@ def test_delete_object_with_model_permission(self): # Assign model-level permission obj_perm = ObjectPermission( - can_delete=True + actions=['delete'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -407,7 +407,7 @@ def test_delete_object_with_object_permission(self): # Assign object-level permission obj_perm = ObjectPermission( attrs={'pk': instance1.pk}, - can_delete=True + actions=['delete'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -459,7 +459,7 @@ def test_list_objects_with_model_permission(self): # Add model-level permission obj_perm = ObjectPermission( - can_view=True + actions=['view'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -481,7 +481,7 @@ def test_list_objects_with_object_permission(self): # Add object-level permission obj_perm = ObjectPermission( attrs={'pk': instance1.pk}, - can_view=True + actions=['view'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -512,7 +512,7 @@ def test_bulk_create_objects(self): self.assertHttpStatus(self.client.post(**request), 403) # Assign object-level permission - obj_perm = ObjectPermission(can_add=True) + obj_perm = ObjectPermission(actions=['add']) obj_perm.save() obj_perm.users.add(self.user) obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) @@ -557,7 +557,7 @@ def test_bulk_import_objects_with_model_permission(self): # Assign model-level permission obj_perm = ObjectPermission( - can_add=True + actions=['add'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -580,7 +580,7 @@ def test_bulk_import_objects_with_object_permission(self): # Assign object-level permission obj_perm = ObjectPermission( attrs={'pk__gt': 0}, # Dummy permission to allow all - can_add=True + actions=['add'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -627,7 +627,7 @@ def test_bulk_edit_objects_with_model_permission(self): # Assign model-level permission obj_perm = ObjectPermission( - can_change=True + actions=['change'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -652,7 +652,7 @@ def test_bulk_edit_objects_with_object_permission(self): # Assign object-level permission obj_perm = ObjectPermission( attrs={'pk__in': list(pk_list)}, - can_change=True + actions=['change'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -697,7 +697,7 @@ def test_bulk_delete_objects_with_model_permission(self): # Assign model-level permission obj_perm = ObjectPermission( - can_delete=True + actions=['delete'] ) obj_perm.save() obj_perm.users.add(self.user) @@ -719,7 +719,7 @@ def test_bulk_delete_objects_with_object_permission(self): # Assign object-level permission obj_perm = ObjectPermission( attrs={'pk__in': list(pk_list)}, - can_delete=True + actions=['delete'] ) obj_perm.save() obj_perm.users.add(self.user) From 85c54703ec13dbd912f8d3ab12f0f783b7211cfa Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 29 May 2020 12:08:51 -0400 Subject: [PATCH 068/101] Improve the admin form for ObjectPermissions --- netbox/users/admin.py | 45 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/netbox/users/admin.py b/netbox/users/admin.py index 507b75869b..e76150fc41 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -81,22 +81,53 @@ class TokenAdmin(admin.ModelAdmin): # class ObjectPermissionForm(forms.ModelForm): + can_view = forms.BooleanField(required=False) + can_add = forms.BooleanField(required=False) + can_change = forms.BooleanField(required=False) + can_delete = forms.BooleanField(required=False) class Meta: model = ObjectPermission exclude = [] + help_texts = { + 'actions': 'Actions granted in addition to those listed above' + } + labels = { + 'actions': 'Additional actions' + } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + # Make the actions field optional since the admin form uses it only for non-CRUD actions + self.fields['actions'].required = False + # Format ContentType choices order_content_types(self.fields['content_types']) self.fields['content_types'].choices.insert(0, ('', '---------')) + # Check the appropriate checkboxes when editing an existing ObjectPermission + if self.instance: + for action in ['view', 'add', 'change', 'delete']: + if action in self.instance.actions: + self.fields[f'can_{action}'].initial = True + self.instance.actions.remove(action) + def clean(self): content_types = self.cleaned_data['content_types'] attrs = self.cleaned_data['attrs'] + # Append any of the selected CRUD checkboxes to the actions list + if not self.cleaned_data.get('actions'): + self.cleaned_data['actions'] = list() + for action in ['view', 'add', 'change', 'delete']: + if self.cleaned_data[f'can_{action}'] and action not in self.cleaned_data['actions']: + self.cleaned_data['actions'].append(action) + + # At least one action must be specified + if not self.cleaned_data['actions']: + raise ValidationError("At least one action must be selected.") + # Validate the specified model attributes by attempting to execute a query. We don't care whether the query # returns anything; we just want to make sure the specified attributes are valid. if attrs: @@ -112,6 +143,20 @@ def clean(self): @admin.register(ObjectPermission) class ObjectPermissionAdmin(admin.ModelAdmin): + fieldsets = ( + ('Objects', { + 'fields': ('content_types',) + }), + ('Assignment', { + 'fields': (('groups', 'users'),) + }), + ('Actions', { + 'fields': (('can_view', 'can_add', 'can_change', 'can_delete'), 'actions') + }), + ('Constraints', { + 'fields': ('attrs',) + }), + ) form = ObjectPermissionForm list_display = [ 'list_models', 'list_users', 'list_groups', 'actions', 'attrs', From 5d3cf8074bc50e5e269fab047d64db8cc60d16e6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 29 May 2020 13:42:38 -0400 Subject: [PATCH 069/101] Add migration for replicating legact permissions to ObjectPermissions --- .../migrations/0009_replicate_permissions.py | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 netbox/users/migrations/0009_replicate_permissions.py diff --git a/netbox/users/migrations/0009_replicate_permissions.py b/netbox/users/migrations/0009_replicate_permissions.py new file mode 100644 index 0000000000..ba0663a0cf --- /dev/null +++ b/netbox/users/migrations/0009_replicate_permissions.py @@ -0,0 +1,41 @@ +from django.db import migrations + + +ACTIONS = ['view', 'add', 'change', 'delete'] + + +def replicate_permissions(apps, schema_editor): + """ + Replicate all Permission assignments as ObjectPermissions. + """ + Permission = apps.get_model('auth', 'Permission') + ObjectPermission = apps.get_model('users', 'ObjectPermission') + + # TODO: Optimize this iteration so that ObjectPermissions with identical sets of users and groups + # are combined into a single ObjectPermission instance. + for perm in Permission.objects.all(): + print(f'Replicating permission {perm.codename}') + action, model_name = perm.codename.split('_') + + if perm.group_set.exists() or perm.user_set.exists(): + obj_perm = ObjectPermission(actions=[action]) + obj_perm.save() + obj_perm.content_types.add(perm.content_type) + if perm.group_set.exists(): + obj_perm.groups.add(*list(perm.group_set.all())) + if perm.user_set.exists(): + obj_perm.users.add(*list(perm.user_set.all())) + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0008_objectpermission'), + ] + + operations = [ + migrations.RunPython( + code=replicate_permissions, + reverse_code=migrations.RunPython.noop + ) + ] From 670139492d1a8c7f70aeb715d78f7f22d00f2d9b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 29 May 2020 13:47:19 -0400 Subject: [PATCH 070/101] Fix permission action evaluation --- netbox/users/migrations/0009_replicate_permissions.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/netbox/users/migrations/0009_replicate_permissions.py b/netbox/users/migrations/0009_replicate_permissions.py index ba0663a0cf..c5e4d364c5 100644 --- a/netbox/users/migrations/0009_replicate_permissions.py +++ b/netbox/users/migrations/0009_replicate_permissions.py @@ -14,8 +14,11 @@ def replicate_permissions(apps, schema_editor): # TODO: Optimize this iteration so that ObjectPermissions with identical sets of users and groups # are combined into a single ObjectPermission instance. for perm in Permission.objects.all(): - print(f'Replicating permission {perm.codename}') - action, model_name = perm.codename.split('_') + # Account for non-standard permission names; e.g. napalm_read + if perm.codename.split('_')[0] in ACTIONS: + action = perm.codename.split('_')[0] + else: + action = perm.codename if perm.group_set.exists() or perm.user_set.exists(): obj_perm = ObjectPermission(actions=[action]) From 8786bb25c519a71bd6f8d205b400e8413cf4e456 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 29 May 2020 13:57:38 -0400 Subject: [PATCH 071/101] Fix instance evaluation --- netbox/users/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/users/admin.py b/netbox/users/admin.py index e76150fc41..c1b659a8e7 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -107,7 +107,7 @@ def __init__(self, *args, **kwargs): self.fields['content_types'].choices.insert(0, ('', '---------')) # Check the appropriate checkboxes when editing an existing ObjectPermission - if self.instance: + if self.instance.pk: for action in ['view', 'add', 'change', 'delete']: if action in self.instance.actions: self.fields[f'can_{action}'].initial = True From 58989b85c866cd526b1f6a21d0635f823783d625 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 29 May 2020 14:12:24 -0400 Subject: [PATCH 072/101] Introduce restrict_queryset() --- netbox/utilities/api.py | 11 +++-------- netbox/utilities/permissions.py | 18 ++++++++++++++++++ netbox/utilities/views.py | 12 +++--------- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 41002dd205..ef26505352 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -6,7 +6,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist, PermissionDenied from django.db import transaction -from django.db.models import ManyToManyField, ProtectedError, Q +from django.db.models import ManyToManyField, ProtectedError from django.urls import reverse from rest_framework.exceptions import APIException from rest_framework.permissions import BasePermission @@ -16,7 +16,7 @@ from rest_framework.viewsets import ModelViewSet as _ModelViewSet from netbox.api import TokenPermissions -from users.models import ObjectPermission +from utilities.permissions import restrict_queryset from .utils import dict_to_filter_params, dynamic_import @@ -340,12 +340,7 @@ def initial(self, request, *args, **kwargs): permission_required = TokenPermissions.perms_map[request.method][0] % kwargs # Update the view's QuerySet to filter only the permitted objects - obj_perm_attrs = request.user._object_perm_cache[permission_required] - attrs = Q() - for perm_attrs in obj_perm_attrs: - if perm_attrs: - attrs |= Q(**perm_attrs) - self.queryset = self.queryset.filter(attrs) + self.queryset = restrict_queryset(self.queryset, request.user, permission_required) def dispatch(self, request, *args, **kwargs): logger = logging.getLogger('netbox.api.views.ModelViewSet') diff --git a/netbox/utilities/permissions.py b/netbox/utilities/permissions.py index 80d564db4f..be5c0189e9 100644 --- a/netbox/utilities/permissions.py +++ b/netbox/utilities/permissions.py @@ -1,4 +1,5 @@ from django.contrib.contenttypes.models import ContentType +from django.db.models import Q def get_permission_for_model(model, action): @@ -33,3 +34,20 @@ def resolve_permission(name): raise ValueError(f"Unknown app/model for {name}") return content_type, action + + +def restrict_queryset(queryset, user, permission_required): + """ + Filters a QuerySet to return only the objects on which the specified user has been granted the specified + permission. + + :param queryset: Base QuerySet to be restricted + :param user: User instance + :param permission_required: Name of the required permission (e.g. "dcim.view_site") + """ + obj_perm_attrs = user._object_perm_cache[permission_required] + attrs = Q() + for perm_attrs in obj_perm_attrs: + if perm_attrs: + attrs |= Q(**perm_attrs) + return queryset.filter(attrs) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index e73a55dc75..a86b5ccc5b 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -8,7 +8,7 @@ from django.contrib.auth.mixins import AccessMixin from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured, ObjectDoesNotExist, ValidationError from django.db import transaction, IntegrityError -from django.db.models import ManyToManyField, ProtectedError, Q +from django.db.models import ManyToManyField, ProtectedError from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea from django.http import HttpResponse, HttpResponseServerError from django.shortcuts import get_object_or_404, redirect, render @@ -26,10 +26,9 @@ from extras.models import CustomField, CustomFieldValue, ExportTemplate from extras.querysets import CustomFieldQueryset -from users.models import ObjectPermission from utilities.exceptions import AbortTransaction from utilities.forms import BootstrapMixin, CSVDataField, TableConfigForm -from utilities.permissions import get_permission_for_model +from utilities.permissions import get_permission_for_model, restrict_queryset from utilities.utils import csv_format, prepare_cloned_fields from .error_handlers import handle_protectederror from .forms import ConfirmationForm, ImportForm @@ -67,12 +66,7 @@ def has_permission(self): # Update the view's QuerySet to filter only the permitted objects if user.is_authenticated and not user.is_superuser: - obj_perm_attrs = user._object_perm_cache[permission_required] - attrs = Q() - for perm_attrs in obj_perm_attrs: - if perm_attrs: - attrs |= Q(**perm_attrs) - self.queryset = self.queryset.filter(attrs) + self.queryset = restrict_queryset(self.queryset, user, permission_required) return True From 5b6a6fb63e2d5a67649a9db40450b1e835cda561 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 29 May 2020 15:09:08 -0400 Subject: [PATCH 073/101] Move restrict_queryset() function to RestrictedQuerySet --- netbox/utilities/api.py | 5 ++--- netbox/utilities/permissions.py | 17 ----------------- netbox/utilities/querysets.py | 30 ++++++++++++++++++++++++++++++ netbox/utilities/views.py | 4 ++-- 4 files changed, 34 insertions(+), 22 deletions(-) diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index ef26505352..ac21d298c5 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -16,7 +16,6 @@ from rest_framework.viewsets import ModelViewSet as _ModelViewSet from netbox.api import TokenPermissions -from utilities.permissions import restrict_queryset from .utils import dict_to_filter_params, dynamic_import @@ -339,8 +338,8 @@ def initial(self, request, *args, **kwargs): } permission_required = TokenPermissions.perms_map[request.method][0] % kwargs - # Update the view's QuerySet to filter only the permitted objects - self.queryset = restrict_queryset(self.queryset, request.user, permission_required) + # Restrict the view's QuerySet to allow only the permitted objects + self.queryset = self.queryset.restrict(request.user, permission_required) def dispatch(self, request, *args, **kwargs): logger = logging.getLogger('netbox.api.views.ModelViewSet') diff --git a/netbox/utilities/permissions.py b/netbox/utilities/permissions.py index be5c0189e9..697e188288 100644 --- a/netbox/utilities/permissions.py +++ b/netbox/utilities/permissions.py @@ -34,20 +34,3 @@ def resolve_permission(name): raise ValueError(f"Unknown app/model for {name}") return content_type, action - - -def restrict_queryset(queryset, user, permission_required): - """ - Filters a QuerySet to return only the objects on which the specified user has been granted the specified - permission. - - :param queryset: Base QuerySet to be restricted - :param user: User instance - :param permission_required: Name of the required permission (e.g. "dcim.view_site") - """ - obj_perm_attrs = user._object_perm_cache[permission_required] - attrs = Q() - for perm_attrs in obj_perm_attrs: - if perm_attrs: - attrs |= Q(**perm_attrs) - return queryset.filter(attrs) diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py index 34b7a0cf3c..36460310e5 100644 --- a/netbox/utilities/querysets.py +++ b/netbox/utilities/querysets.py @@ -1,3 +1,6 @@ +from django.db.models import Q, QuerySet + + class DummyQuerySet: """ A fake QuerySet that can be used to cache relationships to objects that have been deleted. @@ -7,3 +10,30 @@ def __init__(self, queryset): def all(self): return self._cache + + +class RestrictedQuerySet(QuerySet): + + def restrict(self, user, permission_required): + """ + Filter the QuerySet to return only objects on which the specified user has been granted the specified + permission. + + :param queryset: Base QuerySet to be restricted + :param user: User instance + :param permission_required: Name of the required permission (e.g. "dcim.view_site") + """ + + # Determine what constraints (if any) have been placed on this user for this action and model + # TODO: Find a better way to ensure permissions are cached + if not hasattr(user, '_object_perm_cache'): + user.get_all_permisisons() + obj_perm_attrs = user._object_perm_cache[permission_required] + + # Filter the queryset to include only objects with allowed attributes + attrs = Q() + for perm_attrs in obj_perm_attrs: + if perm_attrs: + attrs |= Q(**perm_attrs) + + return self.filter(attrs) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index a86b5ccc5b..fed7748125 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -28,7 +28,7 @@ from extras.querysets import CustomFieldQueryset from utilities.exceptions import AbortTransaction from utilities.forms import BootstrapMixin, CSVDataField, TableConfigForm -from utilities.permissions import get_permission_for_model, restrict_queryset +from utilities.permissions import get_permission_for_model from utilities.utils import csv_format, prepare_cloned_fields from .error_handlers import handle_protectederror from .forms import ConfirmationForm, ImportForm @@ -66,7 +66,7 @@ def has_permission(self): # Update the view's QuerySet to filter only the permitted objects if user.is_authenticated and not user.is_superuser: - self.queryset = restrict_queryset(self.queryset, user, permission_required) + self.queryset = self.queryset.restrict(user, permission_required) return True From e23b2c4c4fdf9d7c77145823648dc1c1ede7274e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 29 May 2020 16:27:36 -0400 Subject: [PATCH 074/101] Implement RestrictedQuerySet as a manager --- netbox/circuits/models.py | 8 ++++- netbox/circuits/querysets.py | 6 ++-- netbox/dcim/models/__init__.py | 35 +++++++++++++++++-- .../dcim/models/device_component_templates.py | 2 ++ netbox/dcim/models/device_components.py | 3 ++ netbox/extras/models/models.py | 3 ++ netbox/extras/models/tags.py | 3 ++ netbox/extras/querysets.py | 4 ++- netbox/ipam/managers.py | 3 +- netbox/ipam/models.py | 23 ++++++++---- netbox/ipam/querysets.py | 4 +-- netbox/secrets/models.py | 6 +++- netbox/tenancy/models.py | 7 +++- netbox/utilities/mptt.py | 19 ++++++++++ netbox/utilities/querysets.py | 2 +- netbox/virtualization/models.py | 11 ++++-- 16 files changed, 118 insertions(+), 21 deletions(-) create mode 100644 netbox/utilities/mptt.py diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 57d41a994d..dcf1c51180 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -8,6 +8,7 @@ from dcim.models import CableTermination from extras.models import CustomFieldModel, ObjectChange, TaggedItem from extras.utils import extras_features +from utilities.querysets import RestrictedQuerySet from utilities.models import ChangeLoggedModel from utilities.utils import serialize_object from .choices import * @@ -66,9 +67,10 @@ class Provider(ChangeLoggedModel, CustomFieldModel): content_type_field='obj_type', object_id_field='obj_id' ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = [ 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', ] @@ -115,6 +117,8 @@ class CircuitType(ChangeLoggedModel): blank=True, ) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'description'] class Meta: @@ -300,6 +304,8 @@ class CircuitTermination(CableTermination): blank=True ) + objects = RestrictedQuerySet.as_manager() + class Meta: ordering = ['circuit', 'term_side'] unique_together = ['circuit', 'term_side'] diff --git a/netbox/circuits/querysets.py b/netbox/circuits/querysets.py index 60956f32ae..8a9bd50a40 100644 --- a/netbox/circuits/querysets.py +++ b/netbox/circuits/querysets.py @@ -1,7 +1,9 @@ -from django.db.models import OuterRef, QuerySet, Subquery +from django.db.models import OuterRef, Subquery +from utilities.querysets import RestrictedQuerySet -class CircuitQuerySet(QuerySet): + +class CircuitQuerySet(RestrictedQuerySet): def annotate_sites(self): """ diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 1f64781193..3dd3b8c89b 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -25,7 +25,9 @@ from extras.utils import extras_features from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField +from utilities.querysets import RestrictedQuerySet from utilities.models import ChangeLoggedModel +from utilities.mptt import TreeManager from utilities.utils import serialize_object, to_meters from utilities.validators import ExclusionValidator from .device_component_templates import ( @@ -103,6 +105,8 @@ class Region(MPTTModel, ChangeLoggedModel): blank=True ) + objects = TreeManager() + csv_headers = ['name', 'slug', 'parent', 'description'] class MPTTMeta: @@ -244,6 +248,8 @@ class Site(ChangeLoggedModel, CustomFieldModel): ) tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = [ 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments', @@ -326,6 +332,8 @@ class RackGroup(MPTTModel, ChangeLoggedModel): blank=True ) + objects = TreeManager() + csv_headers = ['site', 'parent', 'name', 'slug', 'description'] class Meta: @@ -388,6 +396,8 @@ class RackRole(ChangeLoggedModel): blank=True, ) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'color', 'description'] class Meta: @@ -526,6 +536,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel): ) tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = [ 'site', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments', @@ -821,6 +833,8 @@ class RackReservation(ChangeLoggedModel): max_length=200 ) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['site', 'rack_group', 'rack', 'units', 'tenant', 'user', 'description'] class Meta: @@ -900,6 +914,8 @@ class Manufacturer(ChangeLoggedModel): blank=True ) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'description'] class Meta: @@ -982,9 +998,10 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): content_type_field='obj_type', object_id_field='obj_id' ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + clone_fields = [ 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', ] @@ -1206,6 +1223,8 @@ class DeviceRole(ChangeLoggedModel): blank=True, ) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'color', 'vm_role', 'description'] class Meta: @@ -1263,6 +1282,8 @@ class Platform(ChangeLoggedModel): blank=True ) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description'] class Meta: @@ -1429,6 +1450,8 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): ) tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = [ 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', 'site', 'rack_group', 'rack_name', 'position', 'face', 'comments', @@ -1741,9 +1764,10 @@ class VirtualChassis(ChangeLoggedModel): max_length=30, blank=True ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['master', 'domain'] class Meta: @@ -1813,6 +1837,8 @@ class PowerPanel(ChangeLoggedModel): max_length=50 ) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['site', 'rack_group', 'name'] class Meta: @@ -1916,9 +1942,10 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel): content_type_field='obj_type', object_id_field='obj_id' ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = [ 'site', 'power_panel', 'rack_group', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'comments', @@ -2084,6 +2111,8 @@ class Cable(ChangeLoggedModel): null=True ) + objects = RestrictedQuerySet.as_manager() + csv_headers = [ 'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 164d37d776..e412a602e5 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -6,6 +6,7 @@ from dcim.constants import * from extras.models import ObjectChange from utilities.fields import NaturalOrderingField +from utilities.querysets import RestrictedQuerySet from utilities.ordering import naturalize_interface from utilities.utils import serialize_object from .device_components import ( @@ -26,6 +27,7 @@ class ComponentTemplateModel(models.Model): + objects = RestrictedQuerySet.as_manager() class Meta: abstract = True diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 4005d41a4b..702455c7ea 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -16,6 +16,7 @@ from extras.utils import extras_features from utilities.fields import NaturalOrderingField from utilities.ordering import naturalize_interface +from utilities.querysets import RestrictedQuerySet from utilities.query_functions import CollateAsChar from utilities.utils import serialize_object from virtualization.choices import VMInterfaceTypeChoices @@ -41,6 +42,8 @@ class ComponentModel(models.Model): blank=True ) + objects = RestrictedQuerySet.as_manager() + class Meta: abstract = True diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index f98a7b34f4..a94fc3eea8 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -12,6 +12,7 @@ from django.urls import reverse from rest_framework.utils.encoders import JSONEncoder +from utilities.querysets import RestrictedQuerySet from utilities.utils import deepmerge, render_jinja2 from extras.choices import * from extras.constants import * @@ -670,6 +671,8 @@ class ObjectChange(models.Model): editable=False ) + objects = RestrictedQuerySet.as_manager() + csv_headers = [ 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id', 'related_object_type', 'related_object_id', 'object_repr', 'object_data', diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index d68ca2ce69..d5792ebdad 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -6,6 +6,7 @@ from utilities.choices import ColorChoices from utilities.fields import ColorField from utilities.models import ChangeLoggedModel +from utilities.querysets import RestrictedQuerySet # @@ -21,6 +22,8 @@ class Tag(TagBase, ChangeLoggedModel): blank=True, ) + objects = RestrictedQuerySet.as_manager() + def get_absolute_url(self): return reverse('extras:tag', args=[self.slug]) diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index 812c66714b..9d9b55778d 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -2,6 +2,8 @@ from django.db.models import Q, QuerySet +from utilities.querysets import RestrictedQuerySet + class CustomFieldQueryset: """ @@ -19,7 +21,7 @@ def __iter__(self): yield obj -class ConfigContextQuerySet(QuerySet): +class ConfigContextQuerySet(RestrictedQuerySet): def get_for_object(self, obj): """ diff --git a/netbox/ipam/managers.py b/netbox/ipam/managers.py index 8811e504a2..245a3c891e 100644 --- a/netbox/ipam/managers.py +++ b/netbox/ipam/managers.py @@ -1,6 +1,7 @@ from django.db import models from ipam.lookups import Host, Inet +from utilities.querysets import RestrictedQuerySet class IPAddressManager(models.Manager): @@ -13,5 +14,5 @@ def get_queryset(self): then re-cast this value to INET() so that records will be ordered properly. We are essentially re-casting each IP address as a /32 or /128. """ - qs = super().get_queryset() + qs = RestrictedQuerySet(self.model, using=self._db) return qs.order_by(Inet(Host('address'))) diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index eeb985b7c8..b99a6c9192 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -12,6 +12,7 @@ from extras.models import CustomFieldModel, ObjectChange, TaggedItem from extras.utils import extras_features from utilities.models import ChangeLoggedModel +from utilities.querysets import RestrictedQuerySet from utilities.utils import serialize_object from virtualization.models import VirtualMachine from .choices import * @@ -74,9 +75,10 @@ class VRF(ChangeLoggedModel, CustomFieldModel): content_type_field='obj_type', object_id_field='obj_id' ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] clone_fields = [ 'tenant', 'enforce_unique', 'description', @@ -131,6 +133,8 @@ class RIR(ChangeLoggedModel): blank=True ) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'is_private', 'description'] class Meta: @@ -179,9 +183,10 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel): content_type_field='obj_type', object_id_field='obj_id' ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['prefix', 'rir', 'date_added', 'description'] clone_fields = [ 'rir', 'date_added', 'description', @@ -274,6 +279,8 @@ class Role(ChangeLoggedModel): blank=True, ) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'weight', 'description'] class Meta: @@ -360,9 +367,9 @@ class Prefix(ChangeLoggedModel, CustomFieldModel): content_type_field='obj_type', object_id_field='obj_id' ) + tags = TaggableManager(through=TaggedItem) objects = PrefixQuerySet.as_manager() - tags = TaggableManager(through=TaggedItem) csv_headers = [ 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'description', @@ -631,9 +638,9 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): content_type_field='obj_type', object_id_field='obj_id' ) + tags = TaggableManager(through=TaggedItem) objects = IPAddressManager() - tags = TaggableManager(through=TaggedItem) csv_headers = [ 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary', @@ -828,6 +835,8 @@ class VLANGroup(ChangeLoggedModel): blank=True ) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'site', 'description'] class Meta: @@ -923,9 +932,10 @@ class VLAN(ChangeLoggedModel, CustomFieldModel): content_type_field='obj_type', object_id_field='obj_id' ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description'] clone_fields = [ 'site', 'group', 'tenant', 'status', 'role', 'description', @@ -1039,9 +1049,10 @@ class Service(ChangeLoggedModel, CustomFieldModel): content_type_field='obj_type', object_id_field='obj_id' ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'port', 'description'] class Meta: diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py index 3a48be7893..6d2dc6f335 100644 --- a/netbox/ipam/querysets.py +++ b/netbox/ipam/querysets.py @@ -1,7 +1,7 @@ -from django.db.models import QuerySet +from utilities.querysets import RestrictedQuerySet -class PrefixQuerySet(QuerySet): +class PrefixQuerySet(RestrictedQuerySet): def annotate_depth(self, limit=None): """ diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 61d8adb6ba..757ef88c76 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -17,6 +17,7 @@ from extras.models import CustomFieldModel, TaggedItem from extras.utils import extras_features from utilities.models import ChangeLoggedModel +from utilities.querysets import RestrictedQuerySet from .exceptions import InvalidKey from .hashers import SecretValidationHasher from .querysets import UserKeyQuerySet @@ -268,6 +269,8 @@ class SecretRole(ChangeLoggedModel): blank=True ) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'description'] class Meta: @@ -333,9 +336,10 @@ class Secret(ChangeLoggedModel, CustomFieldModel): content_type_field='obj_type', object_id_field='obj_id' ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + plaintext = None csv_headers = ['device', 'role', 'name', 'plaintext'] diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 077fb6ad1e..2e415b9650 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -7,6 +7,8 @@ from extras.models import CustomFieldModel, ObjectChange, TaggedItem from extras.utils import extras_features from utilities.models import ChangeLoggedModel +from utilities.mptt import TreeManager +from utilities.querysets import RestrictedQuerySet from utilities.utils import serialize_object @@ -40,6 +42,8 @@ class TenantGroup(MPTTModel, ChangeLoggedModel): blank=True ) + objects = TreeManager() + csv_headers = ['name', 'slug', 'parent', 'description'] class Meta: @@ -104,9 +108,10 @@ class Tenant(ChangeLoggedModel, CustomFieldModel): content_type_field='obj_type', object_id_field='obj_id' ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'group', 'description', 'comments'] clone_fields = [ 'group', 'description', diff --git a/netbox/utilities/mptt.py b/netbox/utilities/mptt.py new file mode 100644 index 0000000000..1bae2053d2 --- /dev/null +++ b/netbox/utilities/mptt.py @@ -0,0 +1,19 @@ +from mptt.managers import TreeManager as TreeManager_ +from mptt.querysets import TreeQuerySet as TreeQuerySet_ + +from django.db.models import Manager +from .querysets import RestrictedQuerySet + + +class TreeQuerySet(TreeQuerySet_, RestrictedQuerySet): + """ + Mate django-mptt's TreeQuerySet with our RestrictedQuerySet for permissions enforcement. + """ + pass + + +class TreeManager(Manager.from_queryset(TreeQuerySet), TreeManager_): + """ + Extend django-mptt's TreeManager to incorporate RestrictedQuerySet(). + """ + pass diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py index 36460310e5..3bc41e072d 100644 --- a/netbox/utilities/querysets.py +++ b/netbox/utilities/querysets.py @@ -27,7 +27,7 @@ def restrict(self, user, permission_required): # Determine what constraints (if any) have been placed on this user for this action and model # TODO: Find a better way to ensure permissions are cached if not hasattr(user, '_object_perm_cache'): - user.get_all_permisisons() + user.get_all_permissions() obj_perm_attrs = user._object_perm_cache[permission_required] # Filter the queryset to include only objects with allowed attributes diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 3daeff013c..8ad40bab7f 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -9,6 +9,7 @@ from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem from extras.utils import extras_features from utilities.models import ChangeLoggedModel +from utilities.querysets import RestrictedQuerySet from .choices import * @@ -40,6 +41,8 @@ class ClusterType(ChangeLoggedModel): blank=True ) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'description'] class Meta: @@ -79,6 +82,8 @@ class ClusterGroup(ChangeLoggedModel): blank=True ) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'description'] class Meta: @@ -145,9 +150,10 @@ class Cluster(ChangeLoggedModel, CustomFieldModel): content_type_field='obj_type', object_id_field='obj_id' ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'type', 'group', 'site', 'comments'] clone_fields = [ 'type', 'group', 'tenant', 'site', @@ -269,9 +275,10 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): content_type_field='obj_type', object_id_field='obj_id' ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = [ 'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', ] From 5574aaa8cb8556e6b2cbe2d29a9137da54f36c61 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Jun 2020 10:45:49 -0400 Subject: [PATCH 075/101] Tweak restrict() to accept only an action keyword --- netbox/utilities/api.py | 20 ++++++++++++-------- netbox/utilities/querysets.py | 8 ++++++-- netbox/utilities/views.py | 3 ++- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index ac21d298c5..50401dfd1f 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -330,16 +330,20 @@ def initial(self, request, *args, **kwargs): if not request.user.is_authenticated or request.user.is_superuser: return - # TODO: Move this to a cleaner function - # Determine the required permission based on the request method - kwargs = { - 'app_label': self.queryset.model._meta.app_label, - 'model_name': self.queryset.model._meta.model_name - } - permission_required = TokenPermissions.perms_map[request.method][0] % kwargs + # TODO: Reconcile this with TokenPermissions.perms_map + action = { + 'GET': 'view', + 'OPTIONS': None, + 'HEAD': 'view', + 'POST': 'add', + 'PUT': 'change', + 'PATCH': 'change', + 'DELETE': 'delete', + }[request.method] # Restrict the view's QuerySet to allow only the permitted objects - self.queryset = self.queryset.restrict(request.user, permission_required) + if action: + self.queryset = self.queryset.restrict(request.user, action) def dispatch(self, request, *args, **kwargs): logger = logging.getLogger('netbox.api.views.ModelViewSet') diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py index 3bc41e072d..07199e1430 100644 --- a/netbox/utilities/querysets.py +++ b/netbox/utilities/querysets.py @@ -14,15 +14,19 @@ def all(self): class RestrictedQuerySet(QuerySet): - def restrict(self, user, permission_required): + def restrict(self, user, action): """ Filter the QuerySet to return only objects on which the specified user has been granted the specified permission. :param queryset: Base QuerySet to be restricted :param user: User instance - :param permission_required: Name of the required permission (e.g. "dcim.view_site") + :param action: The action which must be permitted (e.g. "view" for "dcim.view_site") """ + # Resolve the full name of the required permission + app_label = self.model._meta.app_label + model_name = self.model._meta.model_name + permission_required = f'{app_label}.{action}_{model_name}' # Determine what constraints (if any) have been placed on this user for this action and model # TODO: Find a better way to ensure permissions are cached diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index fed7748125..f59492a0c7 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -66,7 +66,8 @@ def has_permission(self): # Update the view's QuerySet to filter only the permitted objects if user.is_authenticated and not user.is_superuser: - self.queryset = self.queryset.restrict(user, permission_required) + action = permission_required.split('.')[1].split('_')[0] + self.queryset = self.queryset.restrict(user, action) return True From 3c334a0238fdb4631a4f8f9bdc5d2df5689564ba Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Jun 2020 11:43:49 -0400 Subject: [PATCH 076/101] Update views to restrict all querysets --- netbox/circuits/views.py | 6 +- netbox/dcim/views.py | 87 ++++++++++++++--------- netbox/extras/views.py | 12 ++-- netbox/ipam/managers.py | 7 +- netbox/ipam/views.py | 34 ++++----- netbox/tenancy/views.py | 22 +++--- netbox/utilities/querysets.py | 11 ++- netbox/virtualization/tests/test_views.py | 1 - netbox/virtualization/views.py | 6 +- 9 files changed, 108 insertions(+), 78 deletions(-) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index bb4d787c8c..5da912f0a2 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -33,7 +33,7 @@ class ProviderView(ObjectView): def get(self, request, slug): provider = get_object_or_404(self.queryset, slug=slug) - circuits = Circuit.objects.filter( + circuits = Circuit.objects.restrict(request.user, 'view').filter( provider=provider ).prefetch_related( 'type', 'tenant', 'terminations__site' @@ -138,12 +138,12 @@ class CircuitView(ObjectView): def get(self, request, pk): circuit = get_object_or_404(self.queryset, pk=pk) - termination_a = CircuitTermination.objects.prefetch_related( + termination_a = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related( 'site__region', 'connected_endpoint__device' ).filter( circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A ).first() - termination_z = CircuitTermination.objects.prefetch_related( + termination_z = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related( 'site__region', 'connected_endpoint__device' ).filter( circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index d8ef5a5e96..0f4297fd63 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -19,8 +19,9 @@ from circuits.models import Circuit from extras.models import Graph from extras.views import ObjectConfigContextView -from ipam.models import Prefix, VLAN +from ipam.models import Prefix, Service, VLAN from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable +from secrets.models import Secret from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator from utilities.permissions import get_permission_for_model @@ -197,14 +198,16 @@ def get(self, request, slug): site = get_object_or_404(self.queryset, slug=slug) stats = { - 'rack_count': Rack.objects.filter(site=site).count(), - 'device_count': Device.objects.filter(site=site).count(), - 'prefix_count': Prefix.objects.filter(site=site).count(), - 'vlan_count': VLAN.objects.filter(site=site).count(), - 'circuit_count': Circuit.objects.filter(terminations__site=site).count(), - 'vm_count': VirtualMachine.objects.filter(cluster__site=site).count(), + 'rack_count': Rack.objects.restrict(request.user, 'view').filter(site=site).count(), + 'device_count': Device.objects.restrict(request.user, 'view').filter(site=site).count(), + 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(site=site).count(), + 'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(site=site).count(), + 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=site).count(), + 'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=site).count(), } - rack_groups = RackGroup.objects.filter(site=site).annotate(rack_count=Count('racks')) + rack_groups = RackGroup.objects.restrict(request.user, 'view').filter(site=site).annotate( + rack_count=Count('racks') + ) show_graphs = Graph.objects.filter(type__model='site').exists() return render(request, 'dcim/site.html', { @@ -372,7 +375,7 @@ def get(self, request, pk): rack = get_object_or_404(self.queryset, pk=pk) - nonracked_devices = Device.objects.filter( + nonracked_devices = Device.objects.restrict(request.user, 'view').filter( rack=rack, position__isnull=True, parent_bay__isnull=True @@ -384,8 +387,8 @@ def get(self, request, pk): next_rack = peer_racks.filter(name__gt=rack.name).order_by('name').first() prev_rack = peer_racks.filter(name__lt=rack.name).order_by('-name').first() - reservations = RackReservation.objects.filter(rack=rack) - power_feeds = PowerFeed.objects.filter(rack=rack).prefetch_related('power_panel') + reservations = RackReservation.objects.restrict(request.user, 'view').filter(rack=rack) + power_feeds = PowerFeed.objects.restrict(request.user, 'view').filter(rack=rack).prefetch_related('power_panel') return render(request, 'dcim/rack.html', { 'rack': rack, @@ -558,35 +561,35 @@ def get(self, request, pk): # Component tables consoleport_table = tables.ConsolePortTemplateTable( - ConsolePortTemplate.objects.filter(device_type=devicetype), + ConsolePortTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype), orderable=False ) consoleserverport_table = tables.ConsoleServerPortTemplateTable( - ConsoleServerPortTemplate.objects.filter(device_type=devicetype), + ConsoleServerPortTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype), orderable=False ) powerport_table = tables.PowerPortTemplateTable( - PowerPortTemplate.objects.filter(device_type=devicetype), + PowerPortTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype), orderable=False ) poweroutlet_table = tables.PowerOutletTemplateTable( - PowerOutletTemplate.objects.filter(device_type=devicetype), + PowerOutletTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype), orderable=False ) interface_table = tables.InterfaceTemplateTable( - list(InterfaceTemplate.objects.filter(device_type=devicetype)), + list(InterfaceTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype)), orderable=False ) front_port_table = tables.FrontPortTemplateTable( - FrontPortTemplate.objects.filter(device_type=devicetype), + FrontPortTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype), orderable=False ) rear_port_table = tables.RearPortTemplateTable( - RearPortTemplate.objects.filter(device_type=devicetype), + RearPortTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype), orderable=False ) devicebay_table = tables.DeviceBayTemplateTable( - DeviceBayTemplate.objects.filter(device_type=devicetype), + DeviceBayTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype), orderable=False ) if request.user.has_perm('dcim.change_devicetype'): @@ -995,47 +998,61 @@ def get(self, request, pk): # VirtualChassis members if device.virtual_chassis is not None: - vc_members = Device.objects.filter( + vc_members = Device.objects.restrict(request.user, 'view').filter( virtual_chassis=device.virtual_chassis ).order_by('vc_position') else: vc_members = [] # Console ports - console_ports = device.consoleports.prefetch_related('connected_endpoint__device', 'cable') + console_ports = ConsolePort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related( + 'connected_endpoint__device', 'cable', + ) # Console server ports - consoleserverports = device.consoleserverports.prefetch_related('connected_endpoint__device', 'cable') + consoleserverports = ConsoleServerPort.objects.restrict(request.user, 'view').filter( + device=device + ).prefetch_related( + 'connected_endpoint__device', 'cable', + ) # Power ports - power_ports = device.powerports.prefetch_related('_connected_poweroutlet__device', 'cable') + power_ports = PowerPort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related( + '_connected_poweroutlet__device', 'cable', + ) # Power outlets - poweroutlets = device.poweroutlets.prefetch_related('connected_endpoint__device', 'cable', 'power_port') + poweroutlets = PowerOutlet.objects.restrict(request.user, 'view').filter(device=device).prefetch_related( + 'connected_endpoint__device', 'cable', 'power_port', + ) # Interfaces - interfaces = device.vc_interfaces.prefetch_related( + interfaces = device.vc_interfaces.restrict(request.user, 'view').filter(device=device).prefetch_related( 'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable', 'cable__termination_a', 'cable__termination_b', 'ip_addresses', 'tags' ) # Front ports - front_ports = device.frontports.prefetch_related('rear_port', 'cable') + front_ports = FrontPort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related( + 'rear_port', 'cable', + ) # Rear ports - rear_ports = device.rearports.prefetch_related('cable') + rear_ports = RearPort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related('cable') # Device bays - device_bays = device.device_bays.prefetch_related('installed_device__device_type__manufacturer') + device_bays = DeviceBay.objects.restrict(request.user, 'view').filter(device=device).prefetch_related( + 'installed_device__device_type__manufacturer', + ) # Services - services = device.services.all() + services = Service.objects.restrict(request.user, 'view').filter(device=device) # Secrets - secrets = device.secrets.all() + secrets = Secret.objects.restrict(request.user, 'view').filter(device=device) # Find up to ten devices in the same site with the same functional role for quick reference. - related_devices = Device.objects.filter( + related_devices = Device.objects.restrict(request.user, 'view').filter( site=device.site, device_role=device.device_role ).exclude( pk=device.pk @@ -1068,7 +1085,7 @@ class DeviceInventoryView(ObjectView): def get(self, request, pk): device = get_object_or_404(self.queryset, pk=pk) - inventory_items = InventoryItem.objects.filter( + inventory_items = InventoryItem.objects.restrict(request.user, 'view').filter( device=device, parent=None ).prefetch_related( 'manufacturer', 'child_items' @@ -1102,7 +1119,9 @@ class DeviceLLDPNeighborsView(ObjectView): def get(self, request, pk): device = get_object_or_404(self.queryset, pk=pk) - interfaces = device.vc_interfaces.exclude(type__in=NONCONNECTABLE_IFACE_TYPES).prefetch_related( + interfaces = device.vc_interfaces.restrict(request.user, 'view').exclude( + type__in=NONCONNECTABLE_IFACE_TYPES + ).prefetch_related( '_connected_interface__device' ) @@ -1423,7 +1442,7 @@ def get(self, request, pk): # Get assigned IP addresses ipaddress_table = InterfaceIPAddressTable( - data=interface.ip_addresses.prefetch_related('vrf', 'tenant'), + data=interface.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), orderable=False ) diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 9abf96f261..a607a4df81 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -163,7 +163,7 @@ class ObjectConfigContextView(ObjectView): def get(self, request, pk): obj = get_object_or_404(self.queryset, pk=pk) - source_contexts = ConfigContext.objects.get_for_object(obj) + source_contexts = ConfigContext.objects.restrict(request.user, 'view').get_for_object(obj) model_name = self.queryset.model._meta.model_name # Determine user's preferred output format @@ -207,13 +207,17 @@ def get(self, request, pk): objectchange = get_object_or_404(self.queryset, pk=pk) - related_changes = ObjectChange.objects.filter(request_id=objectchange.request_id).exclude(pk=objectchange.pk) + related_changes = ObjectChange.objects.restrict(request.user, 'view').filter( + request_id=objectchange.request_id + ).exclude( + pk=objectchange.pk + ) related_changes_table = ObjectChangeTable( data=related_changes[:50], orderable=False ) - objectchanges = ObjectChange.objects.filter( + objectchanges = ObjectChange.objects.restrict(request.user, 'view').filter( changed_object_type=objectchange.changed_object_type, changed_object_id=objectchange.changed_object_id, ) @@ -255,7 +259,7 @@ def get(self, request, model, **kwargs): # Gather all changes for this object (and its related objects) content_type = ContentType.objects.get_for_model(model) - objectchanges = ObjectChange.objects.prefetch_related( + objectchanges = ObjectChange.objects.restrict(request.user, 'view').prefetch_related( 'user', 'changed_object_type' ).filter( Q(changed_object_type=content_type, changed_object_id=obj.pk) | diff --git a/netbox/ipam/managers.py b/netbox/ipam/managers.py index 245a3c891e..1ef00e1251 100644 --- a/netbox/ipam/managers.py +++ b/netbox/ipam/managers.py @@ -1,10 +1,10 @@ -from django.db import models +from django.db.models import Manager from ipam.lookups import Host, Inet from utilities.querysets import RestrictedQuerySet -class IPAddressManager(models.Manager): +class IPAddressManager(Manager.from_queryset(RestrictedQuerySet)): def get_queryset(self): """ @@ -14,5 +14,4 @@ def get_queryset(self): then re-cast this value to INET() so that records will be ordered properly. We are essentially re-casting each IP address as a /32 or /128. """ - qs = RestrictedQuerySet(self.model, using=self._db) - return qs.order_by(Inet(Host('address'))) + return super().get_queryset().order_by(Inet(Host('address'))) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index d3b604be65..98fe1d73da 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -3,14 +3,13 @@ from django.db.models import Count, Q from django.db.models.expressions import RawSQL from django.shortcuts import get_object_or_404, redirect, render -from django.views.generic import View from django_tables2 import RequestConfig from dcim.models import Device, Interface from utilities.paginator import EnhancedPaginator from utilities.views import ( BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, - ObjectListView, ObjectPermissionRequiredMixin, + ObjectListView, ) from virtualization.models import VirtualMachine from . import filters, forms, tables @@ -125,7 +124,7 @@ class VRFView(ObjectView): def get(self, request, pk): vrf = get_object_or_404(self.queryset, pk=pk) - prefix_count = Prefix.objects.filter(vrf=vrf).count() + prefix_count = Prefix.objects.restrict(request.user, 'view').filter(vrf=vrf).count() return render(request, 'ipam/vrf.html', { 'vrf': vrf, @@ -305,7 +304,7 @@ def get(self, request, pk): aggregate = get_object_or_404(self.queryset, pk=pk) # Find all child prefixes contained by this aggregate - child_prefixes = Prefix.objects.filter( + child_prefixes = Prefix.objects.restrict(request.user, 'view').filter( prefix__net_contained_or_equal=str(aggregate.prefix) ).prefetch_related( 'site', 'role' @@ -429,12 +428,14 @@ def get(self, request, pk): prefix = get_object_or_404(self.queryset, pk=pk) try: - aggregate = Aggregate.objects.get(prefix__net_contains_or_equals=str(prefix.prefix)) + aggregate = Aggregate.objects.restrict(request.user, 'view').get( + prefix__net_contains_or_equals=str(prefix.prefix) + ) except Aggregate.DoesNotExist: aggregate = None # Parent prefixes table - parent_prefixes = Prefix.objects.filter( + parent_prefixes = Prefix.objects.restrict(request.user, 'view').filter( Q(vrf=prefix.vrf) | Q(vrf__isnull=True) ).filter( prefix__net_contains=str(prefix.prefix) @@ -445,7 +446,7 @@ def get(self, request, pk): parent_prefix_table.exclude = ('vrf',) # Duplicate prefixes table - duplicate_prefixes = Prefix.objects.filter( + duplicate_prefixes = Prefix.objects.restrict(request.user, 'view').filter( vrf=prefix.vrf, prefix=str(prefix.prefix) ).exclude( pk=prefix.pk @@ -471,7 +472,7 @@ def get(self, request, pk): prefix = get_object_or_404(self.queryset, pk=pk) # Child prefixes table - child_prefixes = prefix.get_child_prefixes().prefetch_related( + child_prefixes = prefix.get_child_prefixes().restrict(request.user, 'view').prefetch_related( 'site', 'vlan', 'role', ).annotate_depth(limit=0) @@ -515,7 +516,7 @@ def get(self, request, pk): prefix = get_object_or_404(self.queryset, pk=pk) # Find all IPAddresses belonging to this Prefix - ipaddresses = prefix.get_child_ips().prefetch_related( + ipaddresses = prefix.get_child_ips().restrict(request.user, 'view').prefetch_related( 'vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for' ) @@ -607,7 +608,7 @@ def get(self, request, pk): ipaddress = get_object_or_404(self.queryset, pk=pk) # Parent prefixes table - parent_prefixes = Prefix.objects.filter( + parent_prefixes = Prefix.objects.restrict(request.user, 'view').filter( vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip) ).prefetch_related( 'site', 'role' @@ -616,7 +617,7 @@ def get(self, request, pk): parent_prefixes_table.exclude = ('vrf',) # Duplicate IPs table - duplicate_ips = IPAddress.objects.filter( + duplicate_ips = IPAddress.objects.restrict(request.user, 'view').filter( vrf=ipaddress.vrf, address=str(ipaddress.address) ).exclude( pk=ipaddress.pk @@ -629,14 +630,13 @@ def get(self, request, pk): duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False) # Related IP table - related_ips = IPAddress.objects.prefetch_related( + related_ips = IPAddress.objects.restrict(request.user, 'view').prefetch_related( 'interface__device' ).exclude( address=str(ipaddress.address) ).filter( vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address) ) - related_ips_table = tables.IPAddressTable(related_ips, orderable=False) paginate = { @@ -785,7 +785,7 @@ class VLANGroupVLANsView(ObjectView): def get(self, request, pk): vlan_group = get_object_or_404(self.queryset, pk=pk) - vlans = VLAN.objects.filter(group_id=pk) + vlans = VLAN.objects.restrict(request.user, 'view').filter(group_id=pk) vlans = add_available_vlans(vlan_group, vlans) vlan_table = tables.VLANDetailTable(vlans) @@ -832,7 +832,9 @@ class VLANView(ObjectView): def get(self, request, pk): vlan = get_object_or_404(self.queryset, pk=pk) - prefixes = Prefix.objects.filter(vlan=vlan).prefetch_related('vrf', 'site', 'role') + prefixes = Prefix.objects.restrict(request.user, 'view').filter(vlan=vlan).prefetch_related( + 'vrf', 'site', 'role' + ) prefix_table = tables.PrefixTable(list(prefixes), orderable=False) prefix_table.exclude = ('vlan',) @@ -848,7 +850,7 @@ class VLANMembersView(ObjectView): def get(self, request, pk): vlan = get_object_or_404(self.queryset, pk=pk) - members = vlan.get_members().prefetch_related('device', 'virtual_machine') + members = vlan.get_members().restrict(request.user, 'view').prefetch_related('device', 'virtual_machine') members_table = tables.VLANMemberTable(members) diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 823df99332..a82b231f55 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -64,17 +64,17 @@ def get(self, request, slug): tenant = get_object_or_404(self.queryset, slug=slug) stats = { - 'site_count': Site.objects.filter(tenant=tenant).count(), - 'rack_count': Rack.objects.filter(tenant=tenant).count(), - 'rackreservation_count': RackReservation.objects.filter(tenant=tenant).count(), - 'device_count': Device.objects.filter(tenant=tenant).count(), - 'vrf_count': VRF.objects.filter(tenant=tenant).count(), - 'prefix_count': Prefix.objects.filter(tenant=tenant).count(), - 'ipaddress_count': IPAddress.objects.filter(tenant=tenant).count(), - 'vlan_count': VLAN.objects.filter(tenant=tenant).count(), - 'circuit_count': Circuit.objects.filter(tenant=tenant).count(), - 'virtualmachine_count': VirtualMachine.objects.filter(tenant=tenant).count(), - 'cluster_count': Cluster.objects.filter(tenant=tenant).count(), + 'site_count': Site.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'rack_count': Rack.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'rackreservation_count': RackReservation.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'device_count': Device.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'vrf_count': VRF.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'virtualmachine_count': VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'cluster_count': Cluster.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), } return render(request, 'tenancy/tenant.html', { diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py index 07199e1430..6649e4d9c0 100644 --- a/netbox/utilities/querysets.py +++ b/netbox/utilities/querysets.py @@ -28,15 +28,22 @@ def restrict(self, user, action): model_name = self.model._meta.model_name permission_required = f'{app_label}.{action}_{model_name}' + # TODO: Handle anonymous users + if not user.is_authenticated: + return self + # Determine what constraints (if any) have been placed on this user for this action and model # TODO: Find a better way to ensure permissions are cached if not hasattr(user, '_object_perm_cache'): user.get_all_permissions() - obj_perm_attrs = user._object_perm_cache[permission_required] + + # User has not been granted any permission + if permission_required not in user._object_perm_cache: + return self.none() # Filter the queryset to include only objects with allowed attributes attrs = Q() - for perm_attrs in obj_perm_attrs: + for perm_attrs in user._object_perm_cache[permission_required]: if perm_attrs: attrs |= Q(**perm_attrs) diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 9fde121865..5cd19381f9 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -187,7 +187,6 @@ def setUpTestData(cls): # TODO: Update base class to DeviceComponentViewTestCase class InterfaceTestCase( - ViewTestCases.GetObjectViewTestCase, ViewTestCases.EditObjectViewTestCase, ViewTestCases.DeleteObjectViewTestCase, ViewTestCases.BulkCreateObjectsViewTestCase, diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 79a807c211..aea4d0556b 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -89,7 +89,7 @@ class ClusterView(ObjectView): def get(self, request, pk): cluster = get_object_or_404(self.queryset, pk=pk) - devices = Device.objects.filter(cluster=cluster).prefetch_related( + devices = Device.objects.restrict(request.user, 'view').filter(cluster=cluster).prefetch_related( 'site', 'rack', 'tenant', 'device_type__manufacturer' ) device_table = DeviceTable(list(devices), orderable=False) @@ -235,8 +235,8 @@ class VirtualMachineView(ObjectView): def get(self, request, pk): virtualmachine = get_object_or_404(self.queryset, pk=pk) - interfaces = Interface.objects.filter(virtual_machine=virtualmachine) - services = Service.objects.filter(virtual_machine=virtualmachine) + interfaces = Interface.objects.restrict(request.user, 'view').filter(virtual_machine=virtualmachine) + services = Service.objects.restrict(request.user, 'view').filter(virtual_machine=virtualmachine) return render(request, 'virtualization/virtualmachine.html', { 'virtualmachine': virtualmachine, From 9679557747b456efc2caa4bf790008b0b6231840 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Jun 2020 12:31:18 -0400 Subject: [PATCH 077/101] Add permission_is_exempt() --- netbox/utilities/permissions.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/netbox/utilities/permissions.py b/netbox/utilities/permissions.py index 697e188288..de024cf993 100644 --- a/netbox/utilities/permissions.py +++ b/netbox/utilities/permissions.py @@ -1,5 +1,5 @@ +from django.conf import settings from django.contrib.contenttypes.models import ContentType -from django.db.models import Q def get_permission_for_model(model, action): @@ -34,3 +34,25 @@ def resolve_permission(name): raise ValueError(f"Unknown app/model for {name}") return content_type, action + + +def permission_is_exempt(name): + """ + Determine whether a specified permission is exempt from evaluation. + + :param name: Permission name in the format ._ + """ + app_label, codename = name.split('.') + action, model_name = codename.split('_') + + if action == 'view': + if ( + # All models are exempt from view permission enforcement + '*' in settings.EXEMPT_VIEW_PERMISSIONS + ) or ( + # This specific model is exempt from view permission enforcement + '{}.{}'.format(app_label, model_name) in settings.EXEMPT_VIEW_PERMISSIONS + ): + return True + + return False From 3a9512f086fdb46a386639d8f98d5aa4fdcdb5f8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Jun 2020 13:09:34 -0400 Subject: [PATCH 078/101] Refine queryset restriction logic --- netbox/utilities/permissions.py | 18 ++++++++++++++++-- netbox/utilities/querysets.py | 16 ++++++---------- netbox/utilities/views.py | 16 ++++++++-------- 3 files changed, 30 insertions(+), 20 deletions(-) diff --git a/netbox/utilities/permissions.py b/netbox/utilities/permissions.py index de024cf993..38064b6894 100644 --- a/netbox/utilities/permissions.py +++ b/netbox/utilities/permissions.py @@ -19,12 +19,26 @@ def get_permission_for_model(model, action): ) +def get_permission_action(name): + """ + Return the action component (e.g. view or add) from a permission name. + + :param name: Permission name in the format ._ + """ + try: + return name.split('.')[1].split('_')[0] + except ValueError: + raise ValueError( + f"Invalid permission name: {name}. Must be in the format ._" + ) + + def resolve_permission(name): """ Given a permission name, return the relevant ContentType and action. For example, "dcim.view_site" returns (Site, "view"). - :param name: Permission name in the format ._ + :param name: Permission name in the format ._ """ app_label, codename = name.split('.') action, model_name = codename.split('_') @@ -40,7 +54,7 @@ def permission_is_exempt(name): """ Determine whether a specified permission is exempt from evaluation. - :param name: Permission name in the format ._ + :param name: Permission name in the format ._ """ app_label, codename = name.split('.') action, model_name = codename.split('_') diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py index 6649e4d9c0..1ac79e90a9 100644 --- a/netbox/utilities/querysets.py +++ b/netbox/utilities/querysets.py @@ -1,5 +1,7 @@ from django.db.models import Q, QuerySet +from utilities.permissions import permission_is_exempt + class DummyQuerySet: """ @@ -19,7 +21,6 @@ def restrict(self, user, action): Filter the QuerySet to return only objects on which the specified user has been granted the specified permission. - :param queryset: Base QuerySet to be restricted :param user: User instance :param action: The action which must be permitted (e.g. "view" for "dcim.view_site") """ @@ -28,17 +29,12 @@ def restrict(self, user, action): model_name = self.model._meta.model_name permission_required = f'{app_label}.{action}_{model_name}' - # TODO: Handle anonymous users - if not user.is_authenticated: + # Bypass restriction for superusers and exempt views + if user.is_superuser or permission_is_exempt(permission_required): return self - # Determine what constraints (if any) have been placed on this user for this action and model - # TODO: Find a better way to ensure permissions are cached - if not hasattr(user, '_object_perm_cache'): - user.get_all_permissions() - - # User has not been granted any permission - if permission_required not in user._object_perm_cache: + # User is anonymous or has not been granted the requisite permission + if not user.is_authenticated or permission_required not in user.get_all_permissions(): return self.none() # Filter the queryset to include only objects with allowed attributes diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index f59492a0c7..0304780f3c 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -28,7 +28,7 @@ from extras.querysets import CustomFieldQueryset from utilities.exceptions import AbortTransaction from utilities.forms import BootstrapMixin, CSVDataField, TableConfigForm -from utilities.permissions import get_permission_for_model +from utilities.permissions import get_permission_action, get_permission_for_model from utilities.utils import csv_format, prepare_cloned_fields from .error_handlers import handle_protectederror from .forms import ConfirmationForm, ImportForm @@ -60,16 +60,16 @@ def has_permission(self): user = self.request.user permission_required = self.get_required_permission() - # First, check that the user is granted the required permission(s) at either the model or object level. - if not user.has_perms((permission_required, *self.additional_permissions)): - return False + # Check that the user has been granted the required permission(s). + if user.has_perms((permission_required, *self.additional_permissions)): - # Update the view's QuerySet to filter only the permitted objects - if user.is_authenticated and not user.is_superuser: - action = permission_required.split('.')[1].split('_')[0] + # Update the view's QuerySet to filter only the permitted objects + action = get_permission_action(permission_required) self.queryset = self.queryset.restrict(user, action) - return True + return True + + return False def dispatch(self, request, *args, **kwargs): From b6c38ceb732653cc9ad875385799713098d36d2d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Jun 2020 13:17:59 -0400 Subject: [PATCH 079/101] Call permission_is_exempt() to check for exempt permissions --- netbox/utilities/auth_backends.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py index bc263480f4..1522e62682 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -6,14 +6,14 @@ from django.db.models import Q from users.models import ObjectPermission -from utilities.permissions import resolve_permission +from utilities.permissions import permission_is_exempt, resolve_permission class ObjectPermissionBackend(ModelBackend): def get_all_permissions(self, user_obj, obj=None): if not user_obj.is_active or user_obj.is_anonymous: - return set() + return dict() if not hasattr(user_obj, '_object_perm_cache'): user_obj._object_perm_cache = self.get_object_permissions(user_obj) return user_obj._object_perm_cache @@ -49,16 +49,9 @@ def has_perm(self, user_obj, perm, obj=None): if user_obj.is_active and user_obj.is_superuser: return True - # If this is a view permission, check whether the model has been exempted from enforcement - if action == 'view': - if ( - # All models are exempt from view permission enforcement - '*' in settings.EXEMPT_VIEW_PERMISSIONS - ) or ( - # This specific model is exempt from view permission enforcement - '{}.{}'.format(app_label, model_name) in settings.EXEMPT_VIEW_PERMISSIONS - ): - return True + # Permission is exempt from enforcement (i.e. listed in EXEMPT_VIEW_PERMISSIONS) + if permission_is_exempt(perm): + return True # Handle inactive/anonymous users if not user_obj.is_active or user_obj.is_anonymous: From a4af270ea8648892175a03f881fcd04bcef741fb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Jun 2020 13:36:57 -0400 Subject: [PATCH 080/101] Restrict querysets for home, search views --- netbox/netbox/views.py | 45 +++++++++++++++++++++--------------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 37a5164090..d6be844d43 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -194,52 +194,51 @@ class HomeView(View): def get(self, request): - connected_consoleports = ConsolePort.objects.filter( + connected_consoleports = ConsolePort.objects.restrict(request.user, 'view').filter( connected_endpoint__isnull=False ) - connected_powerports = PowerPort.objects.filter( + connected_powerports = PowerPort.objects.restrict(request.user, 'view').filter( _connected_poweroutlet__isnull=False ) - connected_interfaces = Interface.objects.filter( + connected_interfaces = Interface.objects.restrict(request.user, 'view').filter( _connected_interface__isnull=False, pk__lt=F('_connected_interface') ) - cables = Cable.objects.all() stats = { # Organization - 'site_count': Site.objects.count(), - 'tenant_count': Tenant.objects.count(), + 'site_count': Site.objects.restrict(request.user, 'view').count(), + 'tenant_count': Tenant.objects.restrict(request.user, 'view').count(), # DCIM - 'rack_count': Rack.objects.count(), - 'devicetype_count': DeviceType.objects.count(), - 'device_count': Device.objects.count(), + 'rack_count': Rack.objects.restrict(request.user, 'view').count(), + 'devicetype_count': DeviceType.objects.restrict(request.user, 'view').count(), + 'device_count': Device.objects.restrict(request.user, 'view').count(), 'interface_connections_count': connected_interfaces.count(), - 'cable_count': cables.count(), + 'cable_count': Cable.objects.restrict(request.user, 'view').count(), 'console_connections_count': connected_consoleports.count(), 'power_connections_count': connected_powerports.count(), - 'powerpanel_count': PowerPanel.objects.count(), - 'powerfeed_count': PowerFeed.objects.count(), + 'powerpanel_count': PowerPanel.objects.restrict(request.user, 'view').count(), + 'powerfeed_count': PowerFeed.objects.restrict(request.user, 'view').count(), # IPAM - 'vrf_count': VRF.objects.count(), - 'aggregate_count': Aggregate.objects.count(), - 'prefix_count': Prefix.objects.count(), - 'ipaddress_count': IPAddress.objects.count(), - 'vlan_count': VLAN.objects.count(), + 'vrf_count': VRF.objects.restrict(request.user, 'view').count(), + 'aggregate_count': Aggregate.objects.restrict(request.user, 'view').count(), + 'prefix_count': Prefix.objects.restrict(request.user, 'view').count(), + 'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').count(), + 'vlan_count': VLAN.objects.restrict(request.user, 'view').count(), # Circuits - 'provider_count': Provider.objects.count(), - 'circuit_count': Circuit.objects.count(), + 'provider_count': Provider.objects.restrict(request.user, 'view').count(), + 'circuit_count': Circuit.objects.restrict(request.user, 'view').count(), # Secrets - 'secret_count': Secret.objects.count(), + 'secret_count': Secret.objects.restrict(request.user, 'view').count(), # Virtualization - 'cluster_count': Cluster.objects.count(), - 'virtualmachine_count': VirtualMachine.objects.count(), + 'cluster_count': Cluster.objects.restrict(request.user, 'view').count(), + 'virtualmachine_count': VirtualMachine.objects.restrict(request.user, 'view').count(), } @@ -293,7 +292,7 @@ def get(self, request): for obj_type in obj_types: - queryset = SEARCH_TYPES[obj_type]['queryset'] + queryset = SEARCH_TYPES[obj_type]['queryset'].restrict(request.user, 'view') filterset = SEARCH_TYPES[obj_type]['filterset'] table = SEARCH_TYPES[obj_type]['table'] url = SEARCH_TYPES[obj_type]['url'] From 26d7c213140c82dca928e370167b6e2a70b901fe Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Jun 2020 13:47:34 -0400 Subject: [PATCH 081/101] Move authentication backends --- docs/configuration/optional-settings.md | 2 +- .../{utilities/auth_backends.py => netbox/authentication.py} | 0 netbox/netbox/configuration.example.py | 2 +- netbox/netbox/settings.py | 4 ++-- 4 files changed, 4 insertions(+), 4 deletions(-) rename netbox/{utilities/auth_backends.py => netbox/authentication.py} (100%) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 3c43929157..7c4a7c9c2c 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -384,7 +384,7 @@ NetBox can be configured to support remote user authentication by inferring user ## REMOTE_AUTH_BACKEND -Default: `'utilities.auth_backends.RemoteUserBackend'` +Default: `'netbox.authentication.RemoteUserBackend'` Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication, if not using NetBox's built-in backend. (Requires `REMOTE_AUTH_ENABLED`.) diff --git a/netbox/utilities/auth_backends.py b/netbox/netbox/authentication.py similarity index 100% rename from netbox/utilities/auth_backends.py rename to netbox/netbox/authentication.py diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 941cbcd889..0803efb2ae 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -205,7 +205,7 @@ # Remote authentication support REMOTE_AUTH_ENABLED = False -REMOTE_AUTH_BACKEND = 'utilities.auth_backends.RemoteUserBackend' +REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend' REMOTE_AUTH_HEADER = 'HTTP_REMOTE_USER' REMOTE_AUTH_AUTO_CREATE_USER = True REMOTE_AUTH_DEFAULT_GROUPS = [] diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 3b345638b1..6199ede276 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -97,7 +97,7 @@ PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {}) PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False) REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False) -REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'utilities.auth_backends.RemoteUserBackend') +REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend') REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', []) REMOTE_AUTH_DEFAULT_PERMISSIONS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_PERMISSIONS', []) REMOTE_AUTH_ENABLED = getattr(configuration, 'REMOTE_AUTH_ENABLED', False) @@ -339,7 +339,7 @@ def _setting(name, default=None): # Set up authentication backends AUTHENTICATION_BACKENDS = [ REMOTE_AUTH_BACKEND, - 'utilities.auth_backends.ObjectPermissionBackend', + 'netbox.authentication.ObjectPermissionBackend', ] # Internationalization From 5d4cc5bf3d278cb124a5cb05d3b302d87a0c9c4b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Jun 2020 13:59:58 -0400 Subject: [PATCH 082/101] Fix ordering of group and user fields in ObjectPermission admin --- netbox/users/admin.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/netbox/users/admin.py b/netbox/users/admin.py index c1b659a8e7..4c3da5acdb 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -90,7 +90,9 @@ class Meta: model = ObjectPermission exclude = [] help_texts = { - 'actions': 'Actions granted in addition to those listed above' + 'actions': 'Actions granted in addition to those listed above', + 'attrs': 'JSON expression of a queryset filter that will return only permitted objects. Leave null to ' + 'match all objects of this type.' } labels = { 'actions': 'Additional actions' @@ -106,6 +108,10 @@ def __init__(self, *args, **kwargs): order_content_types(self.fields['content_types']) self.fields['content_types'].choices.insert(0, ('', '---------')) + # Order group and user fields + self.fields['groups'].queryset = self.fields['groups'].queryset.order_by('name') + self.fields['users'].queryset = self.fields['users'].queryset.order_by('username') + # Check the appropriate checkboxes when editing an existing ObjectPermission if self.instance.pk: for action in ['view', 'add', 'change', 'delete']: From e9831442cd770270008c060e0146692931319d10 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Jun 2020 15:28:36 -0400 Subject: [PATCH 083/101] Drafted documentation for object-based permissions --- docs/administration/permissions.md | 76 ++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 77 insertions(+) create mode 100644 docs/administration/permissions.md diff --git a/docs/administration/permissions.md b/docs/administration/permissions.md new file mode 100644 index 0000000000..582709726b --- /dev/null +++ b/docs/administration/permissions.md @@ -0,0 +1,76 @@ +# Permissions + +NetBox v2.9 introduced a new object-based permissions framework, which replace's Django's built-in permission model. Object-based permissions allow for the assignment of permissions to an arbitrary subset of objects of a certain type, rather than only by type of object. For example, it is possible to grant a user permission to view only sites within a particular region, or to modify only VLANs with a numeric ID within a certain range. + +Assigning a permission in NetBox entails defining a relationship among several components: + +* Model(s) - One or more types of object in NetBox +* User(s) - One or more users or groups of users +* Actions - The actions that can be performed (view, add, change, and/or delete) +* Attributes - An arbitrary filter used to limit the action to a specific subset of objects + +At a minimum, a permission assignment must specify one model, one user or group, and one action. The specification of constraining attributes is optional: A permission without any attributes specified will apply to all instances of the selected model(s). + +## Actions + +There are four core actions that can be permitted for each type of object within NetBox, roughly analogous to the CRUD convention (create, read, update, and delete): + +* View - Retrieve an object from the database +* Add - Create a new object +* Change - Modify an existing object +* Delete - Delete an existing object + +Some models introduce additional permissions that can be granted to allow other actions. For example, the `napalm_read` permission on the device model allows a user to execute NAPALM queries on a device via NetBox's REST API. These can be specified when granting a permission in the "additional actions" field. + +## Attributes + +Constraining attributes are defined as a JSON object representing a [Django query filter](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups). This is the same syntax that you would pass to the QuerySet `filter()` method when performing a query using the Django ORM. As with query filters, double underscores can be used to traverse related objects or invoke lookup expressions. Some example queries and their corresponding definitions are shown below. + +All attributes defined on a permission are applied with a logic AND. For example, suppose you assign a permission for the site model with the following attributes. + +```json +{ + "status": "active", + "region__name": "Americas" +} +``` + +The permission will grant access only to sites which have a status of "active" **and** which are assigned to the "Americas" region. To achieve a logical OR with a different set of attributes, simply create another permission assignment for the same model and user/group. + +### Example Attribute Definitions + +| Query Filter | Permission Attributes | +| ------------ | --------------------- | +| `filter(status='active')` | `{"status": "active"}` | +| `filter(status='active', role='testing')` | `{"status": "active", "role": "testing"}` | +| `filter(status__in=['planned', 'reserved'])` | `{"status__in": ["planned", "reserved"]}` | +| `filter(name__startswith('Foo')` | `{"name__startswith": "Foo"}` | +| `filter(vid__gte=100, vid__lt=200)` | `{"vid__gte": 100, "vid__lt": 200}` | + +## Permissions Enforcement + +### Viewing Objects + +Object-based permissions work by filtering the database query generated by a user's request to restrict the set of objects returned. When a request is received, NetBox first determines whether the user is authenticated and has been granted to perform the requested action. For example, if the requested URL is `/dcim/devices/`, NetBox will check for the `dcim.view_device` permission. If the user has not been assigned this permission (either directly or via a group assignment), NetBox will return a 403 (forbidden) HTTP response. + +If the permission has been granted, NetBox will compile any specified constraints for the model and action. For example, suppose two permissions have been assigned to the user granting view access to the device model, with the following constraints: + +```json +[ + {"site__name__in": ["NYC1", "NYC2"]}, + {"status": "offline", "tenant__isnull": true} +] +``` + +This grants the user access to view any device that is in NYC1 or NYC2, **or** which has a status of "offline" and has no tenant assigned. These attributes will result in the following ORM query: + +```no-highlight +Site.objects.filter( + Q(site__name__in=['NYC1', 'NYC2']), + Q(status='active', tenant__isnull=True) +) +``` + +### Creating and Modifying Objects + +The same sort of logic is in play when a user attempts to create or modify an object in NetBox, with a twist. Once validation has completed, NetBox starts an atomic database transaction to facilitate the change, and the object is created or saved normally. Next, still within the transaction, NetBox issues a second query to retrieve the newly created/updated object, filtering the restricted queryset with the object's primary key. If this query fails to return the object, NetBox knows that the new revision does not match the attributes granted by the permission. The transaction is then aborted, and the database is left in its original state. diff --git a/mkdocs.yml b/mkdocs.yml index b8633ea8f1..2c58acbd8d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -58,6 +58,7 @@ nav: - Using Plugins: 'plugins/index.md' - Developing Plugins: 'plugins/development.md' - Administration: + - Permissions: 'administration/permissions.md' - Replicating NetBox: 'administration/replicating-netbox.md' - NetBox Shell: 'administration/netbox-shell.md' - API: From 76f74f479ba74e86075aef2eda34dfa7d5f58dd0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Jun 2020 16:23:45 -0400 Subject: [PATCH 084/101] Support permission attribute assignment via REMOTE_AUTH_DEFAULT_PERMISSIONS --- docs/configuration/optional-settings.md | 4 ++-- netbox/netbox/authentication.py | 6 +++--- netbox/netbox/configuration.example.py | 2 +- netbox/netbox/settings.py | 13 ++++++++++++- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 7c4a7c9c2c..31ee39a5fc 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -416,9 +416,9 @@ The list of groups to assign a new user account when created using remote authen ## REMOTE_AUTH_DEFAULT_PERMISSIONS -Default: `[]` (Empty list) +Default: `{}` (Empty dictionary) -The list of permissions to assign a new user account when created using remote authentication. (Requires `REMOTE_AUTH_ENABLED`.) +A mapping of permissions to assign a new user account when created using remote authentication. Each key in the dictionary should be set to a dictionary of the attributes to be applied to the permission, or `None` to allow all objects. (Requires `REMOTE_AUTH_ENABLED`.) --- diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index 1522e62682..4e9078a9a4 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -112,18 +112,18 @@ def configure_user(self, request, user): # Assign default object permissions to the user permissions_list = [] - for permission_name in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS: + for permission_name, attrs in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS.items(): try: content_type, action = resolve_permission(permission_name) # TODO: Merge multiple actions into a single ObjectPermission per content type - obj_perm = ObjectPermission(actions=[action]) + obj_perm = ObjectPermission(actions=[action], attrs=attrs) obj_perm.save() obj_perm.users.add(user) obj_perm.content_types.add(content_type) permissions_list.append(permission_name) except ValueError: logging.error( - "Invalid permission name: '{permission_name}'. Permissions must be in the form " + f"Invalid permission name: '{permission_name}'. Permissions must be in the form " "._. (Example: dcim.add_site)" ) if permissions_list: diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 0803efb2ae..7b39fb19e5 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -209,7 +209,7 @@ REMOTE_AUTH_HEADER = 'HTTP_REMOTE_USER' REMOTE_AUTH_AUTO_CREATE_USER = True REMOTE_AUTH_DEFAULT_GROUPS = [] -REMOTE_AUTH_DEFAULT_PERMISSIONS = [] +REMOTE_AUTH_DEFAULT_PERMISSIONS = {} # This determines how often the GitHub API is called to check the latest release of NetBox. Must be at least 1 hour. RELEASE_CHECK_TIMEOUT = 24 * 3600 diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 6199ede276..6923822622 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -99,7 +99,7 @@ REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False) REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend') REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', []) -REMOTE_AUTH_DEFAULT_PERMISSIONS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_PERMISSIONS', []) +REMOTE_AUTH_DEFAULT_PERMISSIONS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_PERMISSIONS', {}) REMOTE_AUTH_ENABLED = getattr(configuration, 'REMOTE_AUTH_ENABLED', False) REMOTE_AUTH_HEADER = getattr(configuration, 'REMOTE_AUTH_HEADER', 'HTTP_REMOTE_USER') RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None) @@ -127,6 +127,17 @@ if RELEASE_CHECK_TIMEOUT < 3600: raise ImproperlyConfigured("RELEASE_CHECK_TIMEOUT has to be at least 3600 seconds (1 hour)") +# TODO: Remove in v2.10 +# Backward compatibility for REMOTE_AUTH_DEFAULT_PERMISSIONS +if type(REMOTE_AUTH_DEFAULT_PERMISSIONS) is not dict: + try: + REMOTE_AUTH_DEFAULT_PERMISSIONS = {perm: None for perm in REMOTE_AUTH_DEFAULT_PERMISSIONS} + warnings.warn( + "REMOTE_AUTH_DEFAULT_PERMISSIONS should be a dictionary. Backward compatibility will be removed in v2.10." + ) + except TypeError: + raise ImproperlyConfigured("REMOTE_AUTH_DEFAULT_PERMISSIONS must be a dictionary.") + # # Database From 32620dd5563507877522c6072055920d9c0ae5b8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Jun 2020 16:30:20 -0400 Subject: [PATCH 085/101] Changelog for #554 --- docs/release-notes/version-2.9.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 docs/release-notes/version-2.9.md diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md new file mode 100644 index 0000000000..b6cc699d41 --- /dev/null +++ b/docs/release-notes/version-2.9.md @@ -0,0 +1,13 @@ +# NetBox v2.8 + +## v2.9.0 (FUTURE) + +### New Features + +#### Object-Based Permissions ([#554](https://github.com/netbox-community/netbox/issues/554)) + +NetBox v2.9 replaces Django's built-in permissions framework with one that supports object-based assignment of permissions using arbitrary constraints. When granting a user or group to perform a certain action on one or more types of objects, an administrator can optionally specify a set of attributes. The permission will apply only to objects which match the specified attributes. For example, assigning permission to modify devices with the attribute filter `{"tenant__group__name": "Customers"}` would grant the permission only for devices assigned to a tenant belonging to the "Customers" group. + +### Configuration Changes + +* `REMOTE_AUTH_DEFAULT_PERMISSIONS` now takes a dictionary rather than a list. This is a mapping of permission names to a dictionary of constraining attributes, or `None`. For example, `['dcim.add_site', 'dcim.change_site']` would become `{'dcim.add_site': None, 'dcim.change_site': None}`. From 7b01ba9776fc06d91f0465e57b0c46efbb605542 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Jun 2020 16:46:14 -0400 Subject: [PATCH 086/101] Fix external auth permissions test --- netbox/netbox/tests/test_authentication.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index bef8f004a5..afeed22633 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -144,7 +144,7 @@ def test_remote_auth_default_groups(self): @override_settings( REMOTE_AUTH_ENABLED=True, REMOTE_AUTH_AUTO_CREATE_USER=True, - REMOTE_AUTH_DEFAULT_PERMISSIONS=['dcim.add_site', 'dcim.change_site'], + REMOTE_AUTH_DEFAULT_PERMISSIONS={'dcim.add_site': None, 'dcim.change_site': None}, LOGIN_REQUIRED=True ) def test_remote_auth_default_permissions(self): @@ -158,7 +158,7 @@ def test_remote_auth_default_permissions(self): self.assertTrue(settings.REMOTE_AUTH_ENABLED) self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER) self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_REMOTE_USER') - self.assertEqual(settings.REMOTE_AUTH_DEFAULT_PERMISSIONS, ['dcim.add_site', 'dcim.change_site']) + self.assertEqual(settings.REMOTE_AUTH_DEFAULT_PERMISSIONS, {'dcim.add_site': None, 'dcim.change_site': None}) response = self.client.get(reverse('home'), follow=True, **headers) self.assertEqual(response.status_code, 200) From 85e932bfc1e6ad62c3e09e0b5a354c7c69597830 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 2 Jun 2020 09:26:45 -0400 Subject: [PATCH 087/101] Clean up permissions utility functions --- netbox/netbox/authentication.py | 7 +++---- netbox/utilities/permissions.py | 20 +++++++++++--------- netbox/utilities/testing/testcases.py | 4 ++-- netbox/utilities/views.py | 6 +++--- 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index 4e9078a9a4..bf1f96edb4 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -6,7 +6,7 @@ from django.db.models import Q from users.models import ObjectPermission -from utilities.permissions import permission_is_exempt, resolve_permission +from utilities.permissions import permission_is_exempt, resolve_permission, resolve_permission_ct class ObjectPermissionBackend(ModelBackend): @@ -42,8 +42,7 @@ def get_object_permissions(self, user_obj): return perms def has_perm(self, user_obj, perm, obj=None): - app_label, codename = perm.split('.') - action, model_name = codename.split('_') + app_label, action, model_name = resolve_permission(perm) # Superusers implicitly have all permissions if user_obj.is_active and user_obj.is_superuser: @@ -114,7 +113,7 @@ def configure_user(self, request, user): permissions_list = [] for permission_name, attrs in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS.items(): try: - content_type, action = resolve_permission(permission_name) + content_type, action = resolve_permission_ct(permission_name) # TODO: Merge multiple actions into a single ObjectPermission per content type obj_perm = ObjectPermission(actions=[action], attrs=attrs) obj_perm.save() diff --git a/netbox/utilities/permissions.py b/netbox/utilities/permissions.py index 38064b6894..44c34942fc 100644 --- a/netbox/utilities/permissions.py +++ b/netbox/utilities/permissions.py @@ -19,33 +19,36 @@ def get_permission_for_model(model, action): ) -def get_permission_action(name): +def resolve_permission(name): """ - Return the action component (e.g. view or add) from a permission name. + Given a permission name, return the app_label, action, and model_name components. For example, "dcim.view_site" + returns ("dcim", "view", "site"). :param name: Permission name in the format ._ """ try: - return name.split('.')[1].split('_')[0] + app_label, codename = name.split('.') + action, model_name = codename.rsplit('_', 1) except ValueError: raise ValueError( f"Invalid permission name: {name}. Must be in the format ._" ) + return app_label, action, model_name -def resolve_permission(name): + +def resolve_permission_ct(name): """ Given a permission name, return the relevant ContentType and action. For example, "dcim.view_site" returns (Site, "view"). :param name: Permission name in the format ._ """ - app_label, codename = name.split('.') - action, model_name = codename.split('_') + app_label, action, model_name = resolve_permission(name) try: content_type = ContentType.objects.get(app_label=app_label, model=model_name) except ContentType.DoesNotExist: - raise ValueError(f"Unknown app/model for {name}") + raise ValueError(f"Unknown app_label/model_name for {name}") return content_type, action @@ -56,8 +59,7 @@ def permission_is_exempt(name): :param name: Permission name in the format ._ """ - app_label, codename = name.split('.') - action, model_name = codename.split('_') + app_label, action, model_name = resolve_permission(name) if action == 'view': if ( diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py index 3514f90605..2ef5a19fe1 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -7,7 +7,7 @@ from rest_framework.test import APIClient from users.models import ObjectPermission, Token -from utilities.permissions import resolve_permission +from utilities.permissions import resolve_permission_ct from .utils import disable_warnings, post_data @@ -33,7 +33,7 @@ def add_permissions(self, *names): Assign a set of permissions to the test user. Accepts permission names in the form ._. """ for name in names: - ct, action = resolve_permission(name) + ct, action = resolve_permission_ct(name) obj_perm = ObjectPermission(actions=[action]) obj_perm.save() obj_perm.users.add(self.user) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 0304780f3c..e4161077cd 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -28,7 +28,7 @@ from extras.querysets import CustomFieldQueryset from utilities.exceptions import AbortTransaction from utilities.forms import BootstrapMixin, CSVDataField, TableConfigForm -from utilities.permissions import get_permission_action, get_permission_for_model +from utilities.permissions import get_permission_for_model, resolve_permission from utilities.utils import csv_format, prepare_cloned_fields from .error_handlers import handle_protectederror from .forms import ConfirmationForm, ImportForm @@ -64,7 +64,7 @@ def has_permission(self): if user.has_perms((permission_required, *self.additional_permissions)): # Update the view's QuerySet to filter only the permitted objects - action = get_permission_action(permission_required) + action = resolve_permission(permission_required)[1] self.queryset = self.queryset.restrict(user, action) return True @@ -233,7 +233,7 @@ def get(self, request): # Compile a dictionary indicating which permissions are available to the current user for this model permissions = {} for action in ('add', 'change', 'delete', 'view'): - perm_name = '{}.{}_{}'.format(model._meta.app_label, action, model._meta.model_name) + perm_name = get_permission_for_model(model, action) permissions[action] = request.user.has_perm(perm_name) # Construct the table based on the user's permissions From 110bad7041abff297b2202cad2bef9fcd16e52e4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 2 Jun 2020 09:36:45 -0400 Subject: [PATCH 088/101] Update custom napalm_read, napalm_write permissions --- netbox/dcim/api/views.py | 2 +- netbox/dcim/migrations/0041_napalm_integration.py | 2 +- netbox/dcim/migrations/0089_deterministic_ordering.py | 2 +- netbox/dcim/migrations/0095_primary_model_ordering.py | 2 +- netbox/dcim/models/__init__.py | 4 ---- netbox/dcim/views.py | 6 +++--- netbox/templates/dcim/device.html | 2 +- 7 files changed, 8 insertions(+), 12 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 9c8fe12def..3abfddbc24 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -395,7 +395,7 @@ def napalm(self, request, pk): )) # Verify user permission - if not request.user.has_perm('dcim.napalm_read'): + if not request.user.has_perm('dcim.napalm_read_device'): return HttpResponseForbidden() # Connect to the device diff --git a/netbox/dcim/migrations/0041_napalm_integration.py b/netbox/dcim/migrations/0041_napalm_integration.py index 50c2fbd99c..3acad9f0b9 100644 --- a/netbox/dcim/migrations/0041_napalm_integration.py +++ b/netbox/dcim/migrations/0041_napalm_integration.py @@ -22,7 +22,7 @@ class Migration(migrations.Migration): operations = [ migrations.AlterModelOptions( name='device', - options={'ordering': ['name'], 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))}, + options={'ordering': ['name']}, ), migrations.AddField( model_name='platform', diff --git a/netbox/dcim/migrations/0089_deterministic_ordering.py b/netbox/dcim/migrations/0089_deterministic_ordering.py index 6944cff003..77d18739ea 100644 --- a/netbox/dcim/migrations/0089_deterministic_ordering.py +++ b/netbox/dcim/migrations/0089_deterministic_ordering.py @@ -12,7 +12,7 @@ class Migration(migrations.Migration): operations = [ migrations.AlterModelOptions( name='device', - options={'ordering': ('name', 'pk'), 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))}, + options={'ordering': ('name', 'pk')}, ), migrations.AlterModelOptions( name='rack', diff --git a/netbox/dcim/migrations/0095_primary_model_ordering.py b/netbox/dcim/migrations/0095_primary_model_ordering.py index 3bc780161a..6225a9b737 100644 --- a/netbox/dcim/migrations/0095_primary_model_ordering.py +++ b/netbox/dcim/migrations/0095_primary_model_ordering.py @@ -30,7 +30,7 @@ class Migration(migrations.Migration): operations = [ migrations.AlterModelOptions( name='device', - options={'ordering': ('_name', 'pk'), 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))}, + options={'ordering': ('_name', 'pk')}, ), migrations.AlterModelOptions( name='rack', diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 3dd3b8c89b..4d18509a96 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -1477,10 +1477,6 @@ class Meta: ('rack', 'position', 'face'), ('virtual_chassis', 'vc_position'), ) - permissions = ( - ('napalm_read', 'Read-only access to devices via NAPALM'), - ('napalm_write', 'Read/write access to devices via NAPALM'), - ) def __str__(self): return self.display_name or super().__str__() diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 0f4297fd63..2508590d97 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1099,7 +1099,7 @@ def get(self, request, pk): class DeviceStatusView(ObjectView): - additional_permissions = ['dcim.napalm_read'] + additional_permissions = ['dcim.napalm_read_device'] queryset = Device.objects.all() def get(self, request, pk): @@ -1113,7 +1113,7 @@ def get(self, request, pk): class DeviceLLDPNeighborsView(ObjectView): - additional_permissions = ['dcim.napalm_read'] + additional_permissions = ['dcim.napalm_read_device'] queryset = Device.objects.all() def get(self, request, pk): @@ -1133,7 +1133,7 @@ def get(self, request, pk): class DeviceConfigView(ObjectView): - additional_permissions = ['dcim.napalm_read'] + additional_permissions = ['dcim.napalm_read_device'] queryset = Device.objects.all() def get(self, request, pk): diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index ef1a301e20..a42250a3d4 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -101,7 +101,7 @@

{{ device }}

Inventory {{ device.inventory_items.count }} - {% if perms.dcim.napalm_read %} + {% if perms.dcim.napalm_read_device %} {% if device.status != 'active' %} {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='Device must be in active status' %} {% elif not device.platform %} From c6e85970d479ba570ddd1f730d4cf1e82bfddaa0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 2 Jun 2020 09:47:31 -0400 Subject: [PATCH 089/101] Remove activate_userkey permission --- docs/release-notes/version-2.9.md | 4 ++++ netbox/secrets/admin.py | 2 +- netbox/secrets/migrations/0001_initial.py | 1 - netbox/secrets/models.py | 3 --- netbox/users/migrations/0009_replicate_permissions.py | 5 ++++- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index b6cc699d41..fc16ed6fd7 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -11,3 +11,7 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo ### Configuration Changes * `REMOTE_AUTH_DEFAULT_PERMISSIONS` now takes a dictionary rather than a list. This is a mapping of permission names to a dictionary of constraining attributes, or `None`. For example, `['dcim.add_site', 'dcim.change_site']` would become `{'dcim.add_site': None, 'dcim.change_site': None}`. + +### Other Changes + +* The `secrets.activate_userkey` permission no longer exists. Instead, `secrets.change_userkey` is checked to determine whether a user has the ability to activate a UserKey. diff --git a/netbox/secrets/admin.py b/netbox/secrets/admin.py index 94cd1c7fa4..e111286744 100644 --- a/netbox/secrets/admin.py +++ b/netbox/secrets/admin.py @@ -23,7 +23,7 @@ def get_actions(self, request): actions = super().get_actions(request) if 'delete_selected' in actions: del actions['delete_selected'] - if not request.user.has_perm('secrets.activate_userkey'): + if not request.user.has_perm('secrets.change_userkey'): del actions['activate_selected'] return actions diff --git a/netbox/secrets/migrations/0001_initial.py b/netbox/secrets/migrations/0001_initial.py index 1281a266a8..3664bae63c 100644 --- a/netbox/secrets/migrations/0001_initial.py +++ b/netbox/secrets/migrations/0001_initial.py @@ -56,7 +56,6 @@ class Migration(migrations.Migration): ], options={ 'ordering': ['user__username'], - 'permissions': (('activate_userkey', 'Can activate user keys for decryption'),), }, ), migrations.AddField( diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 757ef88c76..bf5858ff8f 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -64,9 +64,6 @@ class UserKey(models.Model): class Meta: ordering = ['user__username'] - permissions = ( - ('activate_userkey', "Can activate user keys for decryption"), - ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/netbox/users/migrations/0009_replicate_permissions.py b/netbox/users/migrations/0009_replicate_permissions.py index c5e4d364c5..66084c3be1 100644 --- a/netbox/users/migrations/0009_replicate_permissions.py +++ b/netbox/users/migrations/0009_replicate_permissions.py @@ -14,9 +14,12 @@ def replicate_permissions(apps, schema_editor): # TODO: Optimize this iteration so that ObjectPermissions with identical sets of users and groups # are combined into a single ObjectPermission instance. for perm in Permission.objects.all(): - # Account for non-standard permission names; e.g. napalm_read if perm.codename.split('_')[0] in ACTIONS: + # Account for non-standard legacy permission names; e.g. napalm_read action = perm.codename.split('_')[0] + elif perm.codename == 'activate_userkey': + # Rename activate_userkey permission + action = 'change' else: action = perm.codename From 7a7634de2d80726df9a5593ac8cb7306387ae7fa Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 2 Jun 2020 10:50:58 -0400 Subject: [PATCH 090/101] Accomodate custom legacy permission in schema migration --- netbox/extras/migrations/0024_scripts.py | 1 - netbox/extras/models/models.py | 3 --- netbox/users/migrations/0009_replicate_permissions.py | 4 ++-- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/netbox/extras/migrations/0024_scripts.py b/netbox/extras/migrations/0024_scripts.py index 82d0afdc9b..c8d81e5e27 100644 --- a/netbox/extras/migrations/0024_scripts.py +++ b/netbox/extras/migrations/0024_scripts.py @@ -16,7 +16,6 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), ], options={ - 'permissions': (('run_script', 'Can run script'),), 'managed': False, }, ), diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index a94fc3eea8..9e000774f3 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -564,9 +564,6 @@ class Script(models.Model): """ class Meta: managed = False - permissions = ( - ('run_script', 'Can run script'), - ) # diff --git a/netbox/users/migrations/0009_replicate_permissions.py b/netbox/users/migrations/0009_replicate_permissions.py index 66084c3be1..b25698a366 100644 --- a/netbox/users/migrations/0009_replicate_permissions.py +++ b/netbox/users/migrations/0009_replicate_permissions.py @@ -15,11 +15,11 @@ def replicate_permissions(apps, schema_editor): # are combined into a single ObjectPermission instance. for perm in Permission.objects.all(): if perm.codename.split('_')[0] in ACTIONS: - # Account for non-standard legacy permission names; e.g. napalm_read action = perm.codename.split('_')[0] elif perm.codename == 'activate_userkey': - # Rename activate_userkey permission action = 'change' + elif perm.codename == 'run_script': + action = 'run' else: action = perm.codename From a62b98ac506aa45d72c7879ac7c01fff556cad30 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 2 Jun 2020 13:21:00 -0400 Subject: [PATCH 091/101] Admin UI improvements --- netbox/users/admin.py | 34 ++++++++++++++++--- .../users/migrations/0007_proxy_group_user.py | 2 ++ netbox/users/models.py | 7 +++- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/netbox/users/admin.py b/netbox/users/admin.py index 4c3da5acdb..80b7affaf8 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -35,20 +35,42 @@ class UserConfigInline(admin.TabularInline): verbose_name = 'Preferences' +class ObjectPermissionInline(admin.TabularInline): + model = AdminUser.object_permissions.through + fields = ['content_types', 'actions', 'attrs'] + readonly_fields = fields + extra = 0 + verbose_name = 'Permission' + + def content_types(self, instance): + return ', '.join(instance.objectpermission.content_types.values_list('model', flat=True)) + + def actions(self, instance): + return ', '.join(instance.objectpermission.actions) + + def attrs(self, instance): + return instance.objectpermission.attrs + + def has_add_permission(self, request, obj): + # Don't allow the creation of new ObjectPermission assignments via this form + return False + + @admin.register(AdminUser) class UserAdmin(UserAdmin_): list_display = [ 'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active' ] fieldsets = ( - (None, {'fields': ('username', 'password')}), - ('Personal info', {'fields': ('first_name', 'last_name', 'email')}), + (None, {'fields': ('username', 'password', 'first_name', 'last_name', 'email')}), + ('Groups', {'fields': ('groups',)}), ('Permissions', { 'fields': ('is_active', 'is_staff', 'is_superuser'), }), ('Important dates', {'fields': ('last_login', 'date_joined')}), ) - inlines = (UserConfigInline,) + inlines = [ObjectPermissionInline, UserConfigInline] + filter_horizontal = ('groups',) # @@ -154,7 +176,7 @@ class ObjectPermissionAdmin(admin.ModelAdmin): 'fields': ('content_types',) }), ('Assignment', { - 'fields': (('groups', 'users'),) + 'fields': ('groups', 'users') }), ('Actions', { 'fields': (('can_view', 'can_add', 'can_change', 'can_delete'), 'actions') @@ -163,10 +185,14 @@ class ObjectPermissionAdmin(admin.ModelAdmin): 'fields': ('attrs',) }), ) + filter_horizontal = ('content_types', 'groups', 'users') form = ObjectPermissionForm list_display = [ 'list_models', 'list_users', 'list_groups', 'actions', 'attrs', ] + list_filter = [ + 'groups', 'users' + ] def get_queryset(self, request): return super().get_queryset(request).prefetch_related('content_types', 'users', 'groups') diff --git a/netbox/users/migrations/0007_proxy_group_user.py b/netbox/users/migrations/0007_proxy_group_user.py index dfd0512bde..2aec9e425e 100644 --- a/netbox/users/migrations/0007_proxy_group_user.py +++ b/netbox/users/migrations/0007_proxy_group_user.py @@ -21,6 +21,7 @@ class Migration(migrations.Migration): 'proxy': True, 'indexes': [], 'constraints': [], + 'verbose_name': 'Group', }, bases=('auth.group',), managers=[ @@ -35,6 +36,7 @@ class Migration(migrations.Migration): 'proxy': True, 'indexes': [], 'constraints': [], + 'verbose_name': 'User', }, bases=('auth.user',), managers=[ diff --git a/netbox/users/models.py b/netbox/users/models.py index 1c8775699d..9dde9d0091 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -29,6 +29,7 @@ class AdminGroup(Group): Proxy contrib.auth.models.Group for the admin UI """ class Meta: + verbose_name = 'Group' proxy = True @@ -37,6 +38,7 @@ class AdminUser(User): Proxy contrib.auth.models.User for the admin UI """ class Meta: + verbose_name = 'User' proxy = True @@ -264,4 +266,7 @@ class Meta: verbose_name = "Permission" def __str__(self): - return "Object permission" + return '{}: {}'.format( + ', '.join(self.content_types.values_list('model', flat=True)), + ', '.join(self.actions) + ) From cae412d280fe876d0513af2aec8334f3020565cf Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 2 Jun 2020 14:19:08 -0400 Subject: [PATCH 092/101] Update ObjectImportView to support ObjectPermissions --- netbox/dcim/views.py | 6 +++--- netbox/utilities/views.py | 34 +++++++++++++++++++++++++++++----- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 2508590d97..b3b99d8048 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -627,8 +627,8 @@ class DeviceTypeDeleteView(ObjectDeleteView): default_return_url = 'dcim:devicetype_list' -class DeviceTypeImportView(PermissionRequiredMixin, ObjectImportView): - permission_required = [ +class DeviceTypeImportView(ObjectImportView): + additional_permissions = [ 'dcim.add_devicetype', 'dcim.add_consoleporttemplate', 'dcim.add_consoleserverporttemplate', @@ -639,7 +639,7 @@ class DeviceTypeImportView(PermissionRequiredMixin, ObjectImportView): 'dcim.add_rearporttemplate', 'dcim.add_devicebaytemplate', ] - model = DeviceType + queryset = DeviceType.objects.all() model_form = forms.DeviceTypeImportForm related_object_forms = OrderedDict(( ('console-ports', forms.ConsolePortTemplateImportForm), diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index e4161077cd..e448f2934c 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -571,21 +571,29 @@ def post(self, request): }) -class ObjectImportView(GetReturnURLMixin, View): +class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Import a single object (YAML or JSON format). + + queryset: Base queryset for the objects being created + model_form: The ModelForm used to create individual objects + related_object_forms: A dictionary mapping of forms to be used for the creation of related (child) objects + template_name: The name of the template """ - model = None + queryset = None model_form = None related_object_forms = dict() template_name = 'utilities/obj_import.html' + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'add') + def get(self, request): form = ImportForm() return render(request, self.template_name, { 'form': form, - 'obj_type': self.model._meta.verbose_name, + 'obj_type': self.queryset.model._meta.verbose_name, 'return_url': self.get_return_url(request), }) @@ -615,12 +623,17 @@ def post(self, request): # Save the primary object obj = model_form.save() + + # Enforce object-level permissions + self.queryset.get(pk=obj.pk) + logger.debug(f"Created {obj} (PK: {obj.pk})") # Iterate through the related object forms (if any), validating and saving each instance. for field_name, related_object_form in self.related_object_forms.items(): logger.debug("Processing form for related objects: {related_object_form}") + related_obj_pks = [] for i, rel_obj_data in enumerate(data.get(field_name, list())): f = related_object_form(obj, rel_obj_data) @@ -630,7 +643,8 @@ def post(self, request): f.data[subfield_name] = field.initial if f.is_valid(): - f.save() + related_obj = f.save() + related_obj_pks.append(related_obj.pk) else: # Replicate errors on the related object form to the primary form for display for subfield_name, errors in f.errors.items(): @@ -639,9 +653,19 @@ def post(self, request): model_form.add_error(None, err_msg) raise AbortTransaction() + # Enforce object-level permissions on related objects + model = related_object_form.Meta.model + if model.objects.filter(pk__in=related_obj_pks).count() != len(related_obj_pks): + raise ObjectDoesNotExist + except AbortTransaction: pass + except ObjectDoesNotExist: + msg = "Object creation failed due to object-level permissions violation" + logger.debug(msg) + form.add_error(None, msg) + if not model_form.errors: logger.info(f"Import object {obj} (PK: {obj.pk})") messages.success(request, mark_safe('Imported object: {}'.format( @@ -673,7 +697,7 @@ def post(self, request): return render(request, self.template_name, { 'form': form, - 'obj_type': self.model._meta.verbose_name, + 'obj_type': self.queryset.model._meta.verbose_name, 'return_url': self.get_return_url(request), }) From e463430d51567dd2111cf46e6dc7d03e5e24b73c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 2 Jun 2020 15:15:57 -0400 Subject: [PATCH 093/101] Change CableCreateView to use ObjectEditView --- netbox/dcim/views.py | 73 +++++++++++++-------------------------- netbox/utilities/views.py | 2 +- 2 files changed, 25 insertions(+), 50 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index b3b99d8048..12f7a50468 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1904,23 +1904,15 @@ def get(self, request, pk): }) -class CableCreateView(PermissionRequiredMixin, GetReturnURLMixin, View): - permission_required = 'dcim.add_cable' +class CableCreateView(ObjectEditView): + queryset = Cable.objects.all() template_name = 'dcim/cable_connect.html' + default_return_url = 'dcim:cable_list' def dispatch(self, request, *args, **kwargs): - termination_a_type = kwargs.get('termination_a_type') - termination_a_id = kwargs.get('termination_a_id') - - termination_b_type_name = kwargs.get('termination_b_type') - self.termination_b_type = ContentType.objects.get(model=termination_b_type_name.replace('-', '')) - - self.obj = Cable( - termination_a=termination_a_type.objects.get(pk=termination_a_id), - termination_b_type=self.termination_b_type - ) - self.form_class = { + # Set the model_form class based on the type of component being connected + self.model_form = { 'console-port': forms.ConnectCableToConsolePortForm, 'console-server-port': forms.ConnectCableToConsoleServerPortForm, 'power-port': forms.ConnectCableToPowerPortForm, @@ -1930,59 +1922,42 @@ def dispatch(self, request, *args, **kwargs): 'rear-port': forms.ConnectCableToRearPortForm, 'power-feed': forms.ConnectCableToPowerFeedForm, 'circuit-termination': forms.ConnectCableToCircuitTerminationForm, - }[termination_b_type_name] + }[kwargs.get('termination_b_type')] return super().dispatch(request, *args, **kwargs) + def alter_obj(self, obj, request, url_args, url_kwargs): + termination_a_type = url_kwargs.get('termination_a_type') + termination_a_id = url_kwargs.get('termination_a_id') + termination_b_type_name = url_kwargs.get('termination_b_type') + self.termination_b_type = ContentType.objects.get(model=termination_b_type_name.replace('-', '')) + + # Initialize Cable termination attributes + obj.termination_a = termination_a_type.objects.get(pk=termination_a_id) + obj.termination_b_type = self.termination_b_type + + return obj + def get(self, request, *args, **kwargs): + obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs) # Parse initial data manually to avoid setting field values as lists initial_data = {k: request.GET[k] for k in request.GET} # Set initial site and rack based on side A termination (if not already set) if 'termination_b_site' not in initial_data: - initial_data['termination_b_site'] = getattr(self.obj.termination_a.parent, 'site', None) + initial_data['termination_b_site'] = getattr(obj.termination_a.parent, 'site', None) if 'termination_b_rack' not in initial_data: - initial_data['termination_b_rack'] = getattr(self.obj.termination_a.parent, 'rack', None) - - form = self.form_class(instance=self.obj, initial=initial_data) - - return render(request, self.template_name, { - 'obj': self.obj, - 'obj_type': Cable._meta.verbose_name, - 'termination_b_type': self.termination_b_type.name, - 'form': form, - 'return_url': self.get_return_url(request, self.obj), - }) - - def post(self, request, *args, **kwargs): + initial_data['termination_b_rack'] = getattr(obj.termination_a.parent, 'rack', None) - form = self.form_class(request.POST, request.FILES, instance=self.obj) - - if form.is_valid(): - obj = form.save() - - msg = 'Created cable {}'.format( - obj.get_absolute_url(), - escape(obj) - ) - messages.success(request, mark_safe(msg)) - - if '_addanother' in request.POST: - 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)) + form = self.model_form(instance=obj, initial=initial_data) return render(request, self.template_name, { - 'obj': self.obj, + 'obj': obj, 'obj_type': Cable._meta.verbose_name, 'termination_b_type': self.termination_b_type.name, 'form': form, - 'return_url': self.get_return_url(request, self.obj), + 'return_url': self.get_return_url(request, obj), }) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index e448f2934c..9271e1c64f 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -346,7 +346,7 @@ def post(self, request, *args, **kwargs): form = self.model_form( data=request.POST, files=request.FILES, - instance=self.alter_obj(self.get_object(kwargs), request, args, kwargs) + instance=obj ) if form.is_valid(): From 205acd2c4d8ebaf83eeed38b998377bb59b70ac7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 2 Jun 2020 15:33:41 -0400 Subject: [PATCH 094/101] Update VirtualChassis views to support ObjectPermissions --- netbox/dcim/views.py | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 12f7a50468..de2bf80e50 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -3,7 +3,6 @@ from django.conf import settings from django.contrib import messages -from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.contenttypes.models import ContentType from django.core.paginator import EmptyPage, PageNotAnInteger from django.db import transaction @@ -12,7 +11,6 @@ from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.html import escape -from django.utils.http import is_safe_url from django.utils.safestring import mark_safe from django.views.generic import View @@ -2169,8 +2167,11 @@ def get(self, request, pk): }) -class VirtualChassisCreateView(PermissionRequiredMixin, View): - permission_required = 'dcim.add_virtualchassis' +class VirtualChassisCreateView(ObjectPermissionRequiredMixin, View): + queryset = VirtualChassis.objects.all() + + def get_required_permission(self): + return 'dcim.add_virtualchassis' def post(self, request): @@ -2224,8 +2225,11 @@ def post(self, request): }) -class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View): - permission_required = 'dcim.change_virtualchassis' +class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, View): + queryset = VirtualChassis.objects.all() + + def get_required_permission(self): + return 'dcim.change_virtualchassis' def get(self, request, pk): @@ -2294,12 +2298,15 @@ class VirtualChassisDeleteView(ObjectDeleteView): default_return_url = 'dcim:device_list' -class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, View): - permission_required = 'dcim.change_virtualchassis' +class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMixin, View): + queryset = VirtualChassis.objects.all() + + def get_required_permission(self): + return 'dcim.change_virtualchassis' def get(self, request, pk): - virtual_chassis = get_object_or_404(VirtualChassis, pk=pk) + virtual_chassis = get_object_or_404(self.queryset, pk=pk) initial_data = {k: request.GET[k] for k in request.GET} member_select_form = forms.VCMemberSelectForm(initial=initial_data) @@ -2314,7 +2321,7 @@ def get(self, request, pk): def post(self, request, pk): - virtual_chassis = get_object_or_404(VirtualChassis, pk=pk) + virtual_chassis = get_object_or_404(self.queryset, pk=pk) member_select_form = forms.VCMemberSelectForm(request.POST) @@ -2348,12 +2355,15 @@ def post(self, request, pk): }) -class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin, View): - permission_required = 'dcim.change_virtualchassis' +class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURLMixin, View): + queryset = Device.objects.all() + + def get_required_permission(self): + return 'dcim.change_device' def get(self, request, pk): - device = get_object_or_404(Device, pk=pk, virtual_chassis__isnull=False) + device = get_object_or_404(self.queryset, pk=pk, virtual_chassis__isnull=False) form = ConfirmationForm(initial=request.GET) return render(request, 'dcim/virtualchassis_remove_member.html', { @@ -2364,7 +2374,7 @@ def get(self, request, pk): def post(self, request, pk): - device = get_object_or_404(Device, pk=pk, virtual_chassis__isnull=False) + device = get_object_or_404(self.queryset, pk=pk, virtual_chassis__isnull=False) form = ConfirmationForm(request.POST) # Protect master device from being removed From 3502398d1d90546f4fb56c678f591599ce0893c3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 2 Jun 2020 15:36:31 -0400 Subject: [PATCH 095/101] Remove delete_token permission from TokenDeleteView --- docs/release-notes/version-2.9.md | 1 + netbox/users/views.py | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index fc16ed6fd7..4fda77838d 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -15,3 +15,4 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo ### Other Changes * The `secrets.activate_userkey` permission no longer exists. Instead, `secrets.change_userkey` is checked to determine whether a user has the ability to activate a UserKey. +* The `users.delete_token` permission is no longer enforced. All users are permitted to delete their own API tokens. diff --git a/netbox/users/views.py b/netbox/users/views.py index c3e3665426..f88ff040cd 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -3,7 +3,7 @@ from django.conf import settings from django.contrib import messages from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash -from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin +from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import update_last_login from django.contrib.auth.signals import user_logged_in from django.http import HttpResponseForbidden, HttpResponseRedirect @@ -320,8 +320,7 @@ def post(self, request, pk=None): }) -class TokenDeleteView(PermissionRequiredMixin, View): - permission_required = 'users.delete_token' +class TokenDeleteView(LoginRequiredMixin, View): def get(self, request, pk): From 19407ba3bc7104b1d88c4efa1f997b469f7c8616 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 2 Jun 2020 15:40:39 -0400 Subject: [PATCH 096/101] Uodate script and report views to use ObjectPermissionRequiredMixin --- netbox/extras/views.py | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/netbox/extras/views.py b/netbox/extras/views.py index a607a4df81..e80aa1d62e 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,7 +1,6 @@ from django import template from django.conf import settings from django.contrib import messages -from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.contenttypes.models import ContentType from django.db.models import Count, Q from django.http import Http404, HttpResponseForbidden @@ -13,7 +12,10 @@ from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator from utilities.utils import shallow_compare_dict -from utilities.views import BulkDeleteView, BulkEditView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView +from utilities.views import ( + BulkDeleteView, BulkEditView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView, + ObjectPermissionRequiredMixin, +) from . import filters, forms from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem from .reports import get_report, get_reports @@ -324,11 +326,12 @@ def get_return_url(self, request, imageattachment): # Reports # -class ReportListView(PermissionRequiredMixin, View): +class ReportListView(ObjectPermissionRequiredMixin, View): """ Retrieve all of the available reports from disk and the recorded ReportResult (if any) for each. """ - permission_required = 'extras.view_reportresult' + def get_required_permission(self): + return 'extras.view_reportresult' def get(self, request): @@ -348,11 +351,12 @@ def get(self, request): }) -class ReportView(PermissionRequiredMixin, View): +class ReportView(ObjectPermissionRequiredMixin, View): """ Display a single Report and its associated ReportResult (if any). """ - permission_required = 'extras.view_reportresult' + def get_required_permission(self): + return 'extras.view_reportresult' def get(self, request, name): @@ -371,11 +375,12 @@ def get(self, request, name): }) -class ReportRunView(PermissionRequiredMixin, View): +class ReportRunView(ObjectPermissionRequiredMixin, View): """ Run a Report and record a new ReportResult. """ - permission_required = 'extras.add_reportresult' + def get_required_permission(self): + return 'extras.add_reportresult' def post(self, request, name): @@ -401,8 +406,10 @@ def post(self, request, name): # Scripts # -class ScriptListView(PermissionRequiredMixin, View): - permission_required = 'extras.view_script' +class ScriptListView(ObjectPermissionRequiredMixin, View): + + def get_required_permission(self): + return 'extras.view_script' def get(self, request): @@ -411,8 +418,10 @@ def get(self, request): }) -class ScriptView(PermissionRequiredMixin, View): - permission_required = 'extras.view_script' +class ScriptView(ObjectPermissionRequiredMixin, View): + + def get_required_permission(self): + return 'extras.view_script' def _get_script(self, module, name): scripts = get_scripts() From ddcd172af130d0ebd529b55a297cd7824283b72f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 3 Jun 2020 09:27:20 -0400 Subject: [PATCH 097/101] Rename content_types to object_types --- netbox/netbox/authentication.py | 10 +++--- netbox/netbox/tests/test_authentication.py | 26 +++++++------- netbox/users/admin.py | 22 ++++++------ .../users/migrations/0008_objectpermission.py | 2 +- .../migrations/0009_replicate_permissions.py | 2 +- netbox/users/models.py | 4 +-- netbox/utilities/testing/testcases.py | 36 +++++++++---------- 7 files changed, 51 insertions(+), 51 deletions(-) diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index bf1f96edb4..a219c1498f 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -26,14 +26,14 @@ def get_object_permissions(self, user_obj): object_permissions = ObjectPermission.objects.filter( Q(users=user_obj) | Q(groups__user=user_obj) - ).prefetch_related('content_types') + ).prefetch_related('object_types') # Create a dictionary mapping permissions to their attributes perms = dict() for obj_perm in object_permissions: - for content_type in obj_perm.content_types.all(): + for object_type in obj_perm.object_types.all(): for action in obj_perm.actions: - perm_name = f"{content_type.app_label}.{action}_{content_type.model}" + perm_name = f"{object_type.app_label}.{action}_{object_type.model}" if perm_name in perms: perms[perm_name].append(obj_perm.attrs) else: @@ -113,12 +113,12 @@ def configure_user(self, request, user): permissions_list = [] for permission_name, attrs in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS.items(): try: - content_type, action = resolve_permission_ct(permission_name) + object_type, action = resolve_permission_ct(permission_name) # TODO: Merge multiple actions into a single ObjectPermission per content type obj_perm = ObjectPermission(actions=[action], attrs=attrs) obj_perm.save() obj_perm.users.add(user) - obj_perm.content_types.add(content_type) + obj_perm.object_types.add(object_type) permissions_list.append(permission_name) except ValueError: logging.error( diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index afeed22633..db63faffdd 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -207,7 +207,7 @@ def test_get_object(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix)) + obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) # Retrieve permitted object response = self.client.get(self.prefixes[0].get_absolute_url()) @@ -231,7 +231,7 @@ def test_list_objects(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix)) + obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) # Retrieve all objects. Only permitted objects should be returned. response = self.client.get(reverse('ipam:prefix_list')) @@ -265,7 +265,7 @@ def test_create_object(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix)) + obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) # Attempt to create a non-permitted object request = { @@ -312,7 +312,7 @@ def test_edit_object(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix)) + obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) # Attempt to edit a non-permitted object request = { @@ -355,7 +355,7 @@ def test_delete_object(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix)) + obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) # Delete permitted object request = { @@ -403,7 +403,7 @@ def test_bulk_import_objects(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix)) + obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) # Attempt to create non-permitted objects request = { @@ -452,7 +452,7 @@ def test_bulk_edit_objects(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix)) + obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) # Attempt to edit non-permitted objects request = { @@ -496,7 +496,7 @@ def test_bulk_delete_objects(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix)) + obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) # Attempt to delete non-permitted object request = { @@ -567,7 +567,7 @@ def test_get_object(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix)) + obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) # Retrieve permitted object url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk}) @@ -594,7 +594,7 @@ def test_list_objects(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix)) + obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) # Retrieve all objects. Only permitted objects should be returned. response = self.client.get(url, **self.header) @@ -621,7 +621,7 @@ def test_create_object(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix)) + obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) # Attempt to create a non-permitted object response = self.client.post(url, data, format='json', **self.header) @@ -650,7 +650,7 @@ def test_edit_object(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix)) + obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) # Attempt to edit a non-permitted object data = {'site': self.sites[0].pk} @@ -685,7 +685,7 @@ def test_delete_object(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix)) + obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) # Attempt to delete a non-permitted object url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[3].pk}) diff --git a/netbox/users/admin.py b/netbox/users/admin.py index 80b7affaf8..80283340df 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -37,13 +37,13 @@ class UserConfigInline(admin.TabularInline): class ObjectPermissionInline(admin.TabularInline): model = AdminUser.object_permissions.through - fields = ['content_types', 'actions', 'attrs'] + fields = ['object_types', 'actions', 'attrs'] readonly_fields = fields extra = 0 verbose_name = 'Permission' - def content_types(self, instance): - return ', '.join(instance.objectpermission.content_types.values_list('model', flat=True)) + def object_types(self, instance): + return ', '.join(instance.objectpermission.object_types.values_list('model', flat=True)) def actions(self, instance): return ', '.join(instance.objectpermission.actions) @@ -127,8 +127,8 @@ def __init__(self, *args, **kwargs): self.fields['actions'].required = False # Format ContentType choices - order_content_types(self.fields['content_types']) - self.fields['content_types'].choices.insert(0, ('', '---------')) + order_content_types(self.fields['object_types']) + self.fields['object_types'].choices.insert(0, ('', '---------')) # Order group and user fields self.fields['groups'].queryset = self.fields['groups'].queryset.order_by('name') @@ -142,7 +142,7 @@ def __init__(self, *args, **kwargs): self.instance.actions.remove(action) def clean(self): - content_types = self.cleaned_data['content_types'] + object_types = self.cleaned_data['object_types'] attrs = self.cleaned_data['attrs'] # Append any of the selected CRUD checkboxes to the actions list @@ -159,7 +159,7 @@ def clean(self): # Validate the specified model attributes by attempting to execute a query. We don't care whether the query # returns anything; we just want to make sure the specified attributes are valid. if attrs: - for ct in content_types: + for ct in object_types: model = ct.model_class() try: model.objects.filter(**attrs).exists() @@ -173,7 +173,7 @@ def clean(self): class ObjectPermissionAdmin(admin.ModelAdmin): fieldsets = ( ('Objects', { - 'fields': ('content_types',) + 'fields': ('object_types',) }), ('Assignment', { 'fields': ('groups', 'users') @@ -185,7 +185,7 @@ class ObjectPermissionAdmin(admin.ModelAdmin): 'fields': ('attrs',) }), ) - filter_horizontal = ('content_types', 'groups', 'users') + filter_horizontal = ('object_types', 'groups', 'users') form = ObjectPermissionForm list_display = [ 'list_models', 'list_users', 'list_groups', 'actions', 'attrs', @@ -195,10 +195,10 @@ class ObjectPermissionAdmin(admin.ModelAdmin): ] def get_queryset(self, request): - return super().get_queryset(request).prefetch_related('content_types', 'users', 'groups') + return super().get_queryset(request).prefetch_related('object_types', 'users', 'groups') def list_models(self, obj): - return ', '.join([f"{ct}" for ct in obj.content_types.all()]) + return ', '.join([f"{ct}" for ct in obj.object_types.all()]) list_models.short_description = 'Models' def list_users(self, obj): diff --git a/netbox/users/migrations/0008_objectpermission.py b/netbox/users/migrations/0008_objectpermission.py index f2ecb98b03..4f301264eb 100644 --- a/netbox/users/migrations/0008_objectpermission.py +++ b/netbox/users/migrations/0008_objectpermission.py @@ -22,7 +22,7 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), ('attrs', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)), ('actions', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=30), size=None)), - ('content_types', models.ManyToManyField(limit_choices_to={'app_label__in': ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'virtualization']}, related_name='object_permissions', to='contenttypes.ContentType')), + ('object_types', models.ManyToManyField(limit_choices_to={'app_label__in': ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'virtualization']}, related_name='object_permissions', to='contenttypes.ContentType')), ('groups', models.ManyToManyField(blank=True, related_name='object_permissions', to='auth.Group')), ('users', models.ManyToManyField(blank=True, related_name='object_permissions', to=settings.AUTH_USER_MODEL)), ], diff --git a/netbox/users/migrations/0009_replicate_permissions.py b/netbox/users/migrations/0009_replicate_permissions.py index b25698a366..a5d28beaca 100644 --- a/netbox/users/migrations/0009_replicate_permissions.py +++ b/netbox/users/migrations/0009_replicate_permissions.py @@ -26,7 +26,7 @@ def replicate_permissions(apps, schema_editor): if perm.group_set.exists() or perm.user_set.exists(): obj_perm = ObjectPermission(actions=[action]) obj_perm.save() - obj_perm.content_types.add(perm.content_type) + obj_perm.object_types.add(perm.content_type) if perm.group_set.exists(): obj_perm.groups.add(*list(perm.group_set.all())) if perm.user_set.exists(): diff --git a/netbox/users/models.py b/netbox/users/models.py index 9dde9d0091..255980dfc5 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -243,7 +243,7 @@ class ObjectPermission(models.Model): blank=True, related_name='object_permissions' ) - content_types = models.ManyToManyField( + object_types = models.ManyToManyField( to=ContentType, limit_choices_to={ 'app_label__in': [ @@ -267,6 +267,6 @@ class Meta: def __str__(self): return '{}: {}'.format( - ', '.join(self.content_types.values_list('model', flat=True)), + ', '.join(self.object_types.values_list('model', flat=True)), ', '.join(self.actions) ) diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py index 2ef5a19fe1..3cf6a9df65 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -37,7 +37,7 @@ def add_permissions(self, *names): obj_perm = ObjectPermission(actions=[action]) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ct) + obj_perm.object_types.add(ct) # # Convenience methods @@ -169,7 +169,7 @@ def test_get_object_with_model_permission(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) # Try GET with model-level permission self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 200) @@ -185,7 +185,7 @@ def test_get_object_with_object_permission(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) # Try GET to permitted object self.assertHttpStatus(self.client.get(instance1.get_absolute_url()), 200) @@ -225,7 +225,7 @@ def test_create_object_with_model_permission(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) # Try GET with model-level permission self.assertHttpStatus(self.client.get(self._get_url('add')), 200) @@ -250,7 +250,7 @@ def test_create_object_with_object_permission(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) # Try GET with object-level permission self.assertHttpStatus(self.client.get(self._get_url('add')), 200) @@ -309,7 +309,7 @@ def test_edit_object_with_model_permission(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) # Try GET with model-level permission self.assertHttpStatus(self.client.get(self._get_url('edit', instance)), 200) @@ -333,7 +333,7 @@ def test_edit_object_with_object_permission(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) # Try GET with a permitted object self.assertHttpStatus(self.client.get(self._get_url('edit', instance1)), 200) @@ -386,7 +386,7 @@ def test_delete_object_with_model_permission(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) # Try GET with model-level permission self.assertHttpStatus(self.client.get(self._get_url('delete', instance)), 200) @@ -411,7 +411,7 @@ def test_delete_object_with_object_permission(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) # Try GET with a permitted object self.assertHttpStatus(self.client.get(self._get_url('delete', instance1)), 200) @@ -463,7 +463,7 @@ def test_list_objects_with_model_permission(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) # Try GET with model-level permission self.assertHttpStatus(self.client.get(self._get_url('list')), 200) @@ -485,7 +485,7 @@ def test_list_objects_with_object_permission(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) # Try GET with object-level permission self.assertHttpStatus(self.client.get(self._get_url('list')), 200) @@ -515,7 +515,7 @@ def test_bulk_create_objects(self): obj_perm = ObjectPermission(actions=['add']) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) response = self.client.post(**request) self.assertHttpStatus(response, 302) @@ -561,7 +561,7 @@ def test_bulk_import_objects_with_model_permission(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) # Try GET with model-level permission self.assertHttpStatus(self.client.get(self._get_url('import')), 200) @@ -584,7 +584,7 @@ def test_bulk_import_objects_with_object_permission(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) # Test import with object-level permission self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) @@ -631,7 +631,7 @@ def test_bulk_edit_objects_with_model_permission(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) # Try POST with model-level permission self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302) @@ -656,7 +656,7 @@ def test_bulk_edit_objects_with_object_permission(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) # Try POST with model-level permission self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302) @@ -701,7 +701,7 @@ def test_bulk_delete_objects_with_model_permission(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) # Try POST with model-level permission self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302) @@ -723,7 +723,7 @@ def test_bulk_delete_objects_with_object_permission(self): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.content_types.add(ContentType.objects.get_for_model(self.model)) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) # Try POST with object-level permission self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302) From d157818d7e7dd28e3b8d61456f1aecf66b8f1a31 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 3 Jun 2020 09:43:46 -0400 Subject: [PATCH 098/101] Rename attrs to constraints --- netbox/netbox/authentication.py | 28 +++++++++---------- netbox/netbox/tests/test_authentication.py | 26 ++++++++--------- netbox/users/admin.py | 26 ++++++++--------- .../users/migrations/0008_objectpermission.py | 4 +-- netbox/users/models.py | 4 +-- netbox/utilities/testing/testcases.py | 18 ++++++------ 6 files changed, 52 insertions(+), 54 deletions(-) diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index a219c1498f..02b0be0f38 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -28,16 +28,16 @@ def get_object_permissions(self, user_obj): Q(groups__user=user_obj) ).prefetch_related('object_types') - # Create a dictionary mapping permissions to their attributes + # Create a dictionary mapping permissions to their constraints perms = dict() for obj_perm in object_permissions: for object_type in obj_perm.object_types.all(): for action in obj_perm.actions: perm_name = f"{object_type.app_label}.{action}_{object_type.model}" if perm_name in perms: - perms[perm_name].append(obj_perm.attrs) + perms[perm_name].append(obj_perm.constraints) else: - perms[perm_name] = [obj_perm.attrs] + perms[perm_name] = [obj_perm.constraints] return perms @@ -71,20 +71,20 @@ def has_perm(self, user_obj, perm, obj=None): raise ValueError(f"Invalid permission {perm} for model {model}") # Compile a query filter that matches all instances of the specified model - obj_perm_attrs = self.get_all_permissions(user_obj)[perm] - attrs = Q() - for perm_attrs in obj_perm_attrs: - if perm_attrs: - attrs |= Q(**perm_attrs) + obj_perm_constraints = self.get_all_permissions(user_obj)[perm] + constraints = Q() + for perm_constraints in obj_perm_constraints: + if perm_constraints: + constraints |= Q(**perm_constraints) else: - # Found ObjectPermission with null attrs; allow model-level access - attrs = Q() + # Found ObjectPermission with null constraints; allow model-level access + constraints = Q() break # Permission to perform the requested action on the object depends on whether the specified object matches - # the specified attributes. Note that this check is made against the *database* record representing the object, + # the specified constraints. Note that this check is made against the *database* record representing the object, # not the instance itself. - return model.objects.filter(attrs, pk=obj.pk).exists() + return model.objects.filter(constraints, pk=obj.pk).exists() class RemoteUserBackend(_RemoteUserBackend): @@ -111,11 +111,11 @@ def configure_user(self, request, user): # Assign default object permissions to the user permissions_list = [] - for permission_name, attrs in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS.items(): + for permission_name, constraints in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS.items(): try: object_type, action = resolve_permission_ct(permission_name) # TODO: Merge multiple actions into a single ObjectPermission per content type - obj_perm = ObjectPermission(actions=[action], attrs=attrs) + obj_perm = ObjectPermission(actions=[action], constraints=constraints) obj_perm.save() obj_perm.users.add(user) obj_perm.object_types.add(object_type) diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index db63faffdd..0e9bea90d6 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -202,7 +202,7 @@ def test_get_object(self): # Assign object permission obj_perm = ObjectPermission( - attrs={'site__name': 'Site 1'}, + constraints={'site__name': 'Site 1'}, actions=['view'] ) obj_perm.save() @@ -226,7 +226,7 @@ def test_list_objects(self): # Assign object permission obj_perm = ObjectPermission( - attrs={'site__name': 'Site 1'}, + constraints={'site__name': 'Site 1'}, actions=['view'] ) obj_perm.save() @@ -260,7 +260,7 @@ def test_create_object(self): # Assign object permission obj_perm = ObjectPermission( - attrs={'site__name': 'Site 1'}, + constraints={'site__name': 'Site 1'}, actions=['view', 'add'] ) obj_perm.save() @@ -307,7 +307,7 @@ def test_edit_object(self): # Assign object permission obj_perm = ObjectPermission( - attrs={'site__name': 'Site 1'}, + constraints={'site__name': 'Site 1'}, actions=['view', 'change'] ) obj_perm.save() @@ -350,7 +350,7 @@ def test_delete_object(self): # Assign object permission obj_perm = ObjectPermission( - attrs={'site__name': 'Site 1'}, + constraints={'site__name': 'Site 1'}, actions=['view', 'delete'] ) obj_perm.save() @@ -398,7 +398,7 @@ def test_bulk_import_objects(self): # Assign object permission obj_perm = ObjectPermission( - attrs={'site__name': 'Site 1'}, + constraints={'site__name': 'Site 1'}, actions=['add'] ) obj_perm.save() @@ -447,7 +447,7 @@ def test_bulk_edit_objects(self): # Assign object permission obj_perm = ObjectPermission( - attrs={'site__name': 'Site 1'}, + constraints={'site__name': 'Site 1'}, actions=['change'] ) obj_perm.save() @@ -491,7 +491,7 @@ def test_bulk_delete_objects(self): # Assign object permission obj_perm = ObjectPermission( - attrs={'site__name': 'Site 1'}, + constraints={'site__name': 'Site 1'}, actions=['view', 'delete'] ) obj_perm.save() @@ -562,7 +562,7 @@ def test_get_object(self): # Assign object permission obj_perm = ObjectPermission( - attrs={'site__name': 'Site 1'}, + constraints={'site__name': 'Site 1'}, actions=['view'] ) obj_perm.save() @@ -589,7 +589,7 @@ def test_list_objects(self): # Assign object permission obj_perm = ObjectPermission( - attrs={'site__name': 'Site 1'}, + constraints={'site__name': 'Site 1'}, actions=['view'] ) obj_perm.save() @@ -616,7 +616,7 @@ def test_create_object(self): # Assign object permission obj_perm = ObjectPermission( - attrs={'site__name': 'Site 1'}, + constraints={'site__name': 'Site 1'}, actions=['add'] ) obj_perm.save() @@ -645,7 +645,7 @@ def test_edit_object(self): # Assign object permission obj_perm = ObjectPermission( - attrs={'site__name': 'Site 1'}, + constraints={'site__name': 'Site 1'}, actions=['change'] ) obj_perm.save() @@ -680,7 +680,7 @@ def test_delete_object(self): # Assign object permission obj_perm = ObjectPermission( - attrs={'site__name': 'Site 1'}, + constraints={'site__name': 'Site 1'}, actions=['delete'] ) obj_perm.save() diff --git a/netbox/users/admin.py b/netbox/users/admin.py index 80283340df..cc7a1b3791 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -37,7 +37,7 @@ class UserConfigInline(admin.TabularInline): class ObjectPermissionInline(admin.TabularInline): model = AdminUser.object_permissions.through - fields = ['object_types', 'actions', 'attrs'] + fields = ['object_types', 'actions', 'constraints'] readonly_fields = fields extra = 0 verbose_name = 'Permission' @@ -48,8 +48,8 @@ def object_types(self, instance): def actions(self, instance): return ', '.join(instance.objectpermission.actions) - def attrs(self, instance): - return instance.objectpermission.attrs + def constraints(self, instance): + return instance.objectpermission.constraints def has_add_permission(self, request, obj): # Don't allow the creation of new ObjectPermission assignments via this form @@ -113,8 +113,8 @@ class Meta: exclude = [] help_texts = { 'actions': 'Actions granted in addition to those listed above', - 'attrs': 'JSON expression of a queryset filter that will return only permitted objects. Leave null to ' - 'match all objects of this type.' + 'constraints': 'JSON expression of a queryset filter that will return only permitted objects. Leave null ' + 'to match all objects of this type.' } labels = { 'actions': 'Additional actions' @@ -143,7 +143,7 @@ def __init__(self, *args, **kwargs): def clean(self): object_types = self.cleaned_data['object_types'] - attrs = self.cleaned_data['attrs'] + constraints = self.cleaned_data['constraints'] # Append any of the selected CRUD checkboxes to the actions list if not self.cleaned_data.get('actions'): @@ -156,16 +156,16 @@ def clean(self): if not self.cleaned_data['actions']: raise ValidationError("At least one action must be selected.") - # Validate the specified model attributes by attempting to execute a query. We don't care whether the query - # returns anything; we just want to make sure the specified attributes are valid. - if attrs: + # Validate the specified model constraints by attempting to execute a query. We don't care whether the query + # returns anything; we just want to make sure the specified constraints are valid. + if constraints: for ct in object_types: model = ct.model_class() try: - model.objects.filter(**attrs).exists() + model.objects.filter(**constraints).exists() except FieldError as e: raise ValidationError({ - 'attrs': f'Invalid attributes for {model}: {e}' + 'constraints': f'Invalid filter for {model}: {e}' }) @@ -182,13 +182,13 @@ class ObjectPermissionAdmin(admin.ModelAdmin): 'fields': (('can_view', 'can_add', 'can_change', 'can_delete'), 'actions') }), ('Constraints', { - 'fields': ('attrs',) + 'fields': ('constraints',) }), ) filter_horizontal = ('object_types', 'groups', 'users') form = ObjectPermissionForm list_display = [ - 'list_models', 'list_users', 'list_groups', 'actions', 'attrs', + 'list_models', 'list_users', 'list_groups', 'actions', 'constraints', ] list_filter = [ 'groups', 'users' diff --git a/netbox/users/migrations/0008_objectpermission.py b/netbox/users/migrations/0008_objectpermission.py index 4f301264eb..3f16e1ee8f 100644 --- a/netbox/users/migrations/0008_objectpermission.py +++ b/netbox/users/migrations/0008_objectpermission.py @@ -1,5 +1,3 @@ -# Generated by Django 3.0.6 on 2020-05-29 14:59 - from django.conf import settings import django.contrib.postgres.fields import django.contrib.postgres.fields.jsonb @@ -20,7 +18,7 @@ class Migration(migrations.Migration): name='ObjectPermission', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), - ('attrs', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)), + ('constraints', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)), ('actions', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=30), size=None)), ('object_types', models.ManyToManyField(limit_choices_to={'app_label__in': ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'virtualization']}, related_name='object_permissions', to='contenttypes.ContentType')), ('groups', models.ManyToManyField(blank=True, related_name='object_permissions', to='auth.Group')), diff --git a/netbox/users/models.py b/netbox/users/models.py index 255980dfc5..b340ce90f5 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -252,10 +252,10 @@ class ObjectPermission(models.Model): }, related_name='object_permissions' ) - attrs = JSONField( + constraints = JSONField( blank=True, null=True, - verbose_name='Attributes' + help_text="Queryset filter matching the applicable objects of the selected type(s)" ) actions = ArrayField( base_field=models.CharField(max_length=30), diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py index 3cf6a9df65..0db0ff9363 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -180,7 +180,7 @@ def test_get_object_with_object_permission(self): # Add object-level permission obj_perm = ObjectPermission( - attrs={'pk': instance1.pk}, + constraints={'pk': instance1.pk}, actions=['view'] ) obj_perm.save() @@ -245,7 +245,7 @@ def test_create_object_with_object_permission(self): # Assign object-level permission obj_perm = ObjectPermission( - attrs={'pk__gt': 0}, # Dummy permission to allow all + constraints={'pk__gt': 0}, # Dummy permission to allow all actions=['add'] ) obj_perm.save() @@ -265,7 +265,7 @@ def test_create_object_with_object_permission(self): self.assertInstanceEqual(self.model.objects.order_by('pk').last(), self.form_data) # Nullify ObjectPermission to disallow new object creation - obj_perm.attrs = {'pk': 0} + obj_perm.constraints = {'pk': 0} obj_perm.save() # Try to create a non-permitted object @@ -328,7 +328,7 @@ def test_edit_object_with_object_permission(self): # Assign object-level permission obj_perm = ObjectPermission( - attrs={'pk': instance1.pk}, + constraints={'pk': instance1.pk}, actions=['change'] ) obj_perm.save() @@ -406,7 +406,7 @@ def test_delete_object_with_object_permission(self): # Assign object-level permission obj_perm = ObjectPermission( - attrs={'pk': instance1.pk}, + constraints={'pk': instance1.pk}, actions=['delete'] ) obj_perm.save() @@ -480,7 +480,7 @@ def test_list_objects_with_object_permission(self): # Add object-level permission obj_perm = ObjectPermission( - attrs={'pk': instance1.pk}, + constraints={'pk': instance1.pk}, actions=['view'] ) obj_perm.save() @@ -579,7 +579,7 @@ def test_bulk_import_objects_with_object_permission(self): # Assign object-level permission obj_perm = ObjectPermission( - attrs={'pk__gt': 0}, # Dummy permission to allow all + constraints={'pk__gt': 0}, # Dummy permission to allow all actions=['add'] ) obj_perm.save() @@ -651,7 +651,7 @@ def test_bulk_edit_objects_with_object_permission(self): # Assign object-level permission obj_perm = ObjectPermission( - attrs={'pk__in': list(pk_list)}, + constraints={'pk__in': list(pk_list)}, actions=['change'] ) obj_perm.save() @@ -718,7 +718,7 @@ def test_bulk_delete_objects_with_object_permission(self): # Assign object-level permission obj_perm = ObjectPermission( - attrs={'pk__in': list(pk_list)}, + constraints={'pk__in': list(pk_list)}, actions=['delete'] ) obj_perm.save() From 19b57aa1eaa93df5fba86e6b52e40075d7296227 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 3 Jun 2020 10:00:58 -0400 Subject: [PATCH 099/101] Update permissions documentation --- docs/administration/permissions.md | 22 +++++++++++----------- docs/release-notes/version-2.9.md | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/administration/permissions.md b/docs/administration/permissions.md index 582709726b..56ff049ad4 100644 --- a/docs/administration/permissions.md +++ b/docs/administration/permissions.md @@ -4,12 +4,12 @@ NetBox v2.9 introduced a new object-based permissions framework, which replace's Assigning a permission in NetBox entails defining a relationship among several components: -* Model(s) - One or more types of object in NetBox +* Object type(s) - One or more types of object in NetBox * User(s) - One or more users or groups of users * Actions - The actions that can be performed (view, add, change, and/or delete) -* Attributes - An arbitrary filter used to limit the action to a specific subset of objects +* Constraints - An arbitrary filter used to limit the granted action(s) to a specific subset of objects -At a minimum, a permission assignment must specify one model, one user or group, and one action. The specification of constraining attributes is optional: A permission without any attributes specified will apply to all instances of the selected model(s). +At a minimum, a permission assignment must specify one object type, one user or group, and one action. The specification of constraints is optional: A permission without any constraints specified will apply to all instances of the selected model(s). ## Actions @@ -22,11 +22,11 @@ There are four core actions that can be permitted for each type of object within Some models introduce additional permissions that can be granted to allow other actions. For example, the `napalm_read` permission on the device model allows a user to execute NAPALM queries on a device via NetBox's REST API. These can be specified when granting a permission in the "additional actions" field. -## Attributes +## Constraints -Constraining attributes are defined as a JSON object representing a [Django query filter](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups). This is the same syntax that you would pass to the QuerySet `filter()` method when performing a query using the Django ORM. As with query filters, double underscores can be used to traverse related objects or invoke lookup expressions. Some example queries and their corresponding definitions are shown below. +Constraints are defined as a JSON object representing a [Django query filter](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups). This is the same syntax that you would pass to the QuerySet `filter()` method when performing a query using the Django ORM. As with query filters, double underscores can be used to traverse related objects or invoke lookup expressions. Some example queries and their corresponding definitions are shown below. -All attributes defined on a permission are applied with a logic AND. For example, suppose you assign a permission for the site model with the following attributes. +All constraints defined on a permission are applied with a logic AND. For example, suppose you assign a permission for the site model with the following constraints. ```json { @@ -35,11 +35,11 @@ All attributes defined on a permission are applied with a logic AND. For example } ``` -The permission will grant access only to sites which have a status of "active" **and** which are assigned to the "Americas" region. To achieve a logical OR with a different set of attributes, simply create another permission assignment for the same model and user/group. +The permission will grant access only to sites which have a status of "active" **and** which are assigned to the "Americas" region. To achieve a logical OR with a different set of constraints, simply create another permission assignment for the same model and user/group. -### Example Attribute Definitions +### Example Constraint Definitions -| Query Filter | Permission Attributes | +| Query Filter | Permission Constraints | | ------------ | --------------------- | | `filter(status='active')` | `{"status": "active"}` | | `filter(status='active', role='testing')` | `{"status": "active", "role": "testing"}` | @@ -62,7 +62,7 @@ If the permission has been granted, NetBox will compile any specified constraint ] ``` -This grants the user access to view any device that is in NYC1 or NYC2, **or** which has a status of "offline" and has no tenant assigned. These attributes will result in the following ORM query: +This grants the user access to view any device that is in NYC1 or NYC2, **or** which has a status of "offline" and has no tenant assigned. These constraints will result in the following ORM query: ```no-highlight Site.objects.filter( @@ -73,4 +73,4 @@ Site.objects.filter( ### Creating and Modifying Objects -The same sort of logic is in play when a user attempts to create or modify an object in NetBox, with a twist. Once validation has completed, NetBox starts an atomic database transaction to facilitate the change, and the object is created or saved normally. Next, still within the transaction, NetBox issues a second query to retrieve the newly created/updated object, filtering the restricted queryset with the object's primary key. If this query fails to return the object, NetBox knows that the new revision does not match the attributes granted by the permission. The transaction is then aborted, and the database is left in its original state. +The same sort of logic is in play when a user attempts to create or modify an object in NetBox, with a twist. Once validation has completed, NetBox starts an atomic database transaction to facilitate the change, and the object is created or saved normally. Next, still within the transaction, NetBox issues a second query to retrieve the newly created/updated object, filtering the restricted queryset with the object's primary key. If this query fails to return the object, NetBox knows that the new revision does not match the constraints imposed by the permission. The transaction is then aborted, and the database is left in its original state. diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index 4fda77838d..be6004febb 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -6,7 +6,7 @@ #### Object-Based Permissions ([#554](https://github.com/netbox-community/netbox/issues/554)) -NetBox v2.9 replaces Django's built-in permissions framework with one that supports object-based assignment of permissions using arbitrary constraints. When granting a user or group to perform a certain action on one or more types of objects, an administrator can optionally specify a set of attributes. The permission will apply only to objects which match the specified attributes. For example, assigning permission to modify devices with the attribute filter `{"tenant__group__name": "Customers"}` would grant the permission only for devices assigned to a tenant belonging to the "Customers" group. +NetBox v2.9 replaces Django's built-in permissions framework with one that supports object-based assignment of permissions using arbitrary constraints. When granting a user or group to perform a certain action on one or more types of objects, an administrator can optionally specify a set of constraints. The permission will apply only to objects which match the specified constraints. For example, assigning permission to modify devices with the constraint `{"tenant__group__name": "Customers"}` would grant the permission only for devices assigned to a tenant belonging to the "Customers" group. ### Configuration Changes From 3084d58da1861acbb89f107cdce0953fcb2531eb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 3 Jun 2020 13:08:04 -0400 Subject: [PATCH 100/101] Add REST API endpoint for ObjectPermissions --- netbox/netbox/urls.py | 1 + netbox/netbox/views.py | 1 + netbox/users/api/nested_serializers.py | 11 +- netbox/users/api/serializers.py | 26 ++++- netbox/users/api/urls.py | 21 ++++ netbox/users/api/views.py | 14 +++ netbox/users/models.py | 26 ++--- netbox/users/tests/test_api.py | 144 +++++++++++++++++++++++++ 8 files changed, 228 insertions(+), 16 deletions(-) create mode 100644 netbox/users/api/urls.py create mode 100644 netbox/users/api/views.py create mode 100644 netbox/users/tests/test_api.py diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index d8aa2f9d10..a928b79eae 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -65,6 +65,7 @@ def get_redirect_url(self, *args, **kwargs): path('api/ipam/', include('ipam.api.urls')), path('api/secrets/', include('secrets.api.urls')), path('api/tenancy/', include('tenancy.api.urls')), + path('api/users/', include('users.api.urls')), path('api/virtualization/', include('virtualization.api.urls')), path('api/docs/', schema_view.with_ui('swagger'), name='api_docs'), path('api/redoc/', schema_view.with_ui('redoc'), name='api_redocs'), diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index d6be844d43..7ac5f550b0 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -343,5 +343,6 @@ def get(self, request, format=None): ('plugins', reverse('plugins-api:api-root', request=request, format=format)), ('secrets', reverse('secrets-api:api-root', request=request, format=format)), ('tenancy', reverse('tenancy-api:api-root', request=request, format=format)), + ('users', reverse('users-api:api-root', request=request, format=format)), ('virtualization', reverse('virtualization-api:api-root', request=request, format=format)), ))) diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py index d1b6497136..f7721cf948 100644 --- a/netbox/users/api/nested_serializers.py +++ b/netbox/users/api/nested_serializers.py @@ -1,4 +1,4 @@ -from django.contrib.auth.models import User +from django.contrib.auth.models import Group, User from utilities.api import WritableNestedSerializer @@ -8,9 +8,16 @@ # -# Users +# Groups and users # +class NestedGroupSerializer(WritableNestedSerializer): + + class Meta: + model = Group + fields = ['id', 'name'] + + class NestedUserSerializer(WritableNestedSerializer): class Meta: diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 86d350e691..dc5301846f 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -1,4 +1,28 @@ +from django.contrib.contenttypes.models import ContentType + +from users.models import ObjectPermission +from utilities.api import ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer from .nested_serializers import * -# Placeholder for future serializers +class ObjectPermissionSerializer(ValidatedModelSerializer): + object_types = ContentTypeField( + queryset=ContentType.objects.all(), + many=True + ) + groups = SerializedPKRelatedField( + queryset=Group.objects.all(), + serializer=NestedGroupSerializer, + required=False, + many=True + ) + users = SerializedPKRelatedField( + queryset=User.objects.all(), + serializer=NestedUserSerializer, + required=False, + many=True + ) + + class Meta: + model = ObjectPermission + fields = ('id', 'object_types', 'groups', 'users', 'actions', 'constraints') diff --git a/netbox/users/api/urls.py b/netbox/users/api/urls.py new file mode 100644 index 0000000000..fffea59680 --- /dev/null +++ b/netbox/users/api/urls.py @@ -0,0 +1,21 @@ +from rest_framework import routers + +from . import views + + +class UsersRootView(routers.APIRootView): + """ + Users API root view + """ + def get_view_name(self): + return 'Users' + + +router = routers.DefaultRouter() +router.APIRootView = UsersRootView + +# Permissions +router.register('permissions', views.ObjectPermissionViewSet) + +app_name = 'users-api' +urlpatterns = router.urls diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py new file mode 100644 index 0000000000..74b315b44d --- /dev/null +++ b/netbox/users/api/views.py @@ -0,0 +1,14 @@ +from utilities.api import ModelViewSet +from . import serializers + +from users.models import ObjectPermission + + +# +# ObjectPermissions +# + +class ObjectPermissionViewSet(ModelViewSet): + queryset = ObjectPermission.objects.prefetch_related('object_types', 'groups', 'users') + serializer_class = serializers.ObjectPermissionSerializer + # filterset_class = filters.ObjectPermissionFilterSet diff --git a/netbox/users/models.py b/netbox/users/models.py index b340ce90f5..fa32774566 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -233,16 +233,6 @@ 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 identified by ORM query parameters. """ - users = models.ManyToManyField( - to=User, - blank=True, - related_name='object_permissions' - ) - groups = models.ManyToManyField( - to=Group, - blank=True, - related_name='object_permissions' - ) object_types = models.ManyToManyField( to=ContentType, limit_choices_to={ @@ -252,15 +242,25 @@ class ObjectPermission(models.Model): }, related_name='object_permissions' ) - constraints = JSONField( + groups = models.ManyToManyField( + to=Group, blank=True, - null=True, - help_text="Queryset filter matching the applicable objects of the selected type(s)" + related_name='object_permissions' + ) + users = models.ManyToManyField( + to=User, + blank=True, + related_name='object_permissions' ) actions = ArrayField( base_field=models.CharField(max_length=30), help_text="The list of actions granted by this permission" ) + constraints = JSONField( + blank=True, + null=True, + help_text="Queryset filter matching the applicable objects of the selected type(s)" + ) class Meta: verbose_name = "Permission" diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py new file mode 100644 index 0000000000..f507192ee2 --- /dev/null +++ b/netbox/users/tests/test_api.py @@ -0,0 +1,144 @@ +from django.contrib.auth.models import Group, User +from django.contrib.contenttypes.models import ContentType +from django.urls import reverse +from rest_framework import status + +from users.models import ObjectPermission +from utilities.testing import APITestCase + + +class AppTest(APITestCase): + + def test_root(self): + + url = reverse('users-api:api-root') + response = self.client.get('{}?format=api'.format(url), **self.header) + + self.assertEqual(response.status_code, 200) + + +class ObjectPermissionTest(APITestCase): + + @classmethod + def setUpTestData(cls): + + groups = ( + Group(name='Group 1'), + Group(name='Group 2'), + Group(name='Group 3'), + ) + Group.objects.bulk_create(groups) + + users = ( + User(username='User 1', is_active=True), + User(username='User 2', is_active=True), + User(username='User 3', is_active=True), + ) + User.objects.bulk_create(users) + + object_type = ContentType.objects.get(app_label='dcim', model='device') + + for i in range(0, 3): + objectpermission = ObjectPermission( + actions=['view', 'add', 'change', 'delete'], + constraints={'name': f'TEST{i+1}'} + ) + objectpermission.save() + objectpermission.object_types.add(object_type) + objectpermission.groups.add(groups[i]) + objectpermission.users.add(users[i]) + + def test_get_objectpermission(self): + objectpermission = ObjectPermission.objects.first() + url = reverse('users-api:objectpermission-detail', kwargs={'pk': objectpermission.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['id'], objectpermission.pk) + + def test_list_objectpermissions(self): + url = reverse('users-api:objectpermission-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], ObjectPermission.objects.count()) + + def test_create_objectpermission(self): + data = { + 'object_types': ['dcim.site'], + 'groups': [Group.objects.first().pk], + 'users': [User.objects.first().pk], + 'actions': ['view', 'add', 'change', 'delete'], + 'constraints': {'name': 'TEST4'}, + } + + url = reverse('users-api:objectpermission-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(ObjectPermission.objects.count(), 4) + objectpermission = ObjectPermission.objects.get(pk=response.data['id']) + self.assertEqual(objectpermission.groups.first().pk, data['groups'][0]) + self.assertEqual(objectpermission.users.first().pk, data['users'][0]) + self.assertEqual(objectpermission.actions, data['actions']) + self.assertEqual(objectpermission.constraints, data['constraints']) + + def test_create_objectpermission_bulk(self): + groups = Group.objects.all()[:3] + users = User.objects.all()[:3] + data = [ + { + 'object_types': ['dcim.site'], + 'groups': [groups[0].pk], + 'users': [users[0].pk], + 'actions': ['view', 'add', 'change', 'delete'], + 'constraints': {'name': 'TEST4'}, + }, + { + 'object_types': ['dcim.site'], + 'groups': [groups[1].pk], + 'users': [users[1].pk], + 'actions': ['view', 'add', 'change', 'delete'], + 'constraints': {'name': 'TEST5'}, + }, + { + 'object_types': ['dcim.site'], + 'groups': [groups[2].pk], + 'users': [users[2].pk], + 'actions': ['view', 'add', 'change', 'delete'], + 'constraints': {'name': 'TEST6'}, + }, + ] + + url = reverse('users-api:objectpermission-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(ObjectPermission.objects.count(), 6) + + def test_update_objectpermission(self): + objectpermission = ObjectPermission.objects.first() + data = { + 'object_types': ['dcim.site', 'dcim.device'], + 'groups': [g.pk for g in Group.objects.all()[:2]], + 'users': [u.pk for u in User.objects.all()[:2]], + 'actions': ['view'], + 'constraints': {'name': 'TEST'}, + } + + url = reverse('users-api:objectpermission-detail', kwargs={'pk': objectpermission.pk}) + response = self.client.put(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(ObjectPermission.objects.count(), 3) + objectpermission = ObjectPermission.objects.get(pk=response.data['id']) + self.assertEqual(objectpermission.groups.first().pk, data['groups'][0]) + self.assertEqual(objectpermission.users.first().pk, data['users'][0]) + self.assertEqual(objectpermission.actions, data['actions']) + self.assertEqual(objectpermission.constraints, data['constraints']) + + def test_delete_objectpermission(self): + objectpermission = ObjectPermission.objects.first() + url = reverse('users-api:objectpermission-detail', kwargs={'pk': objectpermission.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(ObjectPermission.objects.count(), 2) From dbf6c0a075a0a92b3d42922baa1fce6f60e71360 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 3 Jun 2020 13:20:35 -0400 Subject: [PATCH 101/101] Split ObjectPermission model documentation --- docs/administration/permissions.md | 35 +------------------------- docs/models/users/objectpermission.md | 36 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 34 deletions(-) create mode 100644 docs/models/users/objectpermission.md diff --git a/docs/administration/permissions.md b/docs/administration/permissions.md index 56ff049ad4..7e47db0d9c 100644 --- a/docs/administration/permissions.md +++ b/docs/administration/permissions.md @@ -2,40 +2,7 @@ NetBox v2.9 introduced a new object-based permissions framework, which replace's Django's built-in permission model. Object-based permissions allow for the assignment of permissions to an arbitrary subset of objects of a certain type, rather than only by type of object. For example, it is possible to grant a user permission to view only sites within a particular region, or to modify only VLANs with a numeric ID within a certain range. -Assigning a permission in NetBox entails defining a relationship among several components: - -* Object type(s) - One or more types of object in NetBox -* User(s) - One or more users or groups of users -* Actions - The actions that can be performed (view, add, change, and/or delete) -* Constraints - An arbitrary filter used to limit the granted action(s) to a specific subset of objects - -At a minimum, a permission assignment must specify one object type, one user or group, and one action. The specification of constraints is optional: A permission without any constraints specified will apply to all instances of the selected model(s). - -## Actions - -There are four core actions that can be permitted for each type of object within NetBox, roughly analogous to the CRUD convention (create, read, update, and delete): - -* View - Retrieve an object from the database -* Add - Create a new object -* Change - Modify an existing object -* Delete - Delete an existing object - -Some models introduce additional permissions that can be granted to allow other actions. For example, the `napalm_read` permission on the device model allows a user to execute NAPALM queries on a device via NetBox's REST API. These can be specified when granting a permission in the "additional actions" field. - -## Constraints - -Constraints are defined as a JSON object representing a [Django query filter](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups). This is the same syntax that you would pass to the QuerySet `filter()` method when performing a query using the Django ORM. As with query filters, double underscores can be used to traverse related objects or invoke lookup expressions. Some example queries and their corresponding definitions are shown below. - -All constraints defined on a permission are applied with a logic AND. For example, suppose you assign a permission for the site model with the following constraints. - -```json -{ - "status": "active", - "region__name": "Americas" -} -``` - -The permission will grant access only to sites which have a status of "active" **and** which are assigned to the "Americas" region. To achieve a logical OR with a different set of constraints, simply create another permission assignment for the same model and user/group. +{!docs/models/users/objectpermission.md!} ### Example Constraint Definitions diff --git a/docs/models/users/objectpermission.md b/docs/models/users/objectpermission.md new file mode 100644 index 0000000000..80313fc0be --- /dev/null +++ b/docs/models/users/objectpermission.md @@ -0,0 +1,36 @@ +# Object Permissions + +Assigning a permission in NetBox entails defining a relationship among several components: + +* Object type(s) - One or more types of object in NetBox +* User(s) - One or more users or groups of users +* Actions - The actions that can be performed (view, add, change, and/or delete) +* Constraints - An arbitrary filter used to limit the granted action(s) to a specific subset of objects + +At a minimum, a permission assignment must specify one object type, one user or group, and one action. The specification of constraints is optional: A permission without any constraints specified will apply to all instances of the selected model(s). + +## Actions + +There are four core actions that can be permitted for each type of object within NetBox, roughly analogous to the CRUD convention (create, read, update, and delete): + +* View - Retrieve an object from the database +* Add - Create a new object +* Change - Modify an existing object +* Delete - Delete an existing object + +Some models introduce additional permissions that can be granted to allow other actions. For example, the `napalm_read` permission on the device model allows a user to execute NAPALM queries on a device via NetBox's REST API. These can be specified when granting a permission in the "additional actions" field. + +## Constraints + +Constraints are defined as a JSON object representing a [Django query filter](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups). This is the same syntax that you would pass to the QuerySet `filter()` method when performing a query using the Django ORM. As with query filters, double underscores can be used to traverse related objects or invoke lookup expressions. Some example queries and their corresponding definitions are shown below. + +All constraints defined on a permission are applied with a logic AND. For example, suppose you assign a permission for the site model with the following constraints. + +```json +{ + "status": "active", + "region__name": "Americas" +} +``` + +The permission will grant access only to sites which have a status of "active" **and** which are assigned to the "Americas" region. To achieve a logical OR with a different set of constraints, simply create another permission assignment for the same model and user/group.