From 43ad9aa2b1e71004c768cf234e2fb78af0cc7f21 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 6 May 2020 15:20:28 -0400 Subject: [PATCH 001/505] Fix version --- 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 ce352ebda..bf660696b 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '2.8.3-dev' +VERSION = '2.9.0-dev' # Hostname HOSTNAME = platform.node() From 6624fc607602502bd40f7124cc98bb050d96c01c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 8 May 2020 17:30:25 -0400 Subject: [PATCH 002/505] 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 bf660696b..5c48ee620 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -335,6 +335,7 @@ TEMPLATES = [ 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 42e651712..fcaeb4ef0 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -3,7 +3,7 @@ from django.contrib import admin 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 000000000..d805c3379 --- /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 ea5762232..f2002ae95 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 @@ class Token(models.Model): 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 000000000..f73fd8f43 --- /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 6342bad2b..0d20fe02f 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -3,6 +3,10 @@ import logging 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 @@ class ViewExemptModelBackend(ModelBackend): 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 003/505] 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 cd1b4edf4..5afa46295 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -21,6 +21,7 @@ from extras.models import Graph 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 000000000..58fd4380a --- /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 87fa6bc25202e1e47d4c02f66c785aad89d0fffe Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 11 May 2020 12:37:22 -0400 Subject: [PATCH 004/505] #4624: Refactor ObjectEditView to use a queryset --- netbox/circuits/views.py | 8 ++--- netbox/dcim/views.py | 62 +++++++++++++++++----------------- netbox/extras/views.py | 6 ++-- netbox/ipam/views.py | 18 +++++----- netbox/secrets/views.py | 2 +- netbox/tenancy/views.py | 4 +-- netbox/utilities/views.py | 19 ++++++----- netbox/virtualization/views.py | 10 +++--- 8 files changed, 65 insertions(+), 64 deletions(-) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 709d2a726..7fe2501fb 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -62,7 +62,7 @@ class ProviderView(PermissionRequiredMixin, View): class ProviderCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'circuits.add_provider' - model = Provider + queryset = Provider.objects.all() model_form = forms.ProviderForm template_name = 'circuits/provider_edit.html' default_return_url = 'circuits:provider_list' @@ -114,7 +114,7 @@ class CircuitTypeListView(PermissionRequiredMixin, ObjectListView): class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'circuits.add_circuittype' - model = CircuitType + queryset = CircuitType.objects.all() model_form = forms.CircuitTypeForm default_return_url = 'circuits:circuittype_list' @@ -178,7 +178,7 @@ class CircuitView(PermissionRequiredMixin, View): class CircuitCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'circuits.add_circuit' - model = Circuit + queryset = Circuit.objects.all() model_form = forms.CircuitForm template_name = 'circuits/circuit_edit.html' default_return_url = 'circuits:circuit_list' @@ -273,7 +273,7 @@ def circuit_terminations_swap(request, pk): class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'circuits.add_circuittermination' - model = CircuitTermination + queryset = CircuitTermination.objects.all() model_form = forms.CircuitTerminationForm template_name = 'circuits/circuittermination_edit.html' diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index cd1b4edf4..c9cc6ac13 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -157,7 +157,7 @@ class RegionListView(PermissionRequiredMixin, ObjectListView): class RegionCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_region' - model = Region + queryset = Region.objects.all() model_form = forms.RegionForm default_return_url = 'dcim:region_list' @@ -220,7 +220,7 @@ class SiteView(PermissionRequiredMixin, View): class SiteCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_site' - model = Site + queryset = Site.objects.all() model_form = forms.SiteForm template_name = 'dcim/site_edit.html' default_return_url = 'dcim:site_list' @@ -280,7 +280,7 @@ class RackGroupListView(PermissionRequiredMixin, ObjectListView): class RackGroupCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_rackgroup' - model = RackGroup + queryset = RackGroup.objects.all() model_form = forms.RackGroupForm default_return_url = 'dcim:rackgroup_list' @@ -316,7 +316,7 @@ class RackRoleListView(PermissionRequiredMixin, ObjectListView): class RackRoleCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_rackrole' - model = RackRole + queryset = RackRole.objects.all() model_form = forms.RackRoleForm default_return_url = 'dcim:rackrole_list' @@ -426,7 +426,7 @@ class RackView(PermissionRequiredMixin, View): class RackCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_rack' - model = Rack + queryset = Rack.objects.all() model_form = forms.RackForm template_name = 'dcim/rack_edit.html' default_return_url = 'dcim:rack_list' @@ -493,7 +493,7 @@ class RackReservationView(PermissionRequiredMixin, View): class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_rackreservation' - model = RackReservation + queryset = RackReservation.objects.all() model_form = forms.RackReservationForm template_name = 'dcim/rackreservation_edit.html' default_return_url = 'dcim:rackreservation_list' @@ -566,7 +566,7 @@ class ManufacturerListView(PermissionRequiredMixin, ObjectListView): class ManufacturerCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_manufacturer' - model = Manufacturer + queryset = Manufacturer.objects.all() model_form = forms.ManufacturerForm default_return_url = 'dcim:manufacturer_list' @@ -666,7 +666,7 @@ class DeviceTypeView(PermissionRequiredMixin, View): class DeviceTypeCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_devicetype' - model = DeviceType + queryset = DeviceType.objects.all() model_form = forms.DeviceTypeForm template_name = 'dcim/devicetype_edit.html' default_return_url = 'dcim:devicetype_list' @@ -740,7 +740,7 @@ class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView class ConsolePortTemplateEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_consoleporttemplate' - model = ConsolePortTemplate + queryset = ConsolePortTemplate.objects.all() model_form = forms.ConsolePortTemplateForm @@ -776,7 +776,7 @@ class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCrea class ConsoleServerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_consoleserverporttemplate' - model = ConsoleServerPortTemplate + queryset = ConsoleServerPortTemplate.objects.all() model_form = forms.ConsoleServerPortTemplateForm @@ -812,7 +812,7 @@ class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): class PowerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_powerporttemplate' - model = PowerPortTemplate + queryset = PowerPortTemplate.objects.all() model_form = forms.PowerPortTemplateForm @@ -848,7 +848,7 @@ class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView class PowerOutletTemplateEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_poweroutlettemplate' - model = PowerOutletTemplate + queryset = PowerOutletTemplate.objects.all() model_form = forms.PowerOutletTemplateForm @@ -884,7 +884,7 @@ class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): class InterfaceTemplateEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_interfacetemplate' - model = InterfaceTemplate + queryset = InterfaceTemplate.objects.all() model_form = forms.InterfaceTemplateForm @@ -920,7 +920,7 @@ class FrontPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): class FrontPortTemplateEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_frontporttemplate' - model = FrontPortTemplate + queryset = FrontPortTemplate.objects.all() model_form = forms.FrontPortTemplateForm @@ -956,7 +956,7 @@ class RearPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): class RearPortTemplateEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_rearporttemplate' - model = RearPortTemplate + queryset = RearPortTemplate.objects.all() model_form = forms.RearPortTemplateForm @@ -992,7 +992,7 @@ class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): class DeviceBayTemplateEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_devicebaytemplate' - model = DeviceBayTemplate + queryset = DeviceBayTemplate.objects.all() model_form = forms.DeviceBayTemplateForm @@ -1026,7 +1026,7 @@ class DeviceRoleListView(PermissionRequiredMixin, ObjectListView): class DeviceRoleCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_devicerole' - model = DeviceRole + queryset = DeviceRole.objects.all() model_form = forms.DeviceRoleForm default_return_url = 'dcim:devicerole_list' @@ -1061,7 +1061,7 @@ class PlatformListView(PermissionRequiredMixin, ObjectListView): class PlatformCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_platform' - model = Platform + queryset = Platform.objects.all() model_form = forms.PlatformForm default_return_url = 'dcim:platform_list' @@ -1247,7 +1247,7 @@ class DeviceConfigContextView(PermissionRequiredMixin, ObjectConfigContextView): class DeviceCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_device' - model = Device + queryset = Device.objects.all() model_form = forms.DeviceForm template_name = 'dcim/device_edit.html' default_return_url = 'dcim:device_list' @@ -1330,7 +1330,7 @@ class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView): class ConsolePortEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_consoleport' - model = ConsolePort + queryset = ConsolePort.objects.all() model_form = forms.ConsolePortForm @@ -1385,7 +1385,7 @@ class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView): class ConsoleServerPortEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_consoleserverport' - model = ConsoleServerPort + queryset = ConsoleServerPort.objects.all() model_form = forms.ConsoleServerPortForm @@ -1452,7 +1452,7 @@ class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView): class PowerPortEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_powerport' - model = PowerPort + queryset = PowerPort.objects.all() model_form = forms.PowerPortForm @@ -1507,7 +1507,7 @@ class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView): class PowerOutletEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_poweroutlet' - model = PowerOutlet + queryset = PowerOutlet.objects.all() model_form = forms.PowerOutletForm @@ -1610,7 +1610,7 @@ class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): class InterfaceEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_interface' - model = Interface + queryset = Interface.objects.all() model_form = forms.InterfaceForm template_name = 'dcim/interface_edit.html' @@ -1678,7 +1678,7 @@ class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView): class FrontPortEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_frontport' - model = FrontPort + queryset = FrontPort.objects.all() model_form = forms.FrontPortForm @@ -1745,7 +1745,7 @@ class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView): class RearPortEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_rearport' - model = RearPort + queryset = RearPort.objects.all() model_form = forms.RearPortForm @@ -1814,7 +1814,7 @@ class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView): class DeviceBayEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_devicebay' - model = DeviceBay + queryset = DeviceBay.objects.all() model_form = forms.DeviceBayForm @@ -2154,7 +2154,7 @@ class CableCreateView(PermissionRequiredMixin, GetReturnURLMixin, View): class CableEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_cable' - model = Cable + queryset = Cable.objects.all() model_form = forms.CableForm template_name = 'dcim/cable_edit.html' default_return_url = 'dcim:cable_list' @@ -2309,7 +2309,7 @@ class InventoryItemListView(PermissionRequiredMixin, ObjectListView): class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_inventoryitem' - model = InventoryItem + queryset = InventoryItem.objects.all() model_form = forms.InventoryItemForm @@ -2654,7 +2654,7 @@ class PowerPanelView(PermissionRequiredMixin, View): class PowerPanelCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_powerpanel' - model = PowerPanel + queryset = PowerPanel.objects.all() model_form = forms.PowerPanelForm default_return_url = 'dcim:powerpanel_list' @@ -2725,7 +2725,7 @@ class PowerFeedView(PermissionRequiredMixin, View): class PowerFeedCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_powerfeed' - model = PowerFeed + queryset = PowerFeed.objects.all() model_form = forms.PowerFeedForm template_name = 'dcim/powerfeed_edit.html' default_return_url = 'dcim:powerfeed_list' diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 613e45132..687ff69b0 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -67,7 +67,7 @@ class TagView(PermissionRequiredMixin, View): class TagEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'extras.change_tag' - model = Tag + queryset = Tag.objects.all() model_form = forms.TagForm default_return_url = 'extras:tag_list' template_name = 'extras/tag_edit.html' @@ -136,7 +136,7 @@ class ConfigContextView(PermissionRequiredMixin, View): class ConfigContextCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'extras.add_configcontext' - model = ConfigContext + queryset = ConfigContext.objects.all() model_form = forms.ConfigContextForm default_return_url = 'extras:configcontext_list' template_name = 'extras/configcontext_edit.html' @@ -306,7 +306,7 @@ class ObjectChangeLogView(View): class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'extras.change_imageattachment' - model = ImageAttachment + queryset = ImageAttachment.objects.all() model_form = forms.ImageAttachmentForm def alter_obj(self, imageattachment, request, args, kwargs): diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index e8041e8dd..bf94a4f74 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -136,7 +136,7 @@ class VRFView(PermissionRequiredMixin, View): class VRFCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_vrf' - model = VRF + queryset = VRF.objects.all() model_form = forms.VRFForm template_name = 'ipam/vrf_edit.html' default_return_url = 'ipam:vrf_list' @@ -259,7 +259,7 @@ class RIRListView(PermissionRequiredMixin, ObjectListView): class RIRCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_rir' - model = RIR + queryset = RIR.objects.all() model_form = forms.RIRForm default_return_url = 'ipam:rir_list' @@ -361,7 +361,7 @@ class AggregateView(PermissionRequiredMixin, View): class AggregateCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_aggregate' - model = Aggregate + queryset = Aggregate.objects.all() model_form = forms.AggregateForm template_name = 'ipam/aggregate_edit.html' default_return_url = 'ipam:aggregate_list' @@ -413,7 +413,7 @@ class RoleListView(PermissionRequiredMixin, ObjectListView): class RoleCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_role' - model = Role + queryset = Role.objects.all() model_form = forms.RoleForm default_return_url = 'ipam:role_list' @@ -588,7 +588,7 @@ class PrefixIPAddressesView(PermissionRequiredMixin, View): class PrefixCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_prefix' - model = Prefix + queryset = Prefix.objects.all() model_form = forms.PrefixForm template_name = 'ipam/prefix_edit.html' default_return_url = 'ipam:prefix_list' @@ -699,7 +699,7 @@ class IPAddressView(PermissionRequiredMixin, View): class IPAddressCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_ipaddress' - model = IPAddress + queryset = IPAddress.objects.all() model_form = forms.IPAddressForm template_name = 'ipam/ipaddress_edit.html' default_return_url = 'ipam:ipaddress_list' @@ -817,7 +817,7 @@ class VLANGroupListView(PermissionRequiredMixin, ObjectListView): class VLANGroupCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_vlangroup' - model = VLANGroup + queryset = VLANGroup.objects.all() model_form = forms.VLANGroupForm default_return_url = 'ipam:vlangroup_list' @@ -933,7 +933,7 @@ class VLANMembersView(PermissionRequiredMixin, View): class VLANCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_vlan' - model = VLAN + queryset = VLAN.objects.all() model_form = forms.VLANForm template_name = 'ipam/vlan_edit.html' default_return_url = 'ipam:vlan_list' @@ -1000,7 +1000,7 @@ class ServiceView(PermissionRequiredMixin, View): class ServiceCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_service' - model = Service + queryset = Service.objects.all() model_form = forms.ServiceForm template_name = 'ipam/service_edit.html' diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index ed59f4392..937b06edf 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -38,7 +38,7 @@ class SecretRoleListView(PermissionRequiredMixin, ObjectListView): class SecretRoleCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'secrets.add_secretrole' - model = SecretRole + queryset = SecretRole.objects.all() model_form = forms.SecretRoleForm default_return_url = 'secrets:secretrole_list' diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index afc363cd6..148ac6c17 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -32,7 +32,7 @@ class TenantGroupListView(PermissionRequiredMixin, ObjectListView): class TenantGroupCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'tenancy.add_tenantgroup' - model = TenantGroup + queryset = TenantGroup.objects.all() model_form = forms.TenantGroupForm default_return_url = 'tenancy:tenantgroup_list' @@ -95,7 +95,7 @@ class TenantView(PermissionRequiredMixin, View): class TenantCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'tenancy.add_tenant' - model = Tenant + queryset = Tenant.objects.all() model_form = forms.TenantForm template_name = 'tenancy/tenant_edit.html' default_return_url = 'tenancy:tenant_list' diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 3064abe4e..cde38846e 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -216,21 +216,22 @@ class ObjectEditView(GetReturnURLMixin, View): """ Create or edit a single object. - model: The model of the object being edited + queryset: The base queryset for the model being modified model_form: The form used to create or edit the object template_name: The name of the template """ - model = None + queryset = None model_form = None template_name = 'utilities/obj_edit.html' def get_object(self, kwargs): - # Look up object by slug or PK. Return None if neither was provided. + # Look up an existing object by slug or PK, if provided. if 'slug' in kwargs: - return get_object_or_404(self.model, slug=kwargs['slug']) + return get_object_or_404(self.queryset, slug=kwargs['slug']) elif 'pk' in kwargs: - return get_object_or_404(self.model, pk=kwargs['pk']) - return self.model() + return get_object_or_404(self.queryset, pk=kwargs['pk']) + # Otherwise, return a new instance. + return self.queryset.model() def alter_obj(self, obj, request, url_args, url_kwargs): # Allow views to add extra info to an object before it is processed. For example, a parent object can be defined @@ -249,7 +250,7 @@ class ObjectEditView(GetReturnURLMixin, View): return render(request, self.template_name, { 'obj': self.obj, - 'obj_type': self.model._meta.verbose_name, + 'obj_type': self.queryset.model._meta.verbose_name, 'form': form, 'return_url': self.get_return_url(request, self.obj), }) @@ -264,7 +265,7 @@ class ObjectEditView(GetReturnURLMixin, View): obj = form.save() msg = '{} {}'.format( 'Created' if not form.instance.pk else 'Modified', - self.model._meta.verbose_name + self.queryset.model._meta.verbose_name ) logger.info(f"{msg} {obj} (PK: {obj.pk})") if hasattr(obj, 'get_absolute_url'): @@ -293,7 +294,7 @@ class ObjectEditView(GetReturnURLMixin, View): return render(request, self.template_name, { 'obj': self.obj, - 'obj_type': self.model._meta.verbose_name, + 'obj_type': self.queryset.model._meta.verbose_name, 'form': form, 'return_url': self.get_return_url(request, self.obj), }) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 0a05833f4..0a6c418fe 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -30,7 +30,7 @@ class ClusterTypeListView(PermissionRequiredMixin, ObjectListView): class ClusterTypeCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'virtualization.add_clustertype' - model = ClusterType + queryset = ClusterType.objects.all() model_form = forms.ClusterTypeForm default_return_url = 'virtualization:clustertype_list' @@ -65,7 +65,7 @@ class ClusterGroupListView(PermissionRequiredMixin, ObjectListView): class ClusterGroupCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'virtualization.add_clustergroup' - model = ClusterGroup + queryset = ClusterGroup.objects.all() model_form = forms.ClusterGroupForm default_return_url = 'virtualization:clustergroup_list' @@ -122,7 +122,7 @@ class ClusterView(PermissionRequiredMixin, View): class ClusterCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'virtualization.add_cluster' template_name = 'virtualization/cluster_edit.html' - model = Cluster + queryset = Cluster.objects.all() model_form = forms.ClusterForm @@ -281,7 +281,7 @@ class VirtualMachineConfigContextView(PermissionRequiredMixin, ObjectConfigConte class VirtualMachineCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'virtualization.add_virtualmachine' - model = VirtualMachine + queryset = VirtualMachine.objects.all() model_form = forms.VirtualMachineForm template_name = 'virtualization/virtualmachine_edit.html' default_return_url = 'virtualization:virtualmachine_list' @@ -335,7 +335,7 @@ class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): class InterfaceEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_interface' - model = Interface + queryset = Interface.objects.all() model_form = forms.InterfaceForm template_name = 'virtualization/interface_edit.html' From 3abb52a0851adfc49341e655d7fb8a1d994f14fc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 11 May 2020 12:47:01 -0400 Subject: [PATCH 005/505] #4624: Refactor ObjectDeleteView to use a queryset --- netbox/circuits/views.py | 6 ++-- netbox/dcim/views.py | 52 +++++++++++++++++----------------- netbox/extras/views.py | 6 ++-- netbox/ipam/views.py | 12 ++++---- netbox/secrets/views.py | 2 +- netbox/tenancy/views.py | 2 +- netbox/utilities/views.py | 16 +++++------ netbox/virtualization/views.py | 6 ++-- 8 files changed, 51 insertions(+), 51 deletions(-) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 7fe2501fb..0546b3832 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -74,7 +74,7 @@ class ProviderEditView(ProviderCreateView): class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'circuits.delete_provider' - model = Provider + queryset = Provider.objects.all() default_return_url = 'circuits:provider_list' @@ -190,7 +190,7 @@ class CircuitEditView(CircuitCreateView): class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'circuits.delete_circuit' - model = Circuit + queryset = Circuit.objects.all() default_return_url = 'circuits:circuit_list' @@ -292,4 +292,4 @@ class CircuitTerminationEditView(CircuitTerminationCreateView): class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'circuits.delete_circuittermination' - model = CircuitTermination + queryset = CircuitTermination.objects.all() diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index c9cc6ac13..1f20d79f2 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -232,7 +232,7 @@ class SiteEditView(SiteCreateView): class SiteDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_site' - model = Site + queryset = Site.objects.all() default_return_url = 'dcim:site_list' @@ -438,7 +438,7 @@ class RackEditView(RackCreateView): class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_rack' - model = Rack + queryset = Rack.objects.all() default_return_url = 'dcim:rack_list' @@ -512,7 +512,7 @@ class RackReservationEditView(RackReservationCreateView): class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_rackreservation' - model = RackReservation + queryset = RackReservation.objects.all() default_return_url = 'dcim:rackreservation_list' @@ -678,7 +678,7 @@ class DeviceTypeEditView(DeviceTypeCreateView): class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_devicetype' - model = DeviceType + queryset = DeviceType.objects.all() default_return_url = 'dcim:devicetype_list' @@ -746,7 +746,7 @@ class ConsolePortTemplateEditView(PermissionRequiredMixin, ObjectEditView): class ConsolePortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_consoleporttemplate' - model = ConsolePortTemplate + queryset = ConsolePortTemplate.objects.all() class ConsolePortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): @@ -782,7 +782,7 @@ class ConsoleServerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView) class ConsoleServerPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_consoleserverporttemplate' - model = ConsoleServerPortTemplate + queryset = ConsoleServerPortTemplate.objects.all() class ConsoleServerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): @@ -818,7 +818,7 @@ class PowerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView): class PowerPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_powerporttemplate' - model = PowerPortTemplate + queryset = PowerPortTemplate.objects.all() class PowerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): @@ -854,7 +854,7 @@ class PowerOutletTemplateEditView(PermissionRequiredMixin, ObjectEditView): class PowerOutletTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_poweroutlettemplate' - model = PowerOutletTemplate + queryset = PowerOutletTemplate.objects.all() class PowerOutletTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): @@ -890,7 +890,7 @@ class InterfaceTemplateEditView(PermissionRequiredMixin, ObjectEditView): class InterfaceTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_interfacetemplate' - model = InterfaceTemplate + queryset = InterfaceTemplate.objects.all() class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): @@ -926,7 +926,7 @@ class FrontPortTemplateEditView(PermissionRequiredMixin, ObjectEditView): class FrontPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_frontporttemplate' - model = FrontPortTemplate + queryset = FrontPortTemplate.objects.all() class FrontPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): @@ -962,7 +962,7 @@ class RearPortTemplateEditView(PermissionRequiredMixin, ObjectEditView): class RearPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_rearporttemplate' - model = RearPortTemplate + queryset = RearPortTemplate.objects.all() class RearPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): @@ -998,7 +998,7 @@ class DeviceBayTemplateEditView(PermissionRequiredMixin, ObjectEditView): class DeviceBayTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_devicebaytemplate' - model = DeviceBayTemplate + queryset = DeviceBayTemplate.objects.all() # class DeviceBayTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): @@ -1259,7 +1259,7 @@ class DeviceEditView(DeviceCreateView): class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_device' - model = Device + queryset = Device.objects.all() default_return_url = 'dcim:device_list' @@ -1336,7 +1336,7 @@ class ConsolePortEditView(PermissionRequiredMixin, ObjectEditView): class ConsolePortDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_consoleport' - model = ConsolePort + queryset = ConsolePort.objects.all() class ConsolePortBulkImportView(PermissionRequiredMixin, BulkImportView): @@ -1391,7 +1391,7 @@ class ConsoleServerPortEditView(PermissionRequiredMixin, ObjectEditView): class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_consoleserverport' - model = ConsoleServerPort + queryset = ConsoleServerPort.objects.all() class ConsoleServerPortBulkImportView(PermissionRequiredMixin, BulkImportView): @@ -1458,7 +1458,7 @@ class PowerPortEditView(PermissionRequiredMixin, ObjectEditView): class PowerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_powerport' - model = PowerPort + queryset = PowerPort.objects.all() class PowerPortBulkImportView(PermissionRequiredMixin, BulkImportView): @@ -1513,7 +1513,7 @@ class PowerOutletEditView(PermissionRequiredMixin, ObjectEditView): class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_poweroutlet' - model = PowerOutlet + queryset = PowerOutlet.objects.all() class PowerOutletBulkImportView(PermissionRequiredMixin, BulkImportView): @@ -1617,7 +1617,7 @@ class InterfaceEditView(PermissionRequiredMixin, ObjectEditView): class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_interface' - model = Interface + queryset = Interface.objects.all() class InterfaceBulkImportView(PermissionRequiredMixin, BulkImportView): @@ -1684,7 +1684,7 @@ class FrontPortEditView(PermissionRequiredMixin, ObjectEditView): class FrontPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_frontport' - model = FrontPort + queryset = FrontPort.objects.all() class FrontPortBulkImportView(PermissionRequiredMixin, BulkImportView): @@ -1751,7 +1751,7 @@ class RearPortEditView(PermissionRequiredMixin, ObjectEditView): class RearPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_rearport' - model = RearPort + queryset = RearPort.objects.all() class RearPortBulkImportView(PermissionRequiredMixin, BulkImportView): @@ -1820,7 +1820,7 @@ class DeviceBayEditView(PermissionRequiredMixin, ObjectEditView): class DeviceBayDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_devicebay' - model = DeviceBay + queryset = DeviceBay.objects.all() class DeviceBayPopulateView(PermissionRequiredMixin, View): @@ -2162,7 +2162,7 @@ class CableEditView(PermissionRequiredMixin, ObjectEditView): class CableDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_cable' - model = Cable + queryset = Cable.objects.all() default_return_url = 'dcim:cable_list' @@ -2323,7 +2323,7 @@ class InventoryItemCreateView(PermissionRequiredMixin, ComponentCreateView): class InventoryItemDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_inventoryitem' - model = InventoryItem + queryset = InventoryItem.objects.all() class InventoryItemBulkImportView(PermissionRequiredMixin, BulkImportView): @@ -2496,7 +2496,7 @@ class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View): class VirtualChassisDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_virtualchassis' - model = VirtualChassis + queryset = VirtualChassis.objects.all() default_return_url = 'dcim:device_list' @@ -2665,7 +2665,7 @@ class PowerPanelEditView(PowerPanelCreateView): class PowerPanelDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_powerpanel' - model = PowerPanel + queryset = PowerPanel.objects.all() default_return_url = 'dcim:powerpanel_list' @@ -2737,7 +2737,7 @@ class PowerFeedEditView(PowerFeedCreateView): class PowerFeedDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_powerfeed' - model = PowerFeed + queryset = PowerFeed.objects.all() default_return_url = 'dcim:powerfeed_list' diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 687ff69b0..e466414b6 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -75,7 +75,7 @@ class TagEditView(PermissionRequiredMixin, ObjectEditView): class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'extras.delete_tag' - model = Tag + queryset = Tag.objects.all() default_return_url = 'extras:tag_list' @@ -157,7 +157,7 @@ class ConfigContextBulkEditView(PermissionRequiredMixin, BulkEditView): class ConfigContextDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'extras.delete_configcontext' - model = ConfigContext + queryset = ConfigContext.objects.all() default_return_url = 'extras:configcontext_list' @@ -322,7 +322,7 @@ class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView): class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'extras.delete_imageattachment' - model = ImageAttachment + queryset = ImageAttachment.objects.all() def get_return_url(self, request, imageattachment): return imageattachment.parent.get_absolute_url() diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index bf94a4f74..92eb5b823 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -148,7 +148,7 @@ class VRFEditView(VRFCreateView): class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'ipam.delete_vrf' - model = VRF + queryset = VRF.objects.all() default_return_url = 'ipam:vrf_list' @@ -373,7 +373,7 @@ class AggregateEditView(AggregateCreateView): class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'ipam.delete_aggregate' - model = Aggregate + queryset = Aggregate.objects.all() default_return_url = 'ipam:aggregate_list' @@ -600,7 +600,7 @@ class PrefixEditView(PrefixCreateView): class PrefixDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'ipam.delete_prefix' - model = Prefix + queryset = Prefix.objects.all() template_name = 'ipam/prefix_delete.html' default_return_url = 'ipam:prefix_list' @@ -766,7 +766,7 @@ class IPAddressAssignView(PermissionRequiredMixin, View): class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'ipam.delete_ipaddress' - model = IPAddress + queryset = IPAddress.objects.all() default_return_url = 'ipam:ipaddress_list' @@ -945,7 +945,7 @@ class VLANEditView(VLANCreateView): class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'ipam.delete_vlan' - model = VLAN + queryset = VLAN.objects.all() default_return_url = 'ipam:vlan_list' @@ -1028,7 +1028,7 @@ class ServiceEditView(ServiceCreateView): class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'ipam.delete_service' - model = Service + queryset = Service.objects.all() class ServiceBulkEditView(PermissionRequiredMixin, BulkEditView): diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index 937b06edf..b40e41cb3 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -191,7 +191,7 @@ def secret_edit(request, pk): class SecretDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'secrets.delete_secret' - model = Secret + queryset = Secret.objects.all() default_return_url = 'secrets:secret_list' diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 148ac6c17..2af44094f 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -107,7 +107,7 @@ class TenantEditView(TenantCreateView): class TenantDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'tenancy.delete_tenant' - model = Tenant + queryset = Tenant.objects.all() default_return_url = 'tenancy:tenant_list' diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index cde38846e..076f2ad14 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -216,7 +216,7 @@ class ObjectEditView(GetReturnURLMixin, View): """ Create or edit a single object. - queryset: The base queryset for the model being modified + 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 """ @@ -304,18 +304,18 @@ class ObjectDeleteView(GetReturnURLMixin, View): """ Delete a single object. - model: The model of the object being deleted + queryset: The base queryset for the object being deleted template_name: The name of the template """ - model = None + queryset = None template_name = 'utilities/obj_delete.html' def get_object(self, kwargs): # Look up object by slug if one has been provided. Otherwise, use PK. if 'slug' in kwargs: - return get_object_or_404(self.model, slug=kwargs['slug']) + return get_object_or_404(self.queryset, slug=kwargs['slug']) else: - return get_object_or_404(self.model, pk=kwargs['pk']) + return get_object_or_404(self.queryset, pk=kwargs['pk']) def get(self, request, **kwargs): obj = self.get_object(kwargs) @@ -324,7 +324,7 @@ class ObjectDeleteView(GetReturnURLMixin, View): return render(request, self.template_name, { 'obj': obj, 'form': form, - 'obj_type': self.model._meta.verbose_name, + 'obj_type': self.queryset.model._meta.verbose_name, 'return_url': self.get_return_url(request, obj), }) @@ -343,7 +343,7 @@ class ObjectDeleteView(GetReturnURLMixin, View): handle_protectederror(obj, request, e) return redirect(obj.get_absolute_url()) - msg = 'Deleted {} {}'.format(self.model._meta.verbose_name, obj) + msg = 'Deleted {} {}'.format(self.queryset.model._meta.verbose_name, obj) logger.info(msg) messages.success(request, msg) @@ -359,7 +359,7 @@ class ObjectDeleteView(GetReturnURLMixin, View): return render(request, self.template_name, { 'obj': obj, 'form': form, - 'obj_type': self.model._meta.verbose_name, + 'obj_type': self.queryset.model._meta.verbose_name, 'return_url': self.get_return_url(request, obj), }) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 0a6c418fe..68a2443ae 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -132,7 +132,7 @@ class ClusterEditView(ClusterCreateView): class ClusterDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'virtualization.delete_cluster' - model = Cluster + queryset = Cluster.objects.all() default_return_url = 'virtualization:cluster_list' @@ -293,7 +293,7 @@ class VirtualMachineEditView(VirtualMachineCreateView): class VirtualMachineDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'virtualization.delete_virtualmachine' - model = VirtualMachine + queryset = VirtualMachine.objects.all() default_return_url = 'virtualization:virtualmachine_list' @@ -342,7 +342,7 @@ class InterfaceEditView(PermissionRequiredMixin, ObjectEditView): class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_interface' - model = Interface + queryset = Interface.objects.all() class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): From 63f842c7db791e68221e888e0c16403a0281ff93 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 11 May 2020 14:32:10 -0400 Subject: [PATCH 006/505] 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 b3f97995c..03e375d35 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 @@ class SiteView(PermissionRequiredMixin, View): }) -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 58fd4380a..850189a83 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -14,22 +14,14 @@ class ObjectPermissionRequiredMixin(AccessMixin): 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 f2002ae95..bb2093f05 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -7,6 +7,7 @@ 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 @@ -194,6 +195,38 @@ class Token(models.Model): 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 0d20fe02f..7deb9b0de 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -56,21 +56,12 @@ class ObjectPermissionBackend(ModelBackend): 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 076f2ad14..d9eace90b 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -4,7 +4,7 @@ from copy import deepcopy 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 django_tables2 import RequestConfig 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 @@ class ObjectEditView(GetReturnURLMixin, View): 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 007/505] 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 7deb9b0de..65154a6f8 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -3,8 +3,6 @@ import logging 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 @@ class ViewExemptModelBackend(ModelBackend): '*' 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 @@ class ObjectPermissionBackend(ModelBackend): 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 008/505] 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 850189a83..0b896969b 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 bb2093f05..452e91c21 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -197,16 +197,19 @@ class Token(models.Model): 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 @@ class ObjectPermissionManager(models.Manager): 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 65154a6f8..f4290e917 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -3,6 +3,8 @@ import logging 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 009/505] Reimplement the ViewExemptModelBackend to explicitly cache all exempted view permissions on the User instance --- netbox/utilities/auth_backends.py | 39 ++++++++++++++----------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py index f4290e917..49dd8d0aa 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -3,7 +3,6 @@ import logging 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): + def _get_user_permissions(self, user_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 - '.'.join((app, model)) in settings.EXEMPT_VIEW_PERMISSIONS - ): - return True - except ValueError: - pass + 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) - # 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) + 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 010/505] 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 0b896969b..2854d4cb9 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -28,7 +28,7 @@ class ObjectPermissionRequiredMixin(AccessMixin): 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 452e91c21..70e7254e6 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -213,9 +213,9 @@ class ObjectPermissionManager(models.Manager): **{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 f73fd8f43..487543bd3 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 tenancy.models import Tenant from users.models import ObjectPermission -class UserConfigTest(TestCase): +class ObjectPermissionTest(TestCase): def setUp(self): @@ -41,7 +41,7 @@ class UserConfigTest(TestCase): 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 @@ class UserConfigTest(TestCase): 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 49dd8d0aa..9e56fd16c 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -90,7 +90,7 @@ class ObjectPermissionBackend(ModelBackend): # 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 011/505] 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 2854d4cb9..d85b2f124 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -19,7 +19,7 @@ class ObjectPermissionRequiredMixin(AccessMixin): # 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 012/505] 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 0e04719f9..a67a5d60a 100644 --- a/netbox/netbox/api.py +++ b/netbox/netbox/api.py @@ -2,7 +2,7 @@ from django.conf import settings 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 @@ class TokenAuthentication(authentication.TokenAuthentication): 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 @@ class TokenPermissions(DjangoModelPermissions): 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 013/505] 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 9e56fd16c..46ec69458 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -34,6 +34,28 @@ class ViewExemptModelBackend(ModelBackend): 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 014/505] 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 205055669..405c26878 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -6,15 +6,15 @@ from django.conf import settings 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 @@ class ModelViewSet(_ModelViewSet): 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 015/505] 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 92eb5b823..0c7d0770f 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -8,6 +8,7 @@ from django.views.generic import View 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 @@ class PrefixListView(PermissionRequiredMixin, ObjectListView): 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 @@ class PrefixIPAddressesView(PermissionRequiredMixin, View): }) -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 42cddb082..59e4dcde4 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 @@ class ExternalAuthenticationTestCase(TestCase): 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 016/505] 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 d85b2f124..2e68e6ef1 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 5c48ee620..d265cc58c 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -333,8 +333,7 @@ TEMPLATES = [ # 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 59e4dcde4..18bf251d4 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -5,11 +5,12 @@ from django.test import Client 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 @@ class ExternalAuthenticationTestCase(TestCase): 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 @@ class ObjectPermissionTestCase(TestCase): 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 @@ class ObjectPermissionTestCase(TestCase): 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 @@ class ObjectPermissionTestCase(TestCase): # 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 @@ class ObjectPermissionTestCase(TestCase): 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 @@ class ObjectPermissionTestCase(TestCase): # 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 @@ class ObjectPermissionTestCase(TestCase): } 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 @@ class ObjectPermissionTestCase(TestCase): } 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 @@ class ObjectPermissionTestCase(TestCase): # 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 @@ class ObjectPermissionTestCase(TestCase): 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 @@ class ObjectPermissionTestCase(TestCase): # 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 46ec69458..e540a04e0 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -8,115 +8,89 @@ from django.db.models import Q 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 -class RemoteUserBackend(ViewExemptModelBackend, RemoteUserBackend_): + # 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(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 017/505] 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 18bf251d4..64dd83783 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -371,3 +371,124 @@ class ObjectPermissionViewTestCase(TestCase): 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 405c26878..9ec587369 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -329,11 +329,15 @@ class ModelViewSet(_ModelViewSet): 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 018/505] 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 d265cc58c..659eadb1c 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -333,8 +333,8 @@ TEMPLATES = [ # 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 019/505] 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 64dd83783..03d0a1dc3 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -460,35 +460,98 @@ class ObjectPermissionAPIViewTestCase(TestCase): 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 9ec587369..745f812ff 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -4,7 +4,8 @@ from collections import OrderedDict 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.response import Response 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 @@ class ModelViewSet(_ModelViewSet): 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 @@ class ModelViewSet(_ModelViewSet): **kwargs ) - def list(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().list(*args, **kwargs) - - def retrieve(self, *args, **kwargs): - """ - Call to super to allow for caching - """ - 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 020/505] 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 0c7d0770f..ace85bc1a 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 03d0a1dc3..d82ef6752 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -342,6 +342,14 @@ class ObjectPermissionViewTestCase(TestCase): '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 @@ class ObjectPermissionViewTestCase(TestCase): 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 d9eace90b..44dd40d90 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -297,9 +297,9 @@ class ObjectEditView(GetReturnURLMixin, View): 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 @@ class BulkImportView(GetReturnURLMixin, View): 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 @@ class BulkImportView(GetReturnURLMixin, View): 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 @@ class BulkEditView(GetReturnURLMixin, View): 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 @@ class BulkEditView(GetReturnURLMixin, View): obj.full_clean() obj.save() + updated_objects.append(obj) logger.debug(f"Saved {obj} (PK: {obj.pk})") # Update custom fields @@ -765,10 +777,12 @@ class BulkEditView(GetReturnURLMixin, View): 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 @@ class BulkEditView(GetReturnURLMixin, View): 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 021/505] 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 0546b3832..c3b09f596 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 03e375d35..d6b97e128 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 @@ class DeviceBayDepopulateView(PermissionRequiredMixin, View): 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 ace85bc1a..ab97afc2a 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 @@ class ServiceCreateView(PermissionRequiredMixin, ObjectEditView): 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 b40e41cb3..8ce9addb4 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 2af44094f..745362271 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 44dd40d90..01eb6d2ba 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 68a2443ae..c6f107be7 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 022/505] 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 d6b97e128..2bcf876c6 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -21,13 +21,12 @@ from extras.models import Graph 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 ab97afc2a..bb0844d4d 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -8,10 +8,10 @@ from django.views.generic import View 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 2e68e6ef1..000000000 --- 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 000000000..516d6fe5b --- /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 01eb6d2ba..6097fa5b2 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -4,7 +4,8 @@ from copy import deepcopy 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 .forms import ConfirmationForm, ImportForm 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 @@ class GetReturnURLMixin(object): 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 023/505] 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 c3b09f596..e3f347398 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -23,8 +23,7 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider # 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 2bcf876c6..9faad490e 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -141,8 +141,7 @@ class BulkDisconnectView(GetReturnURLMixin, View): # 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 @@ class ConsoleConnectionsListView(PermissionRequiredMixin, ObjectListView): 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 @@ class PowerConnectionsListView(PermissionRequiredMixin, ObjectListView): 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 @@ class InterfaceConnectionsListView(PermissionRequiredMixin, ObjectListView): # 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 e466414b6..c1bee4dd7 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -25,8 +25,7 @@ from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemT # 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 @@ class ObjectConfigContextView(View): # 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 bb0844d4d..09c3f7892 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 @@ class VLANGroupVLANsView(PermissionRequiredMixin, View): # 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 8ce9addb4..eda845375 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 745362271..b4e37d153 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -18,8 +18,7 @@ from .models import Tenant, TenantGroup # 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 6097fa5b2..8b4efeb5a 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -27,6 +27,7 @@ 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.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 @@ class ObjectPermissionRequiredMixin(AccessMixin): # 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 @@ class GetReturnURLMixin(object): # 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 c6f107be7..85dbf4774 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -22,8 +22,7 @@ from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine # 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 @@ class ClusterRemoveDevicesView(PermissionRequiredMixin, View): # 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 024/505] 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 72d9720df..1a7fa283b 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -10,7 +10,7 @@ urlpatterns = [ # 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 @@ urlpatterns = [ # 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 @@ urlpatterns = [ # 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 @@ urlpatterns = [ # 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 e3f347398..59cdac930 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -59,18 +59,13 @@ class ProviderView(PermissionRequiredMixin, View): }) -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 @@ class CircuitView(PermissionRequiredMixin, View): }) -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 @@ class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView): 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 0b1f6250e..a0d6bdc92 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 @@ urlpatterns = [ # 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 @@ urlpatterns = [ # 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 @@ urlpatterns = [ # 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 @@ urlpatterns = [ # 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 @@ urlpatterns = [ # 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 @@ urlpatterns = [ # 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 @@ urlpatterns = [ # 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 @@ urlpatterns = [ # 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 @@ urlpatterns = [ # 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 @@ urlpatterns = [ # 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 @@ urlpatterns = [ # 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 @@ urlpatterns = [ 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 @@ urlpatterns = [ # 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 @@ urlpatterns = [ # 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 9faad490e..e33f3bd04 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 @@ class SiteView(ObjectPermissionRequiredMixin, View): }) -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 @@ class RackView(PermissionRequiredMixin, View): }) -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 @@ class RackReservationView(PermissionRequiredMixin, View): }) -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 @@ class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView): 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 @@ class DeviceTypeView(PermissionRequiredMixin, View): }) -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 @@ class CableCreateView(PermissionRequiredMixin, GetReturnURLMixin, View): }) -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 @@ class PowerPanelView(PermissionRequiredMixin, View): }) -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 @@ class PowerFeedView(PermissionRequiredMixin, View): }) -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 a486ce7fc..3eee303a3 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -18,7 +18,7 @@ urlpatterns = [ # 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 c1bee4dd7..b5d9306f8 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -64,8 +64,7 @@ class TagView(PermissionRequiredMixin, View): }) -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 @@ class ConfigContextView(PermissionRequiredMixin, View): }) -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 @@ class ObjectChangeLogView(View): # 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 f1211473e..de8fc86eb 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -9,7 +9,7 @@ urlpatterns = [ # 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 @@ urlpatterns = [ # 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 @@ urlpatterns = [ # 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 @@ urlpatterns = [ # 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 @@ urlpatterns = [ # 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 @@ urlpatterns = [ # 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 @@ urlpatterns = [ # 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 @@ urlpatterns = [ # 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 09c3f7892..220205c19 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -134,18 +134,13 @@ class VRFView(PermissionRequiredMixin, View): }) -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 @@ class RIRListView(ObjectListView): 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 @@ class AggregateView(PermissionRequiredMixin, View): }) -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 @@ class PrefixIPAddressesView(PermissionRequiredMixin, View): }) -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 @@ class IPAddressView(PermissionRequiredMixin, View): }) -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 @@ class IPAddressCreateView(PermissionRequiredMixin, ObjectEditView): 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 @@ class VLANMembersView(PermissionRequiredMixin, View): }) -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 @@ class ServiceView(PermissionRequiredMixin, View): }) -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 a19ec6ae0..ac75a7ed4 100644 --- a/netbox/secrets/urls.py +++ b/netbox/secrets/urls.py @@ -9,7 +9,7 @@ urlpatterns = [ # 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 eda845375..be0c87cee 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 0218a5674..4c65ce4e8 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -9,7 +9,7 @@ urlpatterns = [ # 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 @@ urlpatterns = [ # 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 b4e37d153..4dbc99815 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 @@ class TenantView(PermissionRequiredMixin, View): }) -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 8b4efeb5a..127e0daeb 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -278,7 +278,7 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): 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 557f8a9ca..38ad1a8b1 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 @@ urlpatterns = [ # 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 @@ urlpatterns = [ # 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 @@ urlpatterns = [ # 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 @@ urlpatterns = [ # 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 @@ urlpatterns = [ 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 85dbf4774..11090def8 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 @@ class ClusterView(PermissionRequiredMixin, View): }) -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 025/505] 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 127e0daeb..9815018b7 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 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): 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 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): 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 026/505] 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 59cdac930..7016a5b9d 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -3,7 +3,7 @@ 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, 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 @@ class CircuitTerminationEditView(ObjectEditView): 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 e33f3bd04..d61d0f82f 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 @@ class RackReservationEditView(ObjectEditView): 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 @@ class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View): }) -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 b5d9306f8..63764b683 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 @@ class ImageAttachmentEditView(ObjectEditView): 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 220205c19..176321982 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 @@ class IPAddressAssignView(PermissionRequiredMixin, View): }) -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 be0c87cee..7c69d0ac4 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 4dbc99815..97480bb6a 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 9815018b7..f4267748f 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 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): }) -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 11090def8..8bc3876ca 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 027/505] 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 7016a5b9d..7ee6a7dc1 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 d61d0f82f..d1882359d 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 @@ class DeviceBayDepopulateView(PermissionRequiredMixin, View): }) -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 176321982..dbd45b923 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 @@ class ServiceEditView(ObjectEditView): 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 7c69d0ac4..00794f684 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 97480bb6a..f666e606a 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 f4267748f..3d11cf25b 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -652,7 +652,7 @@ class ObjectImportView(GetReturnURLMixin, View): }) -class BulkImportView(GetReturnURLMixin, View): +class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Import objects in bulk (CSV format). @@ -684,6 +684,9 @@ class BulkImportView(GetReturnURLMixin, View): """ 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 8bc3876ca..de4569b83 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 028/505] 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 7ee6a7dc1..3dc7032e4 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 d1882359d..8e2355a9c 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 @@ class RackReservationImportView(BulkImportView): 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 @@ class ChildDeviceBulkImportView(BulkImportView): 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 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin, }) -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 63764b683..3aadbda98 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 dbd45b923..ba4b310ef 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 d82ef6752..39e82df61 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -463,7 +463,7 @@ class ObjectPermissionViewTestCase(TestCase): '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 00794f684..877133619 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -235,8 +235,7 @@ class SecretBulkImportView(BulkImportView): }) -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 f666e606a..fdfcbd7f5 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 3d11cf25b..1c8ceb525 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -755,7 +755,7 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): }) -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 @@ class BulkEditView(GetReturnURLMixin, View): # 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 @@ class BulkEditView(GetReturnURLMixin, View): 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 de4569b83..e565832d8 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 029/505] 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 3dc7032e4..e3260431f 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 8e2355a9c..5559d577c 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 3aadbda98..0a3796a28 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 ba4b310ef..19d38be5d 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 877133619..dbcf72262 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 fdfcbd7f5..3de321301 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 1c8ceb525..6a1086c94 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -911,7 +911,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): }) -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 e565832d8..898648f90 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 030/505] 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 a6e50f71e..3b9c1053d 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 e3260431f..1f5f05230 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 5559d577c..fb60b6b31 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -26,7 +26,7 @@ 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, 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 @@ class RackElevationListView(PermissionRequiredMixin, View): }) -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 @@ class DeviceView(PermissionRequiredMixin, View): }) -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 @@ class DeviceInventoryView(PermissionRequiredMixin, View): }) -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 @@ class DeviceStatusView(PermissionRequiredMixin, View): 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 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View): 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 0a3796a28..78db8f24a 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -13,7 +13,7 @@ from django_tables2 import RequestConfig 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 19d38be5d..706f819cc 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -10,8 +10,8 @@ from django_tables2 import RequestConfig 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 @@ class AggregateListView(ObjectListView): } -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 @@ class PrefixListView(ObjectListView): 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 @@ class PrefixView(ObjectPermissionRequiredMixin, View): }) -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 @@ class PrefixPrefixesView(PermissionRequiredMixin, View): }) -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 @@ class VLANView(PermissionRequiredMixin, View): }) -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 dbcf72262..a2e627a7c 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -9,7 +9,8 @@ from django.urls import reverse 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 3de321301..823df9933 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 6a1086c94..bd612b4df 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -118,6 +118,18 @@ class GetReturnURLMixin(object): # 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 898648f90..53fcf9697 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -11,8 +11,8 @@ from dcim.tables import DeviceTable 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 031/505] 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 706f819cc..476943b13 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 bd612b4df..ba1c18acc 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -468,20 +468,25 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): }) -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 @@ class BulkCreateView(GetReturnURLMixin, View): 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 @@ class BulkCreateView(GetReturnURLMixin, View): # 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 @@ class BulkCreateView(GetReturnURLMixin, View): 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 032/505] 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 ba1c18acc..cc0c7596d 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 033/505] 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 fb60b6b31..34a482da8 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -23,6 +23,7 @@ from ipam.models import Prefix, VLAN 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 @@ from .models import ( ) -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 @@ class BulkRenameView(GetReturnURLMixin, View): 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 @@ class BulkRenameView(GetReturnURLMixin, View): 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 @@ class BulkDisconnectView(GetReturnURLMixin, View): 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 034/505] 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 34a482da8..41269d0e0 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 @@ class DeviceStatusView(ObjectView): }) -class DeviceLLDPNeighborsView(PermissionRequiredMixin, View): +class DeviceLLDPNeighborsView(ObjectView): permission_required = ('dcim.view_device', 'dcim.napalm_read') queryset = Device.objects.all() @@ -1123,7 +1114,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View): }) -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 @@ class InterfaceView(ObjectView): }) -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 cc0c7596d..c008b0501 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -1033,28 +1033,32 @@ class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): # # 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 @@ class ComponentCreateView(GetReturnURLMixin, View): 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 53fcf9697..f7cf523d9 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 035/505] 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 41269d0e0..29d5498c6 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -94,7 +94,7 @@ class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): }) -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 036/505] 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 29d5498c6..0f5ea01a9 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 c008b0501..87f63678a 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -1118,14 +1118,14 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View }) -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 @@ class BulkComponentCreateView(GetReturnURLMixin, View): 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 f7cf523d9..e6d4f4946 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 037/505] 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 1a7fa283b..1c0f0715b 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -37,10 +37,9 @@ urlpatterns = [ 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 1f5f05230..bb4d787c8 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() - 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) + def get(self, request, pk): + circuit = get_object_or_404(self.queryset, pk=pk) + form = ConfirmationForm() - if request.method == 'POST': + # 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(), + }) + + 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 038/505] 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 476943b13..14c6a6864 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 dcim.models import Device, Interface 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 @@ class IPAddressEditView(ObjectEditView): 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 @@ class IPAddressAssignView(PermissionRequiredMixin, View): return super().dispatch(request, *args, **kwargs) def get(self, request): - form = forms.IPAddressAssignForm() return render(request, 'ipam/ipaddress_assign.html', { @@ -696,13 +694,12 @@ class IPAddressAssignView(PermissionRequiredMixin, View): }) 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 039/505] 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 e2f44ac90..000000000 --- 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 ac75a7ed4..84c2da398 100644 --- a/netbox/secrets/urls.py +++ b/netbox/secrets/urls.py @@ -17,12 +17,12 @@ urlpatterns = [ # 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 a2e627a7c..a5aabaecd 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 @@ class SecretView(ObjectView): }) -@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' - secret = Secret() - session_key = get_session_key(request) + 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) + + 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) + 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)) - else: - initial_data = { - 'device': request.GET.get('device'), - } - form = forms.SecretForm(initial=initial_data) + return redirect(self.get_return_url(request, secret)) - return render(request, 'secrets/secret_edit.html', { - 'secret': secret, - 'form': form, - 'return_url': GetReturnURLMixin().get_return_url(request, secret) - }) + else: + logger.debug("Form validation failed") - -@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.") - - # 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.") - - # 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.") - - # 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 cb3935521..6893e2d14 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 @@
Secret Data
- {% if secret.pk and secret|decryptable_by:request.user %} + {% if obj.pk and obj|decryptable_by:request.user %}
-

********

+

********

- -
@@ -69,9 +69,9 @@
- {% 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 040/505] 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 e6d4f4946..20cd5e9b1 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -4,7 +4,6 @@ from django.db import transaction 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 @@ class ClusterAddDevicesView(PermissionRequiredMixin, View): }) 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 @@ class ClusterAddDevicesView(PermissionRequiredMixin, View): }) -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 041/505] 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 0f5ea01a9..d7e0a336a 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 042/505] 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 d7e0a336a..733571369 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 @@ class DeviceBayPopulateView(PermissionRequiredMixin, View): }) 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 @@ class DeviceBayPopulateView(PermissionRequiredMixin, View): }) -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 @@ class DeviceBayDepopulateView(PermissionRequiredMixin, View): 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 043/505] 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 733571369..f55d9fd96 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1127,9 +1127,8 @@ class DeviceConfigView(ObjectView): }) -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 78db8f24a..77e5cb0e0 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 20cd5e9b1..79a807c21 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 @@ class VirtualMachineView(ObjectView): }) -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 044/505] 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 f55d9fd96..3c0010859 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1857,15 +1857,21 @@ class CableView(ObjectView): }) -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() - obj = get_object_or_404(model, pk=pk) + return super().dispatch(request, *args, **kwargs) + + def get(self, request, 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 045/505] 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 3c0010859..2dfe0f207 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1082,7 +1082,7 @@ class DeviceInventoryView(ObjectView): 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 @@ class DeviceStatusView(ObjectView): 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 @@ class DeviceLLDPNeighborsView(ObjectView): 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 @@ class CableView(ObjectView): }) -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 @@ class ConsoleConnectionsListView(ObjectListView): 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 14c6a6864..d3b604be6 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -671,7 +671,7 @@ class IPAddressEditView(ObjectEditView): 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 87f63678a..b586342e1 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 046/505] Test object permissions for individual/list model views --- netbox/utilities/testing/testcases.py | 294 ++++++++++++++++++++------ netbox/utilities/views.py | 1 + 2 files changed, 233 insertions(+), 62 deletions(-) diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py index de8b93232..f6b5cdfd4 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.test import Client, TestCase as _TestCase, override_settings 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 ViewTestCases: 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 ViewTestCases: 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), + } + 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): + 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()) + 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), - 'follow': False, # Do not follow 302 redirects } - response = self.client.post(**request) - self.assertHttpStatus(response, 302) - - # Validate object creation - self.assertEqual(initial_count + 1, self.model.objects.count()) - instance = self.model.objects.order_by('-pk').first() - self.assertInstanceEqual(instance, 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 ViewTestCases: 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 with permission + # Try POST without 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) + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.post(**request), 403) - # Validate object modifications - instance = self.model.objects.get(pk=instance.pk) - self.assertInstanceEqual(instance, 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): + 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) + + # 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) - # Validate object deletion + @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): + 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 @@ class ViewTestCases: 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 b586342e1..5bba3fbe9 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -347,6 +347,7 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): 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 047/505] 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 f6b5cdfd4..1da5e28ac 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -488,7 +488,7 @@ class ViewTestCases: 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 ViewTestCases: EditObjectViewTestCase, DeleteObjectViewTestCase, ListObjectsViewTestCase, - ImportObjectsViewTestCase, + BulkImportObjectsViewTestCase, BulkEditObjectsViewTestCase, BulkDeleteObjectsViewTestCase, ): @@ -611,7 +611,7 @@ class ViewTestCases: CreateObjectViewTestCase, EditObjectViewTestCase, ListObjectsViewTestCase, - ImportObjectsViewTestCase, + BulkImportObjectsViewTestCase, BulkDeleteObjectsViewTestCase, ): """ @@ -636,7 +636,7 @@ class ViewTestCases: 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 048/505] Extend bulk import/edit/delete view tests to support object-level permissions --- netbox/utilities/testing/testcases.py | 214 ++++++++++++++++++-------- 1 file changed, 152 insertions(+), 62 deletions(-) diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py index 1da5e28ac..ca9df4ac8 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -232,12 +232,11 @@ class ViewTestCases: @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 @@ class ViewTestCases: 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 @@ class ViewTestCases: 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 ViewTestCases: """ 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 - initial_count = self.model.objects.count() - request = { - 'path': self._get_url('import'), - 'data': { - 'csv': '\n'.join(self.csv_data) - } - } - response = self.client.post(**request) - self.assertHttpStatus(response, 200) - - # Validate import of new objects + 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() + data = { + 'csv': self._get_csv_data(), + } + + # 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 ViewTestCases: 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 the required permission and submit again - self.add_permissions( - '{}.change_{}'.format(self.model._meta.app_label, self.model._meta.model_name) - ) - response = self.client.post(**request) - self.assertHttpStatus(response, 302) + # 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): + 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 + ) + 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) - ) - response = self.client.post(**request) - self.assertHttpStatus(response, 302) + # Try POST without permission + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 403) - # Check that all objects were deleted + @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 + ) + obj_perm.save() + obj_perm.users.add(self.user) + + # 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 049/505] 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 65f37c1d5..ef8bd3d5f 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 370055b26..f52054cc1 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 8867a6b43..bbd252473 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 96439a10d..7796be63d 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 ca9df4ac8..475cdb09f 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -351,7 +351,7 @@ class ViewTestCases: # 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 e7bb19285..006db34d6 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 050/505] 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 266f1afd7..f4ee6fff2 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -339,7 +339,7 @@ TEMPLATES = [ # 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 d805c3379..1fadcc9a5 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 @@ 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'), + ('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 70e7254e6..b9ab6cbb5 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 @@ class ObjectPermission(models.Model): # 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 e540a04e0..8cf8b621c 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 @@ class ObjectPermissionBackend(ModelBackend): ): 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 6f878986b..3d0ad1ef3 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -159,15 +159,15 @@ class ViewTestCases: 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 @@ class ViewTestCases: 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 @@ class ViewTestCases: 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 @@ class ViewTestCases: 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 @@ class ViewTestCases: 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 @@ class ViewTestCases: 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 @@ class ViewTestCases: 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 @@ class ViewTestCases: 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 cbedecd4d..6e93c2369 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -8,7 +8,7 @@ from django.contrib.contenttypes.models import ContentType 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 @@ class ObjectPermissionRequiredMixin(AccessMixin): 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 051/505] 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 f4ee6fff2..266f1afd7 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -339,7 +339,7 @@ TEMPLATES = [ # 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 8cf8b621c..3d5ec1830 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 @@ class ObjectPermissionBackend(ModelBackend): 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 052/505] 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 487543bd3..000000000 --- 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 053/505] 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 745f812ff..2d7ae2385 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -340,12 +340,11 @@ class ModelViewSet(_ModelViewSet): 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 3d5ec1830..bcf2fa119 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -38,18 +38,6 @@ class ObjectPermissionBackend(ModelBackend): 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 @@ class ObjectPermissionBackend(ModelBackend): 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 054/505] 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 c94d8cd3f..4df06e12f 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.forms import SiteCSVForm 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 @@ class CustomFieldChoiceAPITest(APITestCase): 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 266f1afd7..3b345638b 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -338,8 +338,8 @@ TEMPLATES = [ # 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 bcf2fa119..41d7033af 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 @@ class ObjectPermissionBackend(ModelBackend): 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 @@ class RemoteUserBackend(BaseBackend): "._. (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 3d0ad1ef3..8346f5d04 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -33,18 +33,31 @@ class TestCase(_TestCase): 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 @@ class ViewTestCases: 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 055/505] 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 339c370d8..c21ac9d72 100644 --- a/netbox/secrets/tests/test_api.py +++ b/netbox/secrets/tests/test_api.py @@ -122,18 +122,15 @@ class SecretRoleTest(APITestCase): 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 056/505] 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 2d7ae2385..41002dd20 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -6,7 +6,7 @@ from django.conf import settings 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 @@ class ModelViewSet(_ModelViewSet): } 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 41d7033af..6d34678be 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -80,7 +80,12 @@ class ObjectPermissionBackend(ModelBackend): 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 057/505] 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 830e91096..61d8adb6b 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 c21ac9d72..8d716a465 100644 --- a/netbox/secrets/tests/test_api.py +++ b/netbox/secrets/tests/test_api.py @@ -5,8 +5,7 @@ from rest_framework import status 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 @@ class SecretRoleTest(APITestCase): 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 @@ class SecretTest(APITestCase): 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 @@ class SecretTest(APITestCase): # 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 @@ class SecretTest(APITestCase): '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 @@ class SecretTest(APITestCase): self.assertEqual(secret4.plaintext, data['plaintext']) def test_create_secret_bulk(self): - data = [ { 'device': self.device.pk, @@ -247,6 +252,9 @@ class SecretTest(APITestCase): }, ] + # 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 @@ class SecretTest(APITestCase): 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 @@ class SecretTest(APITestCase): 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 058/505] 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 6d34678be..99e4f559a 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -2,7 +2,8 @@ import logging 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 @@ class RemoteUserBackend(_RemoteUserBackend): 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 059/505] 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 39e82df61..74f4c411a 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -201,13 +201,11 @@ class ObjectPermissionViewTestCase(TestCase): 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 @@ class ObjectPermissionViewTestCase(TestCase): 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 @@ class ObjectPermissionViewTestCase(TestCase): 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 @@ class ObjectPermissionViewTestCase(TestCase): 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 @@ class ObjectPermissionViewTestCase(TestCase): 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 @@ class ObjectPermissionViewTestCase(TestCase): 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 @@ class ObjectPermissionViewTestCase(TestCase): 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 @@ class ObjectPermissionViewTestCase(TestCase): 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 @@ class ObjectPermissionAPIViewTestCase(TestCase): 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 @@ class ObjectPermissionAPIViewTestCase(TestCase): 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 @@ class ObjectPermissionAPIViewTestCase(TestCase): 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 @@ class ObjectPermissionAPIViewTestCase(TestCase): 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 @@ class ObjectPermissionAPIViewTestCase(TestCase): 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 99e4f559a..bb705a6df 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -122,14 +122,10 @@ class RemoteUserBackend(_RemoteUserBackend): 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 8346f5d04..86f465364 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 django.urls import reverse, NoReverseMatch 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 @@ class TestCase(_TestCase): 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 @@ class TestCase(_TestCase): 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 060/505] 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 b9ab6cbb5..17c5a3a65 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -12,6 +12,7 @@ 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 @@ -202,11 +203,9 @@ class ObjectPermissionManager(models.Manager): 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 bb705a6df..a490115bb 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -7,6 +7,7 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import Q from users.models import ObjectPermission +from utilities.permissions import resolve_permission class ObjectPermissionBackend(ModelBackend): @@ -40,7 +41,6 @@ class ObjectPermissionBackend(ModelBackend): 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 @@ class RemoteUserBackend(_RemoteUserBackend): 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 516d6fe5b..80d564db4 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 86f465364..a505e6e03 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -7,6 +7,7 @@ from django.urls import reverse, NoReverseMatch 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 @@ class TestCase(_TestCase): 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 @@ class TestCase(_TestCase): 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 061/505] 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 17c5a3a65..721ca2f26 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -17,6 +17,7 @@ from utilities.utils import flatten_dict __all__ = ( + 'ObjectPermission', 'Token', 'UserConfig', ) diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py index a505e6e03..e665b2277 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -155,6 +155,13 @@ class ViewTestCases: """ 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 @@ class ViewTestCases: 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 @@ class ViewTestCases: # 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 @@ class ViewTestCases: 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 @@ class ViewTestCases: 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 @@ class ViewTestCases: 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 ViewTestCases: """ 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 @@ class ViewTestCases: 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 @@ class ViewTestCases: # 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 @@ class ViewTestCases: 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 @@ class ViewTestCases: 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 @@ class ViewTestCases: 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 006db34d6..067606648 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 062/505] 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 6e28700c8..cfbb2b95f 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -321,7 +321,16 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase): ) -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 @@ class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase } -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 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): ) -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 @@ class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase): } -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 f52054cc1..6d41886fc 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -10,17 +10,17 @@ from extras.models import ConfigContext, ObjectChange, Tag 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 @@ class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase): } -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 bbd252473..794284dba 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -333,14 +333,18 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): } -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 7796be63d..577ba4ef4 100644 --- a/netbox/secrets/tests/test_views.py +++ b/netbox/secrets/tests/test_views.py @@ -36,19 +36,16 @@ class SecretRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): ) -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 067606648..9fde12186 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -185,21 +185,17 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): } +# 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 063/505] 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 6e93c2369..e73a55dc7 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -66,7 +66,7 @@ class ObjectPermissionRequiredMixin(AccessMixin): 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 064/505] Expose assigned ObjectPermissions on User instance --- netbox/utilities/auth_backends.py | 49 ++++++++++++++++--------------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py index a490115bb..ecb3ea652 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -3,7 +3,6 @@ import logging 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 @@ from utilities.permissions import 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() + 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') - # 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] - # 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 + return perms def has_perm(self, user_obj, perm, obj=None): app_label, codename = perm.split('.') @@ -64,7 +65,7 @@ class ObjectPermissionBackend(ModelBackend): 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 @@ class ObjectPermissionBackend(ModelBackend): 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 065/505] 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 721ca2f26..cf2ee3953 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -197,29 +197,6 @@ class Token(models.Model): 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 066/505] 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 fcaeb4ef0..8ea33514a 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 067/505] 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 8ea33514a..e13904eea 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -3,8 +3,14 @@ from django.contrib import admin 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 1fadcc9a5..da176dd5d 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 cf2ee3953..6de7bf01a 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 068/505] 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 74f4c411a..ad900bdc0 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -201,11 +201,13 @@ class ObjectPermissionViewTestCase(TestCase): 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 @@ class ObjectPermissionViewTestCase(TestCase): 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 @@ class ObjectPermissionViewTestCase(TestCase): 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 @@ class ObjectPermissionViewTestCase(TestCase): 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 @@ class ObjectPermissionViewTestCase(TestCase): 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 @@ class ObjectPermissionViewTestCase(TestCase): 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 @@ class ObjectPermissionViewTestCase(TestCase): 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 @@ class ObjectPermissionViewTestCase(TestCase): 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 @@ class ObjectPermissionAPIViewTestCase(TestCase): 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 @@ class ObjectPermissionAPIViewTestCase(TestCase): 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 @@ class ObjectPermissionAPIViewTestCase(TestCase): 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 @@ class ObjectPermissionAPIViewTestCase(TestCase): 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 @@ class ObjectPermissionAPIViewTestCase(TestCase): 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 e13904eea..89aa3f49a 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -89,8 +89,8 @@ class ObjectPermissionForm(forms.ModelForm): 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 da176dd5d..2052ffbb2 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 6de7bf01a..bddae2ff7 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 ecb3ea652..36796194e 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -26,18 +26,19 @@ class ObjectPermissionBackend(ModelBackend): 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 @@ class RemoteUserBackend(_RemoteUserBackend): 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 e665b2277..cde394422 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -34,21 +34,10 @@ class TestCase(_TestCase): """ 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 @@ class ViewTestCases: 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 @@ class ViewTestCases: 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 @@ class ViewTestCases: 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 @@ class ViewTestCases: # 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 @@ class ViewTestCases: 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 @@ class ViewTestCases: # 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 @@ class ViewTestCases: 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 @@ class ViewTestCases: # 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 @@ class ViewTestCases: 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 @@ class ViewTestCases: # 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 @@ class ViewTestCases: 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 @@ class ViewTestCases: } # 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 @@ class ViewTestCases: # 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 @@ class ViewTestCases: 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 @@ class ViewTestCases: # 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 @@ class ViewTestCases: } # 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 @@ class ViewTestCases: # 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 069/505] 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 89aa3f49a..9482efd5c 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 @@ from .models import ObjectPermission, Token, UserConfig # # 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 2052ffbb2..000000000 --- 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 000000000..4a72eedd2 --- /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 bddae2ff7..d2a4a152a 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 @@ __all__ = ( ) +# +# 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 @@ class Token(models.Model): 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 070/505] 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 ad900bdc0..bef8f004a 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -203,7 +203,7 @@ class ObjectPermissionViewTestCase(TestCase): # 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 @@ class ObjectPermissionViewTestCase(TestCase): # 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 @@ class ObjectPermissionViewTestCase(TestCase): # 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 @@ class ObjectPermissionViewTestCase(TestCase): # 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 @@ class ObjectPermissionViewTestCase(TestCase): # 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 @@ class ObjectPermissionViewTestCase(TestCase): # 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 @@ class ObjectPermissionViewTestCase(TestCase): # 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 @@ class ObjectPermissionViewTestCase(TestCase): # 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 @@ class ObjectPermissionAPIViewTestCase(TestCase): # 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 @@ class ObjectPermissionAPIViewTestCase(TestCase): # 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 @@ class ObjectPermissionAPIViewTestCase(TestCase): # 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 @@ class ObjectPermissionAPIViewTestCase(TestCase): # 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 @@ class ObjectPermissionAPIViewTestCase(TestCase): # 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 9482efd5c..507b75869 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -2,9 +2,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 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(StockGroup) 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 @@ class ObjectPermissionForm(forms.ModelForm): 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 4a72eedd2..dfd0512bd 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 000000000..f2ecb98b0 --- /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 d2a4a152a..1c8775699 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 @@ __all__ = ( # Proxy models for admin # -class Group(Group_): +class AdminGroup(Group): """ Proxy contrib.auth.models.Group for the admin UI """ @@ -33,7 +32,7 @@ class Group(Group_): 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 36796194e..bc263480f 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -32,13 +32,12 @@ class ObjectPermissionBackend(ModelBackend): 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 @@ class RemoteUserBackend(_RemoteUserBackend): 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 cde394422..3514f9060 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -34,7 +34,7 @@ class TestCase(_TestCase): """ 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 @@ class ViewTestCases: # 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 @@ class ViewTestCases: # 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 @@ class ViewTestCases: # 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 @@ class ViewTestCases: # 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 @@ class ViewTestCases: # 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 @@ class ViewTestCases: # 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 @@ class ViewTestCases: # 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 @@ class ViewTestCases: # 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 @@ class ViewTestCases: # 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 @@ class ViewTestCases: # 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 @@ class ViewTestCases: 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 @@ class ViewTestCases: # 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 @@ class ViewTestCases: # 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 @@ class ViewTestCases: # 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 @@ class ViewTestCases: # 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 @@ class ViewTestCases: # 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 @@ class ViewTestCases: # 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 071/505] 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 507b75869..e76150fc4 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 @@ class ObjectPermissionForm(forms.ModelForm): @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 072/505] 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 000000000..ba0663a0c --- /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 073/505] 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 ba0663a0c..c5e4d364c 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 074/505] 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 e76150fc4..c1b659a8e 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -107,7 +107,7 @@ class ObjectPermissionForm(forms.ModelForm): 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 075/505] 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 41002dd20..ef2650535 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -6,7 +6,7 @@ from django.conf import settings 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.serializers import Field, ModelSerializer, ValidationError 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 @@ class ModelViewSet(_ModelViewSet): 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 80d564db4..be5c0189e 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 e73a55dc7..a86b5ccc5 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -8,7 +8,7 @@ from django.contrib.contenttypes.models import ContentType 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 django_tables2 import RequestConfig 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 @@ class ObjectPermissionRequiredMixin(AccessMixin): # 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 076/505] 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 ef2650535..ac21d298c 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -16,7 +16,6 @@ from rest_framework.serializers import Field, ModelSerializer, ValidationError 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 @@ class ModelViewSet(_ModelViewSet): } 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 be5c0189e..697e18828 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 34b7a0cf3..36460310e 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 @@ class DummyQuerySet: 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 a86b5ccc5..fed774812 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -28,7 +28,7 @@ from extras.models import CustomField, CustomFieldValue, ExportTemplate 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 @@ class ObjectPermissionRequiredMixin(AccessMixin): # 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 077/505] 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 57d41a994..dcf1c5118 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -8,6 +8,7 @@ from dcim.fields import ASNField 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 60956f32a..8a9bd50a4 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 1f6478119..3dd3b8c89 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -25,7 +25,9 @@ from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, Ta 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 164d37d77..e412a602e 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -6,6 +6,7 @@ from dcim.choices import * 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 @@ __all__ = ( 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 4005d41a4..702455c7e 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -16,6 +16,7 @@ from extras.models import ObjectChange, TaggedItem 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 f98a7b34f..a94fc3eea 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -12,6 +12,7 @@ from django.template import Template, Context 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 d68ca2ce6..d5792ebda 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -6,6 +6,7 @@ from taggit.models import TagBase, GenericTaggedItemBase 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 812c66714..9d9b55778 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -2,6 +2,8 @@ from collections import OrderedDict from django.db.models import Q, QuerySet +from utilities.querysets import RestrictedQuerySet + class CustomFieldQueryset: """ @@ -19,7 +21,7 @@ class CustomFieldQueryset: 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 8811e504a..245a3c891 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 @@ class IPAddressManager(models.Manager): 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 eeb985b7c..b99a6c919 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -12,6 +12,7 @@ from dcim.models import Device, Interface 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 3a48be789..6d2dc6f33 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 61d8adb6b..757ef88c7 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -17,6 +17,7 @@ from dcim.models import Device 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 077fb6ad1..2e415b965 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -7,6 +7,8 @@ from taggit.managers import TaggableManager 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 000000000..1bae2053d --- /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 36460310e..3bc41e072 100644 --- a/netbox/utilities/querysets.py +++ b/netbox/utilities/querysets.py @@ -27,7 +27,7 @@ class RestrictedQuerySet(QuerySet): # 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 3daeff013..8ad40bab7 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -9,6 +9,7 @@ from dcim.models import Device 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 078/505] 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 ac21d298c..50401dfd1 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -330,16 +330,20 @@ class ModelViewSet(_ModelViewSet): 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 3bc41e072..07199e143 100644 --- a/netbox/utilities/querysets.py +++ b/netbox/utilities/querysets.py @@ -14,15 +14,19 @@ class DummyQuerySet: 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 fed774812..f59492a0c 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -66,7 +66,8 @@ class ObjectPermissionRequiredMixin(AccessMixin): # 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 079/505] 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 bb4d787c8..5da912f0a 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 d8ef5a5e9..0f4297fd6 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -19,8 +19,9 @@ from django.views.generic import View 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 @@ class SiteView(ObjectView): 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 @@ class RackView(ObjectView): 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 @@ class RackView(ObjectView): 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 @@ class DeviceTypeView(ObjectView): # 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 @@ class DeviceView(ObjectView): # 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 @@ class InterfaceView(ObjectView): # 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 9abf96f26..a607a4df8 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 @@ class ObjectChangeView(ObjectView): 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 @@ class ObjectChangeLogView(View): # 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 245a3c891..1ef00e125 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 @@ class IPAddressManager(models.Manager): 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 d3b604be6..98fe1d73d 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -3,14 +3,13 @@ from django.conf import settings 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 @@ class AggregateView(ObjectView): 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 @@ class PrefixView(ObjectView): 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 @@ class PrefixView(ObjectView): 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 @@ class PrefixPrefixesView(ObjectView): 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 @@ class PrefixIPAddressesView(ObjectView): 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 @@ class IPAddressView(ObjectView): 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 @@ class IPAddressView(ObjectView): 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 @@ class IPAddressView(ObjectView): 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 823df9933..a82b231f5 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -64,17 +64,17 @@ class TenantView(ObjectView): 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 07199e143..6649e4d9c 100644 --- a/netbox/utilities/querysets.py +++ b/netbox/utilities/querysets.py @@ -28,15 +28,22 @@ class RestrictedQuerySet(QuerySet): 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 9fde12186..5cd19381f 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -187,7 +187,6 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): # 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 79a807c21..aea4d0556 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 080/505] 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 697e18828..de024cf99 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 081/505] 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 de024cf99..38064b689 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 6649e4d9c..1ac79e90a 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 @@ class RestrictedQuerySet(QuerySet): 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 @@ class RestrictedQuerySet(QuerySet): 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 f59492a0c..0304780f3 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -28,7 +28,7 @@ from extras.models import CustomField, CustomFieldValue, ExportTemplate 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 @@ class ObjectPermissionRequiredMixin(AccessMixin): 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 082/505] 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 bc263480f..1522e6268 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -6,14 +6,14 @@ from django.contrib.auth.models import Group 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 @@ class ObjectPermissionBackend(ModelBackend): 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 083/505] 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 37a516409..d6be844d4 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 @@ class SearchView(View): 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 084/505] 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 3c4392915..7c4a7c9c2 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 941cbcd88..0803efb2a 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -205,7 +205,7 @@ PREFER_IPV4 = False # 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 3b345638b..6199ede27 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -97,7 +97,7 @@ PLUGINS = getattr(configuration, 'PLUGINS', []) 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 @@ TEMPLATES = [ # 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 085/505] 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 c1b659a8e..4c3da5acd 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -90,7 +90,9 @@ class ObjectPermissionForm(forms.ModelForm): 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 @@ class ObjectPermissionForm(forms.ModelForm): 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 086/505] 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 000000000..582709726 --- /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 b8633ea8f..2c58acbd8 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 087/505] 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 7c4a7c9c2..31ee39a5f 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 1522e6268..4e9078a9a 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -112,18 +112,18 @@ class RemoteUserBackend(_RemoteUserBackend): # 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 0803efb2a..7b39fb19e 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -209,7 +209,7 @@ REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend' 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 6199ede27..692382262 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -99,7 +99,7 @@ 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', '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_URL: 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 088/505] 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 000000000..b6cc699d4 --- /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 089/505] 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 bef8f004a..afeed2263 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -144,7 +144,7 @@ class ExternalAuthenticationTestCase(TestCase): @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 @@ class ExternalAuthenticationTestCase(TestCase): 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 090/505] 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 4e9078a9a..bf1f96edb 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -6,7 +6,7 @@ from django.contrib.auth.models import Group 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 @@ class ObjectPermissionBackend(ModelBackend): 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 @@ class RemoteUserBackend(_RemoteUserBackend): 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 38064b689..44c34942f 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 3514f9060..2ef5a19fe 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -7,7 +7,7 @@ from django.urls import reverse, NoReverseMatch 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 @@ class TestCase(_TestCase): 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 0304780f3..e4161077c 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -28,7 +28,7 @@ from extras.models import CustomField, CustomFieldValue, ExportTemplate 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 @@ class ObjectPermissionRequiredMixin(AccessMixin): 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 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): # 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 091/505] 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 9c8fe12de..3abfddbc2 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -395,7 +395,7 @@ class DeviceViewSet(CustomFieldModelViewSet): )) # 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 50c2fbd99..3acad9f0b 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 6944cff00..77d18739e 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 3bc780161..6225a9b73 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 3dd3b8c89..4d18509a9 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -1477,10 +1477,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): ('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 0f4297fd6..2508590d9 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1099,7 +1099,7 @@ class DeviceInventoryView(ObjectView): 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 @@ class DeviceStatusView(ObjectView): 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 @@ class DeviceLLDPNeighborsView(ObjectView): 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 ef1a301e2..a42250a3d 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -101,7 +101,7 @@ 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 092/505] 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 b6cc699d4..fc16ed6fd 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 94cd1c7fa..e11128674 100644 --- a/netbox/secrets/admin.py +++ b/netbox/secrets/admin.py @@ -23,7 +23,7 @@ class UserKeyAdmin(admin.ModelAdmin): 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 1281a266a..3664bae63 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 757ef88c7..bf5858ff8 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 c5e4d364c..66084c3be 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 093/505] 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 82d0afdc9..c8d81e5e2 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 a94fc3eea..9e000774f 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 66084c3be..b25698a36 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 094/505] 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 4c3da5acd..80b7affaf 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 dfd0512bd..2aec9e425 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 1c8775699..9dde9d009 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 ObjectPermission(models.Model): 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 095/505] 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 2508590d9..b3b99d804 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 e4161077c..e448f2934 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -571,21 +571,29 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): }) -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 @@ class ObjectImportView(GetReturnURLMixin, View): # 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 @@ class ObjectImportView(GetReturnURLMixin, View): 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 @@ class ObjectImportView(GetReturnURLMixin, View): 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 @@ class ObjectImportView(GetReturnURLMixin, View): 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 096/505] 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 b3b99d804..12f7a5046 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1904,23 +1904,15 @@ class CableTraceView(ObjectView): }) -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 @@ class CableCreateView(PermissionRequiredMixin, GetReturnURLMixin, View): '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) + initial_data['termination_b_rack'] = getattr(obj.termination_a.parent, 'rack', None) - form = self.form_class(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': 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): - - 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)) - - 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), + 'return_url': self.get_return_url(request, obj), }) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index e448f2934..9271e1c64 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -346,7 +346,7 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): 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 097/505] 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 12f7a5046..de2bf80e5 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -3,7 +3,6 @@ import re 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.forms import modelformset_factory 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 @@ class VirtualChassisView(ObjectView): }) -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 @@ class VirtualChassisCreateView(PermissionRequiredMixin, View): }) -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 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi 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 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi }) -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 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin, 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 098/505] 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 fc16ed6fd..4fda77838 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 c3e366542..f88ff040c 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -3,7 +3,7 @@ import logging 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 @@ class TokenEditView(LoginRequiredMixin, View): }) -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 099/505] 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 a607a4df8..e80aa1d62 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 django_tables2 import RequestConfig 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 @@ class ImageAttachmentDeleteView(ObjectDeleteView): # 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 @@ class ReportListView(PermissionRequiredMixin, View): }) -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 @@ class ReportView(PermissionRequiredMixin, View): }) -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 @@ class ReportRunView(PermissionRequiredMixin, View): # 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 @@ class ScriptListView(PermissionRequiredMixin, View): }) -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 100/505] 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 bf1f96edb..a219c1498 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -26,14 +26,14 @@ class ObjectPermissionBackend(ModelBackend): 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 @@ class RemoteUserBackend(_RemoteUserBackend): 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 afeed2263..db63faffd 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -207,7 +207,7 @@ class ObjectPermissionViewTestCase(TestCase): ) 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 @@ class ObjectPermissionViewTestCase(TestCase): ) 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 @@ class ObjectPermissionViewTestCase(TestCase): ) 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 @@ class ObjectPermissionViewTestCase(TestCase): ) 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 @@ class ObjectPermissionViewTestCase(TestCase): ) 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 @@ class ObjectPermissionViewTestCase(TestCase): ) 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 @@ class ObjectPermissionViewTestCase(TestCase): ) 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 @@ class ObjectPermissionViewTestCase(TestCase): ) 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 @@ class ObjectPermissionAPIViewTestCase(TestCase): ) 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 @@ class ObjectPermissionAPIViewTestCase(TestCase): ) 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 @@ class ObjectPermissionAPIViewTestCase(TestCase): ) 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 @@ class ObjectPermissionAPIViewTestCase(TestCase): ) 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 @@ class ObjectPermissionAPIViewTestCase(TestCase): ) 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 80b7affaf..80283340d 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 @@ class ObjectPermissionForm(forms.ModelForm): 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 @@ class ObjectPermissionForm(forms.ModelForm): 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 @@ class ObjectPermissionForm(forms.ModelForm): # 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 @@ class ObjectPermissionForm(forms.ModelForm): 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 f2ecb98b0..4f301264e 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 b25698a36..a5d28beac 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 9dde9d009..255980dfc 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 ObjectPermission(models.Model): 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 2ef5a19fe..3cf6a9df6 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -37,7 +37,7 @@ class TestCase(_TestCase): 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 @@ class ViewTestCases: ) 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 @@ class ViewTestCases: ) 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 @@ class ViewTestCases: ) 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 @@ class ViewTestCases: ) 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 @@ class ViewTestCases: ) 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 @@ class ViewTestCases: ) 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 @@ class ViewTestCases: ) 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 @@ class ViewTestCases: ) 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 @@ class ViewTestCases: ) 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 @@ class ViewTestCases: ) 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 @@ class ViewTestCases: 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 @@ class ViewTestCases: ) 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 @@ class ViewTestCases: ) 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 @@ class ViewTestCases: ) 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 @@ class ViewTestCases: ) 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 @@ class ViewTestCases: ) 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 @@ class ViewTestCases: ) 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 101/505] 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 a219c1498..02b0be0f3 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -28,16 +28,16 @@ class ObjectPermissionBackend(ModelBackend): 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 @@ class ObjectPermissionBackend(ModelBackend): 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 @@ class RemoteUserBackend(_RemoteUserBackend): # 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 db63faffd..0e9bea90d 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -202,7 +202,7 @@ class ObjectPermissionViewTestCase(TestCase): # 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 @@ class ObjectPermissionViewTestCase(TestCase): # 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 @@ class ObjectPermissionViewTestCase(TestCase): # 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 @@ class ObjectPermissionViewTestCase(TestCase): # 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 @@ class ObjectPermissionViewTestCase(TestCase): # 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 @@ class ObjectPermissionViewTestCase(TestCase): # 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 @@ class ObjectPermissionViewTestCase(TestCase): # 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 @@ class ObjectPermissionViewTestCase(TestCase): # 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 @@ class ObjectPermissionAPIViewTestCase(TestCase): # 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 @@ class ObjectPermissionAPIViewTestCase(TestCase): # 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 @@ class ObjectPermissionAPIViewTestCase(TestCase): # 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 @@ class ObjectPermissionAPIViewTestCase(TestCase): # 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 @@ class ObjectPermissionAPIViewTestCase(TestCase): # 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 80283340d..cc7a1b379 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 @@ class ObjectPermissionInline(admin.TabularInline): 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 ObjectPermissionForm(forms.ModelForm): 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 @@ class ObjectPermissionForm(forms.ModelForm): 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 @@ class ObjectPermissionForm(forms.ModelForm): 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 4f301264e..3f16e1ee8 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 255980dfc..b340ce90f 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 3cf6a9df6..0db0ff936 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -180,7 +180,7 @@ class ViewTestCases: # Add object-level permission obj_perm = ObjectPermission( - attrs={'pk': instance1.pk}, + constraints={'pk': instance1.pk}, actions=['view'] ) obj_perm.save() @@ -245,7 +245,7 @@ class ViewTestCases: # 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 @@ class ViewTestCases: 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 @@ class ViewTestCases: # Assign object-level permission obj_perm = ObjectPermission( - attrs={'pk': instance1.pk}, + constraints={'pk': instance1.pk}, actions=['change'] ) obj_perm.save() @@ -406,7 +406,7 @@ class ViewTestCases: # Assign object-level permission obj_perm = ObjectPermission( - attrs={'pk': instance1.pk}, + constraints={'pk': instance1.pk}, actions=['delete'] ) obj_perm.save() @@ -480,7 +480,7 @@ class ViewTestCases: # Add object-level permission obj_perm = ObjectPermission( - attrs={'pk': instance1.pk}, + constraints={'pk': instance1.pk}, actions=['view'] ) obj_perm.save() @@ -579,7 +579,7 @@ class ViewTestCases: # 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 @@ class ViewTestCases: # 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 @@ class ViewTestCases: # 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 102/505] 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 582709726..56ff049ad 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 4fda77838..be6004feb 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 103/505] 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 d8aa2f9d1..a928b79ea 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -65,6 +65,7 @@ _patterns = [ 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 d6be844d4..7ac5f550b 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -343,5 +343,6 @@ class APIRootView(APIView): ('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 d1b649713..f7721cf94 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 @@ _all_ = [ # -# 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 86d350e69..dc5301846 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 000000000..fffea5968 --- /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 000000000..74b315b44 --- /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 b340ce90f..fa3277456 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 000000000..f507192ee --- /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 104/505] 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 56ff049ad..7e47db0d9 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 000000000..80313fc0b --- /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. From b31cc89478414608fa76b32d0ecdf0690516c4f2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 3 Jun 2020 14:13:18 -0400 Subject: [PATCH 105/505] Dropped backward compatibility for 'webhooks' Redis queue --- docs/release-notes/version-2.9.md | 1 + netbox/netbox/settings.py | 17 ++++------------- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index be6004feb..31dc0be02 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -16,3 +16,4 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo * 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. +* Backward compatibility for the `webhooks` Redis queue configuration has been dropped. (Use `tasks` instead.) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 692382262..eb9fab57a 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -195,19 +195,11 @@ if STORAGE_CONFIG and STORAGE_BACKEND is None: # # Background task queuing -if 'tasks' in REDIS: - TASKS_REDIS = REDIS['tasks'] -elif 'webhooks' in REDIS: - # TODO: Remove support for 'webhooks' name in v2.9 - warnings.warn( - "The 'webhooks' REDIS configuration section has been renamed to 'tasks'. Please update your configuration as " - "support for the old name will be removed in a future release." - ) - TASKS_REDIS = REDIS['webhooks'] -else: +if 'tasks' not in REDIS: raise ImproperlyConfigured( "REDIS section in configuration.py is missing the 'tasks' subsection." ) +TASKS_REDIS = REDIS['tasks'] TASKS_REDIS_HOST = TASKS_REDIS.get('HOST', 'localhost') TASKS_REDIS_PORT = TASKS_REDIS.get('PORT', 6379) TASKS_REDIS_SENTINELS = TASKS_REDIS.get('SENTINELS', []) @@ -222,12 +214,11 @@ TASKS_REDIS_DEFAULT_TIMEOUT = TASKS_REDIS.get('DEFAULT_TIMEOUT', 300) TASKS_REDIS_SSL = TASKS_REDIS.get('SSL', False) # Caching -if 'caching' in REDIS: - CACHING_REDIS = REDIS['caching'] -else: +if 'caching' not in REDIS: raise ImproperlyConfigured( "REDIS section in configuration.py is missing caching subsection." ) +CACHING_REDIS = REDIS['caching'] CACHING_REDIS_HOST = CACHING_REDIS.get('HOST', 'localhost') CACHING_REDIS_PORT = CACHING_REDIS.get('PORT', 6379) CACHING_REDIS_SENTINELS = CACHING_REDIS.get('SENTINELS', []) From bb1484a444f7a040dc5d3998aedc80521b4ec99b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 3 Jun 2020 14:15:29 -0400 Subject: [PATCH 106/505] Dropped backward compatibility for the /admin/webhook-backend-status URL --- docs/release-notes/version-2.9.md | 3 ++- netbox/netbox/urls.py | 16 +--------------- 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index 31dc0be02..7c63af51c 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -16,4 +16,5 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo * 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. -* Backward compatibility for the `webhooks` Redis queue configuration has been dropped. (Use `tasks` instead.) +* Dropped backward compatibility for the `webhooks` Redis queue configuration (use `tasks` instead). +* Dropped backward compatibility for the `/admin/webhook-backend-status` URL (moved to `/admin/background-tasks/`). diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index a928b79ea..4878729b0 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -1,7 +1,6 @@ from django.conf import settings from django.conf.urls import include -from django.urls import path, re_path, reverse -from django.views.generic.base import RedirectView +from django.urls import path, re_path from django.views.static import serve from drf_yasg import openapi from drf_yasg.views import get_schema_view @@ -12,17 +11,6 @@ from users.views import LoginView, LogoutView from .admin import admin_site -# TODO: Remove in v2.9 -class RQRedirectView(RedirectView): - """ - Temporary 301 redirect from the old URL to the new one. - """ - permanent = True - - def get_redirect_url(self, *args, **kwargs): - return reverse('rq_home') - - openapi_info = openapi.Info( title="NetBox API", default_version='v2', @@ -77,8 +65,6 @@ _patterns = [ # Admin path('admin/', admin_site.urls), path('admin/background-tasks/', include('django_rq.urls')), - # TODO: Remove in v2.9 - path('admin/webhook-backend-status/', RQRedirectView.as_view()), # Errors path('media-failure/', StaticMediaFailureView.as_view(), name='media_failure'), From 040fadb0c300b9675bcec17832da4cdb4ed40f40 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 3 Jun 2020 15:42:24 -0400 Subject: [PATCH 107/505] Move LDAP authentication support to LDAPBackend --- netbox/netbox/authentication.py | 48 +++++++++++++++++++++++ netbox/netbox/settings.py | 69 --------------------------------- 2 files changed, 48 insertions(+), 69 deletions(-) diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index 02b0be0f3..10d2d1b09 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -3,6 +3,7 @@ import logging from django.conf import settings from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _RemoteUserBackend from django.contrib.auth.models import Group +from django.core.exceptions import ImproperlyConfigured from django.db.models import Q from users.models import ObjectPermission @@ -132,3 +133,50 @@ class RemoteUserBackend(_RemoteUserBackend): def has_perm(self, user_obj, perm, obj=None): return False + + +class LDAPBackend: + + def __new__(cls, *args, **kwargs): + try: + import ldap + from django_auth_ldap.backend import LDAPBackend as LDAPBackend_, LDAPSettings + except ImportError: + raise ImproperlyConfigured( + "LDAP authentication has been configured, but django-auth-ldap is not installed." + ) + + try: + from netbox import ldap_config + except ImportError: + raise ImproperlyConfigured( + "ldap_config.py does not exist" + ) + + try: + getattr(ldap_config, 'AUTH_LDAP_SERVER_URI') + except AttributeError: + raise ImproperlyConfigured( + "Required parameter AUTH_LDAP_SERVER_URI is missing from ldap_config.py." + ) + + # Create a new instance of django-auth-ldap's LDAPBackend + obj = LDAPBackend_() + + # Read LDAP configuration parameters from ldap_config.py instead of settings.py + settings = LDAPSettings() + for param in dir(ldap_config): + if param.startswith(settings._prefix): + setattr(settings, param[10:], getattr(ldap_config, param)) + obj.settings = settings + + # Optionally disable strict certificate checking + if getattr(ldap_config, 'LDAP_IGNORE_CERT_ERRORS', False): + ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER) + + # Enable logging for django_auth_ldap + ldap_logger = logging.getLogger('django_auth_ldap') + ldap_logger.addHandler(logging.StreamHandler()) + ldap_logger.setLevel(logging.DEBUG) + + return obj diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index eb9fab57a..bc1a8c2e7 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -378,75 +378,6 @@ LOGIN_URL = '/{}login/'.format(BASE_PATH) CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS - -# -# LDAP authentication (optional) -# - -try: - from netbox import ldap_config as LDAP_CONFIG -except ImportError: - LDAP_CONFIG = None - -if LDAP_CONFIG is not None: - - # Check that django_auth_ldap is installed - try: - import ldap - import django_auth_ldap - except ImportError: - raise ImproperlyConfigured( - "LDAP authentication has been configured, but django-auth-ldap is not installed. Remove " - "netbox/ldap_config.py to disable LDAP." - ) - - # Required configuration parameters - try: - AUTH_LDAP_SERVER_URI = getattr(LDAP_CONFIG, 'AUTH_LDAP_SERVER_URI') - except AttributeError: - raise ImproperlyConfigured( - "Required parameter AUTH_LDAP_SERVER_URI is missing from ldap_config.py." - ) - - # Optional configuration parameters - AUTH_LDAP_ALWAYS_UPDATE_USER = getattr(LDAP_CONFIG, 'AUTH_LDAP_ALWAYS_UPDATE_USER', True) - AUTH_LDAP_AUTHORIZE_ALL_USERS = getattr(LDAP_CONFIG, 'AUTH_LDAP_AUTHORIZE_ALL_USERS', False) - AUTH_LDAP_BIND_AS_AUTHENTICATING_USER = getattr(LDAP_CONFIG, 'AUTH_LDAP_BIND_AS_AUTHENTICATING_USER', False) - AUTH_LDAP_BIND_DN = getattr(LDAP_CONFIG, 'AUTH_LDAP_BIND_DN', '') - AUTH_LDAP_BIND_PASSWORD = getattr(LDAP_CONFIG, 'AUTH_LDAP_BIND_PASSWORD', '') - AUTH_LDAP_CACHE_TIMEOUT = getattr(LDAP_CONFIG, 'AUTH_LDAP_CACHE_TIMEOUT', 0) - AUTH_LDAP_CONNECTION_OPTIONS = getattr(LDAP_CONFIG, 'AUTH_LDAP_CONNECTION_OPTIONS', {}) - AUTH_LDAP_DENY_GROUP = getattr(LDAP_CONFIG, 'AUTH_LDAP_DENY_GROUP', None) - AUTH_LDAP_FIND_GROUP_PERMS = getattr(LDAP_CONFIG, 'AUTH_LDAP_FIND_GROUP_PERMS', False) - AUTH_LDAP_GLOBAL_OPTIONS = getattr(LDAP_CONFIG, 'AUTH_LDAP_GLOBAL_OPTIONS', {}) - AUTH_LDAP_GROUP_SEARCH = getattr(LDAP_CONFIG, 'AUTH_LDAP_GROUP_SEARCH', None) - AUTH_LDAP_GROUP_TYPE = getattr(LDAP_CONFIG, 'AUTH_LDAP_GROUP_TYPE', None) - AUTH_LDAP_MIRROR_GROUPS = getattr(LDAP_CONFIG, 'AUTH_LDAP_MIRROR_GROUPS', None) - AUTH_LDAP_MIRROR_GROUPS_EXCEPT = getattr(LDAP_CONFIG, 'AUTH_LDAP_MIRROR_GROUPS_EXCEPT', None) - AUTH_LDAP_PERMIT_EMPTY_PASSWORD = getattr(LDAP_CONFIG, 'AUTH_LDAP_PERMIT_EMPTY_PASSWORD', False) - AUTH_LDAP_REQUIRE_GROUP = getattr(LDAP_CONFIG, 'AUTH_LDAP_REQUIRE_GROUP', None) - AUTH_LDAP_NO_NEW_USERS = getattr(LDAP_CONFIG, 'AUTH_LDAP_NO_NEW_USERS', False) - AUTH_LDAP_START_TLS = getattr(LDAP_CONFIG, 'AUTH_LDAP_START_TLS', False) - AUTH_LDAP_USER_QUERY_FIELD = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_QUERY_FIELD', None) - AUTH_LDAP_USER_ATTRLIST = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_ATTRLIST', None) - AUTH_LDAP_USER_ATTR_MAP = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_ATTR_MAP', {}) - AUTH_LDAP_USER_DN_TEMPLATE = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_DN_TEMPLATE', None) - AUTH_LDAP_USER_FLAGS_BY_GROUP = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_FLAGS_BY_GROUP', {}) - AUTH_LDAP_USER_SEARCH = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_SEARCH', None) - - # Optionally disable strict certificate checking - if getattr(LDAP_CONFIG, 'LDAP_IGNORE_CERT_ERRORS', False): - ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER) - - # Prepend LDAPBackend to the authentication backends list - AUTHENTICATION_BACKENDS.insert(0, 'django_auth_ldap.backend.LDAPBackend') - - # Enable logging for django_auth_ldap - ldap_logger = logging.getLogger('django_auth_ldap') - ldap_logger.addHandler(logging.StreamHandler()) - ldap_logger.setLevel(logging.DEBUG) - - # # Caching # From dc161d9f2f260e8bae77b3925ee9b023e7acd25b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 3 Jun 2020 15:57:11 -0400 Subject: [PATCH 108/505] Update LDAP configuration documentation --- docs/configuration/optional-settings.md | 5 ++++- docs/installation/5-ldap.md | 11 +++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 31ee39a5f..9fddbe82a 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -386,7 +386,10 @@ NetBox can be configured to support remote user authentication by inferring user 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`.) +Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication. NetBox provides two built-in backends (listed below), though backends may also be provided via other packages. + +* `netbox.authentication.RemoteUserBackend` +* `netbox.authentication.LDAPBackend` --- diff --git a/docs/installation/5-ldap.md b/docs/installation/5-ldap.md index 2fd88b841..bb1300c08 100644 --- a/docs/installation/5-ldap.md +++ b/docs/installation/5-ldap.md @@ -36,7 +36,13 @@ Once installed, add the package to `local_requirements.txt` to ensure it is re-i ## Configuration -Create a file in the same directory as `configuration.py` (typically `netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](http://django-auth-ldap.readthedocs.io/). +First, enable the LDAP authentication backend in `configuration.py`. (Be sure to overwrite this definition if it is already set to `RemoteUserBackend`.) + +```python +REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend' +``` + +Next, create a file in the same directory as `configuration.py` (typically `netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](http://django-auth-ldap.readthedocs.io/). ### General Server Configuration @@ -145,7 +151,8 @@ logfile = "/opt/netbox/logs/django-ldap-debug.log" my_logger = logging.getLogger('django_auth_ldap') my_logger.setLevel(logging.DEBUG) handler = logging.handlers.RotatingFileHandler( - logfile, maxBytes=1024 * 500, backupCount=5) + logfile, maxBytes=1024 * 500, backupCount=5 +) my_logger.addHandler(handler) ``` From cde1db443673036d407a6ff9ea68eb585d0924b7 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Thu, 4 Jun 2020 16:44:25 -0400 Subject: [PATCH 109/505] Add `label` to interface models --- .../dcim/migrations/0107_interface_label.py | 23 +++++++++++++++++++ .../dcim/models/device_component_templates.py | 5 ++++ netbox/dcim/models/device_components.py | 5 ++++ 3 files changed, 33 insertions(+) create mode 100644 netbox/dcim/migrations/0107_interface_label.py diff --git a/netbox/dcim/migrations/0107_interface_label.py b/netbox/dcim/migrations/0107_interface_label.py new file mode 100644 index 000000000..48970d25d --- /dev/null +++ b/netbox/dcim/migrations/0107_interface_label.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.7 on 2020-06-04 20:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0106_role_default_color'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='label', + field=models.CharField(blank=True, max_length=64), + ), + migrations.AddField( + model_name='interfacetemplate', + name='label', + field=models.CharField(blank=True, max_length=64), + ), + ] diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 164d37d77..1a9e7bbf9 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -263,6 +263,11 @@ class InterfaceTemplate(ComponentTemplateModel): max_length=100, blank=True ) + label = models.CharField( + max_length=64, + blank=True, + help_text="The physical label for this interface" + ) type = models.CharField( max_length=50, choices=InterfaceTypeChoices diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 4005d41a4..e5c2e5936 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -605,6 +605,11 @@ class Interface(CableTermination, ComponentModel): max_length=100, blank=True ) + label = models.CharField( + max_length=64, + blank=True, + help_text="The physical label for this interface" + ) _connected_interface = models.OneToOneField( to='self', on_delete=models.SET_NULL, From e9f8640ee680e6eacf49a1ad0523bde1055dd86c Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Thu, 4 Jun 2020 16:50:51 -0400 Subject: [PATCH 110/505] Add `label` to Interface serializers --- netbox/dcim/api/serializers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 9ac58dc3a..e8bc57d1d 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -304,7 +304,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer): class Meta: model = InterfaceTemplate - fields = ['id', 'device_type', 'name', 'type', 'mgmt_only'] + fields = ['id', 'device_type', 'name', 'label', 'type', 'mgmt_only'] class RearPortTemplateSerializer(ValidatedModelSerializer): @@ -536,7 +536,7 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer): class Meta: model = Interface fields = [ - 'id', 'device', 'name', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', + 'id', 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'count_ipaddresses', ] From f8851121abd26eaa2a761c04375d95f76b666acb Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Thu, 4 Jun 2020 17:11:27 -0400 Subject: [PATCH 111/505] Add the `label` to the string representation --- netbox/dcim/models/device_component_templates.py | 2 ++ netbox/dcim/models/device_components.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 1a9e7bbf9..f61c3f057 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -282,6 +282,8 @@ class InterfaceTemplate(ComponentTemplateModel): unique_together = ('device_type', 'name') def __str__(self): + if self.label: + return f"{self.name} ({self.label})" return self.name def instantiate(self, device): diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index e5c2e5936..9d9e79196 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -691,6 +691,8 @@ class Interface(CableTermination, ComponentModel): unique_together = ('device', 'name') def __str__(self): + if self.label: + return f"{self.name} ({self.label})" return self.name def get_absolute_url(self): From a06d74472da1c4ffbcb8e3fc23b9b8f62af079f0 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Fri, 5 Jun 2020 10:32:59 -0400 Subject: [PATCH 112/505] Add `label` to *ports models --- netbox/dcim/migrations/0108_port_label.py | 53 +++++++++++++++++++ .../dcim/models/device_component_templates.py | 20 +++++++ netbox/dcim/models/device_components.py | 20 +++++++ 3 files changed, 93 insertions(+) create mode 100644 netbox/dcim/migrations/0108_port_label.py diff --git a/netbox/dcim/migrations/0108_port_label.py b/netbox/dcim/migrations/0108_port_label.py new file mode 100644 index 000000000..af0aa1962 --- /dev/null +++ b/netbox/dcim/migrations/0108_port_label.py @@ -0,0 +1,53 @@ +# Generated by Django 3.0.7 on 2020-06-05 14:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0107_interface_label'), + ] + + operations = [ + migrations.AddField( + model_name='consoleport', + name='label', + field=models.CharField(blank=True, max_length=64), + ), + migrations.AddField( + model_name='consoleporttemplate', + name='label', + field=models.CharField(blank=True, max_length=64), + ), + migrations.AddField( + model_name='consoleserverport', + name='label', + field=models.CharField(blank=True, max_length=64), + ), + migrations.AddField( + model_name='consoleserverporttemplate', + name='label', + field=models.CharField(blank=True, max_length=64), + ), + migrations.AddField( + model_name='poweroutlet', + name='label', + field=models.CharField(blank=True, max_length=64), + ), + migrations.AddField( + model_name='poweroutlettemplate', + name='label', + field=models.CharField(blank=True, max_length=64), + ), + migrations.AddField( + model_name='powerport', + name='label', + field=models.CharField(blank=True, max_length=64), + ), + migrations.AddField( + model_name='powerporttemplate', + name='label', + field=models.CharField(blank=True, max_length=64), + ), + ] diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index f61c3f057..b7f94a450 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -69,6 +69,11 @@ class ConsolePortTemplate(ComponentTemplateModel): max_length=100, blank=True ) + label = models.CharField( + max_length=64, + blank=True, + help_text="The physical label for this console port" + ) type = models.CharField( max_length=50, choices=ConsolePortTypeChoices, @@ -107,6 +112,11 @@ class ConsoleServerPortTemplate(ComponentTemplateModel): max_length=100, blank=True ) + label = models.CharField( + max_length=64, + blank=True, + help_text="The physical label for this console server port" + ) type = models.CharField( max_length=50, choices=ConsolePortTypeChoices, @@ -145,6 +155,11 @@ class PowerPortTemplate(ComponentTemplateModel): max_length=100, blank=True ) + label = models.CharField( + max_length=64, + blank=True, + help_text="The physical label for this power supply port" + ) type = models.CharField( max_length=50, choices=PowerPortTypeChoices, @@ -197,6 +212,11 @@ class PowerOutletTemplate(ComponentTemplateModel): max_length=100, blank=True ) + label = models.CharField( + max_length=64, + blank=True, + help_text="The physical label for this power outlet" + ) type = models.CharField( max_length=50, choices=PowerOutletTypeChoices, diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 9d9e79196..860e1bd65 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -231,6 +231,11 @@ class ConsolePort(CableTermination, ComponentModel): name = models.CharField( max_length=50 ) + label = models.CharField( + max_length=64, + blank=True, + help_text="The physical label for this console port" + ) _name = NaturalOrderingField( target_field='name', max_length=100, @@ -298,6 +303,11 @@ class ConsoleServerPort(CableTermination, ComponentModel): max_length=100, blank=True ) + label = models.CharField( + max_length=64, + blank=True, + help_text="The physical label for this console server port" + ) type = models.CharField( max_length=50, choices=ConsolePortTypeChoices, @@ -353,6 +363,11 @@ class PowerPort(CableTermination, ComponentModel): max_length=100, blank=True ) + label = models.CharField( + max_length=64, + blank=True, + help_text="The physical label for this power supply port" + ) type = models.CharField( max_length=50, choices=PowerPortTypeChoices, @@ -516,6 +531,11 @@ class PowerOutlet(CableTermination, ComponentModel): max_length=100, blank=True ) + label = models.CharField( + max_length=64, + blank=True, + help_text="The physical label for this power outlet" + ) type = models.CharField( max_length=50, choices=PowerOutletTypeChoices, From 1fae9aff0c90d5217309263388ed02925435d4f2 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Fri, 5 Jun 2020 10:42:13 -0400 Subject: [PATCH 113/505] Add `label` to *port serializers --- netbox/dcim/api/serializers.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index e8bc57d1d..a2f576aca 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -248,7 +248,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer): class Meta: model = ConsolePortTemplate - fields = ['id', 'device_type', 'name', 'type'] + fields = ['id', 'device_type', 'name', 'label', 'type'] class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): @@ -261,7 +261,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = ConsoleServerPortTemplate - fields = ['id', 'device_type', 'name', 'type'] + fields = ['id', 'device_type', 'name', 'label', 'type'] class PowerPortTemplateSerializer(ValidatedModelSerializer): @@ -274,7 +274,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = PowerPortTemplate - fields = ['id', 'device_type', 'name', 'type', 'maximum_draw', 'allocated_draw'] + fields = ['id', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw'] class PowerOutletTemplateSerializer(ValidatedModelSerializer): @@ -295,7 +295,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer): class Meta: model = PowerOutletTemplate - fields = ['id', 'device_type', 'name', 'type', 'power_port', 'feed_leg'] + fields = ['id', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg'] class InterfaceTemplateSerializer(ValidatedModelSerializer): @@ -446,7 +446,7 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer) class Meta: model = ConsoleServerPort fields = [ - 'id', 'device', 'name', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint', + 'id', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'tags', ] @@ -464,7 +464,7 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer): class Meta: model = ConsolePort fields = [ - 'id', 'device', 'name', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint', + 'id', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'tags', ] @@ -494,7 +494,7 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer): class Meta: model = PowerOutlet fields = [ - 'id', 'device', 'name', 'type', 'power_port', 'feed_leg', 'description', 'connected_endpoint_type', + 'id', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'tags', ] @@ -512,7 +512,7 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer): class Meta: model = PowerPort fields = [ - 'id', 'device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description', 'connected_endpoint_type', + 'id', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'tags', ] From e21cbf2a0667e2d014740a1bfef06a056c238a40 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Fri, 5 Jun 2020 11:01:39 -0400 Subject: [PATCH 114/505] Add the `label` to the string representation --- netbox/dcim/models/device_component_templates.py | 8 ++++++++ netbox/dcim/models/device_components.py | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index b7f94a450..acb5f0d46 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -85,6 +85,8 @@ class ConsolePortTemplate(ComponentTemplateModel): unique_together = ('device_type', 'name') def __str__(self): + if self.label: + return f"{self.name} ({self.label})" return self.name def instantiate(self, device): @@ -128,6 +130,8 @@ class ConsoleServerPortTemplate(ComponentTemplateModel): unique_together = ('device_type', 'name') def __str__(self): + if self.label: + return f"{self.name} ({self.label})" return self.name def instantiate(self, device): @@ -183,6 +187,8 @@ class PowerPortTemplate(ComponentTemplateModel): unique_together = ('device_type', 'name') def __str__(self): + if self.label: + return f"{self.name} ({self.label})" return self.name def instantiate(self, device): @@ -241,6 +247,8 @@ class PowerOutletTemplate(ComponentTemplateModel): unique_together = ('device_type', 'name') def __str__(self): + if self.label: + return f"{self.name} ({self.label})" return self.name def clean(self): diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 860e1bd65..b8331365d 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -267,6 +267,8 @@ class ConsolePort(CableTermination, ComponentModel): unique_together = ('device', 'name') def __str__(self): + if self.label: + return f"{self.name} ({self.label})" return self.name def get_absolute_url(self): @@ -327,6 +329,8 @@ class ConsoleServerPort(CableTermination, ComponentModel): unique_together = ('device', 'name') def __str__(self): + if self.label: + return f"{self.name} ({self.label})" return self.name def get_absolute_url(self): @@ -413,6 +417,8 @@ class PowerPort(CableTermination, ComponentModel): unique_together = ('device', 'name') def __str__(self): + if self.label: + return f"{self.name} ({self.label})" return self.name def get_absolute_url(self): @@ -568,6 +574,8 @@ class PowerOutlet(CableTermination, ComponentModel): unique_together = ('device', 'name') def __str__(self): + if self.label: + return f"{self.name} ({self.label})" return self.name def get_absolute_url(self): From d65cead212dc781a6dbbe35ea15eea7b75023da9 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Fri, 5 Jun 2020 12:34:09 -0400 Subject: [PATCH 115/505] Return an empty list if value is None --- netbox/utilities/forms.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 979b6ac32..3d8dbe33f 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -530,6 +530,8 @@ class ExpandableNameField(forms.CharField): """ def to_python(self, value): + if value is None: + return list() if re.search(ALPHANUMERIC_EXPANSION_PATTERN, value): return list(expand_alphanumeric_pattern(value)) return [value] From 286a3e6ca296609201d3f02892a3d50c338b9f78 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Fri, 5 Jun 2020 13:59:59 -0400 Subject: [PATCH 116/505] Add `label` to forms, views and templates --- netbox/dcim/forms.py | 200 ++++++++++++++++++++-- netbox/templates/dcim/interface.html | 4 + netbox/templates/dcim/interface_edit.html | 1 + netbox/utilities/views.py | 19 +- 4 files changed, 207 insertions(+), 17 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 94cf51fcd..0e9d9763e 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1032,7 +1032,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsolePortTemplate fields = [ - 'device_type', 'name', 'type', + 'device_type', 'name', 'label', 'type', ] widgets = { 'device_type': forms.HiddenInput(), @@ -1046,11 +1046,27 @@ class ConsolePortTemplateCreateForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField( label='Name' ) + label_pattern = ExpandableNameField( + label='Label', + required=False + ) type = forms.ChoiceField( choices=add_blank_choice(ConsolePortTypeChoices), widget=StaticSelect2() ) + def clean(self): + + # Validate that the number of ports being created from both the name_pattern and label_pattern are equal + name_pattern_count = len(self.cleaned_data['name_pattern']) + label_pattern_count = len(self.cleaned_data['label_pattern']) + if label_pattern_count and name_pattern_count != label_pattern_count: + raise forms.ValidationError({ + 'label_pattern': 'The provided name pattern will create {} ports, however {} labels will ' + 'be generated. These counts must match.'.format( + name_pattern_count, label_pattern_count) + }) + class ConsolePortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( @@ -1072,7 +1088,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsoleServerPortTemplate fields = [ - 'device_type', 'name', 'type', + 'device_type', 'name', 'label', 'type', ] widgets = { 'device_type': forms.HiddenInput(), @@ -1086,11 +1102,27 @@ class ConsoleServerPortTemplateCreateForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField( label='Name' ) + label_pattern = ExpandableNameField( + label='Label', + required=False + ) type = forms.ChoiceField( choices=add_blank_choice(ConsolePortTypeChoices), widget=StaticSelect2() ) + def clean(self): + + # Validate that the number of ports being created from both the name_pattern and label_pattern are equal + name_pattern_count = len(self.cleaned_data['name_pattern']) + label_pattern_count = len(self.cleaned_data['label_pattern']) + if label_pattern_count and name_pattern_count != label_pattern_count: + raise forms.ValidationError({ + 'label_pattern': 'The provided name pattern will create {} ports, however {} labels will ' + 'be generated. These counts must match.'.format( + name_pattern_count, label_pattern_count) + }) + class ConsoleServerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( @@ -1112,7 +1144,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerPortTemplate fields = [ - 'device_type', 'name', 'type', 'maximum_draw', 'allocated_draw', + 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', ] widgets = { 'device_type': forms.HiddenInput(), @@ -1126,6 +1158,10 @@ class PowerPortTemplateCreateForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField( label='Name' ) + label_pattern = ExpandableNameField( + label='Label', + required=False + ) type = forms.ChoiceField( choices=add_blank_choice(PowerPortTypeChoices), required=False @@ -1141,6 +1177,18 @@ class PowerPortTemplateCreateForm(BootstrapMixin, forms.Form): help_text="Allocated power draw (watts)" ) + def clean(self): + + # Validate that the number of ports being created from both the name_pattern and label_pattern are equal + name_pattern_count = len(self.cleaned_data['name_pattern']) + label_pattern_count = len(self.cleaned_data['label_pattern']) + if label_pattern_count and name_pattern_count != label_pattern_count: + raise forms.ValidationError({ + 'label_pattern': 'The provided name pattern will create {} ports, however {} labels will ' + 'be generated. These counts must match.'.format( + name_pattern_count, label_pattern_count) + }) + class PowerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( @@ -1172,7 +1220,7 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerOutletTemplate fields = [ - 'device_type', 'name', 'type', 'power_port', 'feed_leg', + 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', ] widgets = { 'device_type': forms.HiddenInput(), @@ -1196,6 +1244,10 @@ class PowerOutletTemplateCreateForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField( label='Name' ) + label_pattern = ExpandableNameField( + label='Label', + required=False + ) type = forms.ChoiceField( choices=add_blank_choice(PowerOutletTypeChoices), required=False @@ -1221,6 +1273,18 @@ class PowerOutletTemplateCreateForm(BootstrapMixin, forms.Form): device_type=device_type ) + def clean(self): + + # Validate that the number of ports being created from both the name_pattern and label_pattern are equal + name_pattern_count = len(self.cleaned_data['name_pattern']) + label_pattern_count = len(self.cleaned_data['label_pattern']) + if label_pattern_count and name_pattern_count != label_pattern_count: + raise forms.ValidationError({ + 'label_pattern': 'The provided name pattern will create {} ports, however {} labels will ' + 'be generated. These counts must match.'.format( + name_pattern_count, label_pattern_count) + }) + class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( @@ -1247,7 +1311,7 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = InterfaceTemplate fields = [ - 'device_type', 'name', 'type', 'mgmt_only', + 'device_type', 'name', 'label', 'type', 'mgmt_only', ] widgets = { 'device_type': forms.HiddenInput(), @@ -1262,6 +1326,10 @@ class InterfaceTemplateCreateForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField( label='Name' ) + label_pattern = ExpandableNameField( + label='Label', + required=False + ) type = forms.ChoiceField( choices=InterfaceTypeChoices, widget=StaticSelect2() @@ -1271,6 +1339,18 @@ class InterfaceTemplateCreateForm(BootstrapMixin, forms.Form): label='Management only' ) + def clean(self): + + # Validate that the number of ports being created from both the name_pattern and label_pattern are equal + name_pattern_count = len(self.cleaned_data['name_pattern']) + label_pattern_count = len(self.cleaned_data['label_pattern']) + if label_pattern_count and name_pattern_count != label_pattern_count: + raise forms.ValidationError({ + 'label_pattern': 'The provided name pattern will create {} interfaces, however {} labels will ' + 'be generated. These counts must match.'.format( + name_pattern_count, label_pattern_count) + }) + class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( @@ -1504,7 +1584,7 @@ class ConsolePortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = ConsolePortTemplate fields = [ - 'device_type', 'name', 'type', + 'device_type', 'name', 'label', 'type', ] @@ -1513,7 +1593,7 @@ class ConsoleServerPortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = ConsoleServerPortTemplate fields = [ - 'device_type', 'name', 'type', + 'device_type', 'name', 'label', 'type', ] @@ -1522,7 +1602,7 @@ class PowerPortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = PowerPortTemplate fields = [ - 'device_type', 'name', 'type', 'maximum_draw', 'allocated_draw', + 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', ] @@ -1536,7 +1616,7 @@ class PowerOutletTemplateImportForm(ComponentTemplateImportForm): class Meta: model = PowerOutletTemplate fields = [ - 'device_type', 'name', 'type', 'power_port', 'feed_leg', + 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', ] @@ -1548,7 +1628,7 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm): class Meta: model = InterfaceTemplate fields = [ - 'device_type', 'name', 'type', 'mgmt_only', + 'device_type', 'name', 'label', 'type', 'mgmt_only', ] @@ -2199,12 +2279,28 @@ class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField( label='Name' ) + label_pattern = ExpandableNameField( + label='Label', + required=False + ) def clean_tags(self): # Because we're feeding TagField data (on the bulk edit form) to another TagField (on the model form), we # must first convert the list of tags to a string. return ','.join(self.cleaned_data.get('tags')) + def clean(self): + + # Validate that the number of ports being created from both the name_pattern and label_pattern are equal + name_pattern_count = len(self.cleaned_data['name_pattern']) + label_pattern_count = len(self.cleaned_data['label_pattern']) + if label_pattern_count and name_pattern_count != label_pattern_count: + raise forms.ValidationError({ + 'label_pattern': 'The provided name pattern will create {} {}}, however {} labels will ' + 'be generated. These counts must match.'.format( + name_pattern_count, self.type, label_pattern_count) + }) + # # Console ports @@ -2229,7 +2325,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsolePort fields = [ - 'device', 'name', 'type', 'description', 'tags', + 'device', 'name', 'label', 'type', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -2243,6 +2339,10 @@ class ConsolePortCreateForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField( label='Name' ) + label_pattern = ExpandableNameField( + label='Label', + required=False + ) type = forms.ChoiceField( choices=add_blank_choice(ConsolePortTypeChoices), required=False, @@ -2256,6 +2356,18 @@ class ConsolePortCreateForm(BootstrapMixin, forms.Form): required=False ) + def clean(self): + + # Validate that the number of ports being created from both the name_pattern and label_pattern are equal + name_pattern_count = len(self.cleaned_data['name_pattern']) + label_pattern_count = len(self.cleaned_data['label_pattern']) + if label_pattern_count and name_pattern_count != label_pattern_count: + raise forms.ValidationError({ + 'label_pattern': 'The provided name pattern will create {} ports, however {} labels will ' + 'be generated. These counts must match.'.format( + name_pattern_count, label_pattern_count) + }) + class ConsolePortBulkCreateForm( form_from_model(ConsolePort, ['type', 'description', 'tags']), @@ -2329,6 +2441,10 @@ class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField( label='Name' ) + label_pattern = ExpandableNameField( + label='Label', + required=False + ) type = forms.ChoiceField( choices=add_blank_choice(ConsolePortTypeChoices), required=False, @@ -2342,6 +2458,18 @@ class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form): required=False ) + def clean(self): + + # Validate that the number of ports being created from both the name_pattern and label_pattern are equal + name_pattern_count = len(self.cleaned_data['name_pattern']) + label_pattern_count = len(self.cleaned_data['label_pattern']) + if label_pattern_count and name_pattern_count != label_pattern_count: + raise forms.ValidationError({ + 'label_pattern': 'The provided name pattern will create {} ports, however {} labels will ' + 'be generated. These counts must match.'.format( + name_pattern_count, label_pattern_count) + }) + class ConsoleServerPortBulkCreateForm( form_from_model(ConsoleServerPort, ['type', 'description', 'tags']), @@ -2429,6 +2557,10 @@ class PowerPortCreateForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField( label='Name' ) + label_pattern = ExpandableNameField( + label='Label', + required=False + ) type = forms.ChoiceField( choices=add_blank_choice(PowerPortTypeChoices), required=False, @@ -2451,6 +2583,17 @@ class PowerPortCreateForm(BootstrapMixin, forms.Form): tags = TagField( required=False ) + def clean(self): + + # Validate that the number of ports being created from both the name_pattern and label_pattern are equal + name_pattern_count = len(self.cleaned_data['name_pattern']) + label_pattern_count = len(self.cleaned_data['label_pattern']) + if label_pattern_count and name_pattern_count != label_pattern_count: + raise forms.ValidationError({ + 'label_pattern': 'The provided name pattern will create {} ports, however {} labels will ' + 'be generated. These counts must match.'.format( + name_pattern_count, label_pattern_count) + }) class PowerPortBulkCreateForm( @@ -2538,6 +2681,10 @@ class PowerOutletCreateForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField( label='Name' ) + label_pattern = ExpandableNameField( + label='Label', + required=False + ) type = forms.ChoiceField( choices=add_blank_choice(PowerOutletTypeChoices), required=False, @@ -2568,6 +2715,18 @@ class PowerOutletCreateForm(BootstrapMixin, forms.Form): ) self.fields['power_port'].queryset = PowerPort.objects.filter(device=device) + def clean(self): + + # Validate that the number of ports being created from both the name_pattern and label_pattern are equal + name_pattern_count = len(self.cleaned_data['name_pattern']) + label_pattern_count = len(self.cleaned_data['label_pattern']) + if label_pattern_count and name_pattern_count != label_pattern_count: + raise forms.ValidationError({ + 'label_pattern': 'The provided name pattern will create {} ports, however {} labels will ' + 'be generated. These counts must match.'.format( + name_pattern_count, label_pattern_count) + }) + class PowerOutletBulkCreateForm( form_from_model(PowerOutlet, ['type', 'feed_leg', 'description', 'tags']), @@ -2721,7 +2880,7 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): class Meta: model = Interface fields = [ - 'device', 'name', 'type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', + 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', ] widgets = { @@ -2763,6 +2922,10 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form): name_pattern = ExpandableNameField( label='Name' ) + label_pattern = ExpandableNameField( + label='Label', + required=False + ) type = forms.ChoiceField( choices=InterfaceTypeChoices, widget=StaticSelect2(), @@ -2843,6 +3006,19 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form): self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', device.site.pk) self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk) + def clean(self): + + # Validate that the number of ports being created from both the name_pattern and label_pattern are equal + name_pattern_count = len(self.cleaned_data['name_pattern']) + label_pattern_count = len(self.cleaned_data['label_pattern']) + if label_pattern_count and name_pattern_count != label_pattern_count: + raise forms.ValidationError({ + 'label_pattern': 'The provided name pattern will create {} interfaces, however {} labels will ' + 'be generated. These counts must match.'.format( + name_pattern_count, label_pattern_count) + }) + + class InterfaceBulkCreateForm( form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only', 'description', 'tags']), diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 9d94e0639..d35504368 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -58,6 +58,10 @@ Name {{ interface.name }} + + Label + {{ interface.label }} + Type {{ interface.get_type_display }} diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index a80b7c592..eaffe2bca 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -6,6 +6,7 @@
Interface
{% render_field form.name %} + {% render_field form.label %} {% render_field form.type %} {% render_field form.enabled %} {% render_field form.lag %} diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 4b5993c5f..462e45819 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -919,21 +919,26 @@ class ComponentCreateView(GetReturnURLMixin, View): new_components = [] data = deepcopy(request.POST) - for i, name in enumerate(form.cleaned_data['name_pattern']): - + names = form.cleaned_data['name_pattern'] + labels = form.cleaned_data.get('label_pattern') + for pos, name in enumerate(names): + label = labels[pos] if labels else None # Initialize the individual component form data['name'] = name + data['label'] = label if hasattr(form, 'get_iterative_data'): - data.update(form.get_iterative_data(i)) + data.update(form.get_iterative_data(pos)) component_form = self.model_form(data) if component_form.is_valid(): new_components.append(component_form) else: for field, errors in component_form.errors.as_data().items(): - # Assign errors on the child form's name field to name_pattern on the parent form + # Assign errors on the child form's name/label field to name_pattern/label_pattern on the parent form if field == 'name': field = 'name_pattern' + if field == 'label': + field = 'label_pattern' for e in errors: form.add_error(field, '{}: {}'.format(name, ', '.join(e))) @@ -1003,10 +1008,14 @@ class BulkComponentCreateView(GetReturnURLMixin, View): for obj in data['pk']: names = data['name_pattern'] - for name in names: + labels = data['label_pattern'] + for pos, name in enumerate(names): + label = labels[pos] if labels else None + component_data = { self.parent_field: obj.pk, 'name': name, + 'label': label } component_data.update(data) component_form = self.model_form(component_data) From f83e435a90f01f1b0d97daa50955f17d1f868f77 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 8 Jun 2020 10:46:53 -0400 Subject: [PATCH 117/505] Move APIViewTestCases to api.py --- netbox/netbox/tests/test_authentication.py | 2 +- netbox/utilities/testing/__init__.py | 3 +- netbox/utilities/testing/api.py | 141 ++++++++++++++++++ .../testing/{testcases.py => views.py} | 137 +---------------- 4 files changed, 152 insertions(+), 131 deletions(-) create mode 100644 netbox/utilities/testing/api.py rename netbox/utilities/testing/{testcases.py => views.py} (87%) diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 0e9bea90d..7e5ce89b7 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -11,7 +11,7 @@ from dcim.models import Site from ipam.choices import PrefixStatusChoices from ipam.models import Prefix from users.models import ObjectPermission, Token -from utilities.testing.testcases import TestCase +from utilities.testing import TestCase class ExternalAuthenticationTestCase(TestCase): diff --git a/netbox/utilities/testing/__init__.py b/netbox/utilities/testing/__init__.py index 30e452215..1c18a3481 100644 --- a/netbox/utilities/testing/__init__.py +++ b/netbox/utilities/testing/__init__.py @@ -1,2 +1,3 @@ -from .testcases import * +from .api import * from .utils import * +from .views import * diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py new file mode 100644 index 000000000..d9688b6e2 --- /dev/null +++ b/netbox/utilities/testing/api.py @@ -0,0 +1,141 @@ +from django.contrib.auth.models import User +from django.urls import reverse +from django.test import override_settings +from rest_framework import status +from rest_framework.test import APIClient + +from users.models import Token +from .views import TestCase + + +__all__ = ( + 'APITestCase', + 'APIViewTestCases', +) + + +# +# REST API Tests +# + +class APITestCase(TestCase): + client_class = APIClient + model = None + + def setUp(self): + """ + Create a superuser and token for API calls. + """ + self.user = User.objects.create(username='testuser', is_superuser=True) + self.token = Token.objects.create(user=self.user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)} + + def _get_detail_url(self, instance): + viewname = f'{instance._meta.app_label}-api:{instance._meta.model_name}-detail' + return reverse(viewname, kwargs={'pk': instance.pk}) + + def _get_list_url(self): + viewname = f'{self.model._meta.app_label}-api:{self.model._meta.model_name}-list' + return reverse(viewname) + + +class APIViewTestCases: + + class GetObjectViewTestCase(APITestCase): + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_get_object(self): + """ + GET a single object identified by its numeric ID. + """ + instance = self.model.objects.first() + url = self._get_detail_url(instance) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['id'], instance.pk) + + class ListObjectsViewTestCase(APITestCase): + brief_fields = [] + + def test_list_objects(self): + """ + GET a list of objects. + """ + url = self._get_list_url() + response = self.client.get(url, **self.header) + + self.assertEqual(len(response.data['results']), self.model.objects.count()) + + def test_list_objects_brief(self): + """ + GET a list of objects using the "brief" parameter. + """ + url = f'{self._get_list_url()}?brief=1' + response = self.client.get(url, **self.header) + + self.assertEqual(len(response.data['results']), self.model.objects.count()) + self.assertEqual(sorted(response.data['results'][0]), self.brief_fields) + + class CreateObjectViewTestCase(APITestCase): + create_data = [] + + def test_create_object(self): + """ + POST a single object. + """ + initial_count = self.model.objects.count() + url = self._get_list_url() + response = self.client.post(url, self.create_data[0], format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(self.model.objects.count(), initial_count + 1) + self.assertInstanceEqual(self.model.objects.get(pk=response.data['id']), self.create_data[0], api=True) + + def test_bulk_create_object(self): + """ + POST a set of objects in a single request. + """ + initial_count = self.model.objects.count() + url = self._get_list_url() + response = self.client.post(url, self.create_data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(self.model.objects.count(), initial_count + len(self.create_data)) + + class UpdateObjectViewTestCase(APITestCase): + update_data = {} + + def test_update_object(self): + """ + PATCH a single object identified by its numeric ID. + """ + instance = self.model.objects.first() + url = self._get_detail_url(instance) + update_data = self.update_data or getattr(self, 'create_data')[0] + response = self.client.patch(url, update_data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + instance.refresh_from_db() + self.assertInstanceEqual(instance, self.update_data, api=True) + + class DeleteObjectViewTestCase(APITestCase): + + def test_delete_object(self): + """ + DELETE a single object identified by its numeric ID. + """ + instance = self.model.objects.first() + url = self._get_detail_url(instance) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertFalse(self.model.objects.filter(pk=instance.pk).exists()) + + class APIViewTestCase( + GetObjectViewTestCase, + ListObjectsViewTestCase, + CreateObjectViewTestCase, + UpdateObjectViewTestCase, + DeleteObjectViewTestCase + ): + pass diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/views.py similarity index 87% rename from netbox/utilities/testing/testcases.py rename to netbox/utilities/testing/views.py index dabadc2fa..2400d7087 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/views.py @@ -5,14 +5,19 @@ from django.forms.models import model_to_dict from django.test import Client, TestCase as _TestCase, override_settings from django.urls import reverse, NoReverseMatch from netaddr import IPNetwork -from rest_framework import status -from rest_framework.test import APIClient -from users.models import ObjectPermission, Token +from users.models import ObjectPermission from utilities.permissions import resolve_permission_ct from .utils import disable_warnings, post_data +__all__ = ( + 'TestCase', + 'ModelViewTestCase', + 'ViewTestCases', +) + + class TestCase(_TestCase): user_permissions = () @@ -799,129 +804,3 @@ class ViewTestCases: TestCase suitable for testing device component models (ConsolePorts, Interfaces, etc.) """ maxDiff = None - - -# -# REST API Tests -# - -class APITestCase(TestCase): - client_class = APIClient - model = None - - def setUp(self): - """ - Create a superuser and token for API calls. - """ - self.user = User.objects.create(username='testuser', is_superuser=True) - self.token = Token.objects.create(user=self.user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)} - - def _get_detail_url(self, instance): - viewname = f'{instance._meta.app_label}-api:{instance._meta.model_name}-detail' - return reverse(viewname, kwargs={'pk': instance.pk}) - - def _get_list_url(self): - viewname = f'{self.model._meta.app_label}-api:{self.model._meta.model_name}-list' - return reverse(viewname) - - -class APIViewTestCases: - - class GetObjectViewTestCase(APITestCase): - - def test_get_object(self): - """ - GET a single object identified by its numeric ID. - """ - instance = self.model.objects.first() - url = self._get_detail_url(instance) - response = self.client.get(url, **self.header) - - self.assertEqual(response.data['id'], instance.pk) - - class ListObjectsViewTestCase(APITestCase): - brief_fields = [] - - def test_list_objects(self): - """ - GET a list of objects. - """ - url = self._get_list_url() - response = self.client.get(url, **self.header) - - self.assertEqual(len(response.data['results']), self.model.objects.count()) - - def test_list_objects_brief(self): - """ - GET a list of objects using the "brief" parameter. - """ - url = f'{self._get_list_url()}?brief=1' - response = self.client.get(url, **self.header) - - self.assertEqual(len(response.data['results']), self.model.objects.count()) - self.assertEqual(sorted(response.data['results'][0]), self.brief_fields) - - class CreateObjectViewTestCase(APITestCase): - create_data = [] - - def test_create_object(self): - """ - POST a single object. - """ - initial_count = self.model.objects.count() - url = self._get_list_url() - response = self.client.post(url, self.create_data[0], format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(self.model.objects.count(), initial_count + 1) - self.assertInstanceEqual(self.model.objects.get(pk=response.data['id']), self.create_data[0], api=True) - - def test_bulk_create_object(self): - """ - POST a set of objects in a single request. - """ - initial_count = self.model.objects.count() - url = self._get_list_url() - response = self.client.post(url, self.create_data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(self.model.objects.count(), initial_count + len(self.create_data)) - - class UpdateObjectViewTestCase(APITestCase): - update_data = {} - - def test_update_object(self): - """ - PATCH a single object identified by its numeric ID. - """ - instance = self.model.objects.first() - url = self._get_detail_url(instance) - update_data = self.update_data or getattr(self, 'create_data')[0] - response = self.client.patch(url, update_data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_200_OK) - instance.refresh_from_db() - self.assertInstanceEqual(instance, self.update_data, api=True) - - class DeleteObjectViewTestCase(APITestCase): - - def test_delete_object(self): - """ - DELETE a single object identified by its numeric ID. - """ - instance = self.model.objects.first() - url = self._get_detail_url(instance) - response = self.client.delete(url, **self.header) - - self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) - self.assertFalse(self.model.objects.filter(pk=instance.pk).exists()) - - class APIViewTestCase( - GetObjectViewTestCase, - ListObjectsViewTestCase, - CreateObjectViewTestCase, - UpdateObjectViewTestCase, - DeleteObjectViewTestCase - ): - pass From 830fd5f83ab04fb3032677a5f7374c13617c41fa Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 8 Jun 2020 11:22:12 -0400 Subject: [PATCH 118/505] Remove duplicate test method --- netbox/utilities/testing/views.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 2400d7087..1ba96e395 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -207,13 +207,6 @@ class ViewTestCases: # Try GET to non-permitted object self.assertHttpStatus(self.client.get(instance2.get_absolute_url()), 404) - @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) - class CreateObjectViewTestCase(ModelViewTestCase): """ Create a single new instance. From 3b44e7c1c4a8d8bee88f808a4bbeb05cf78d2aaf Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 8 Jun 2020 11:35:01 -0400 Subject: [PATCH 119/505] Update API test methods to evaluate permissions assignment --- netbox/utilities/testing/api.py | 174 +++++++++++++++++++++++++++++--- 1 file changed, 158 insertions(+), 16 deletions(-) diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index d9688b6e2..fb8b0d795 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -1,10 +1,12 @@ from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType from django.urls import reverse from django.test import override_settings from rest_framework import status from rest_framework.test import APIClient -from users.models import Token +from users.models import ObjectPermission, Token +from .utils import disable_warnings from .views import TestCase @@ -26,7 +28,9 @@ class APITestCase(TestCase): """ Create a superuser and token for API calls. """ - self.user = User.objects.create(username='testuser', is_superuser=True) + # Create the test user and assign permissions + self.user = User.objects.create_user(username='testuser') + self.add_permissions(*self.user_permissions) self.token = Token.objects.create(user=self.user) self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)} @@ -43,32 +47,70 @@ class APIViewTestCases: class GetObjectViewTestCase(APITestCase): + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_get_object_anonymous(self): + """ + GET a single object as an unauthenticated user. + """ + url = self._get_detail_url(self.model.objects.first()) + response = self.client.get(url, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_get_object_without_permission(self): + """ + GET a single object as an authenticated user without the required permission. + """ + url = self._get_detail_url(self.model.objects.first()) + + # Try GET without permission + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_403_FORBIDDEN) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_get_object(self): """ - GET a single object identified by its numeric ID. + GET a single object as an authenticated user with permission to view the object. """ - instance = self.model.objects.first() - url = self._get_detail_url(instance) - response = self.client.get(url, **self.header) + self.assertGreaterEqual(self.model.objects.count(), 2, + f"Test requires the creation of at least two {self.model} instances") + instance1, instance2 = self.model.objects.all()[:2] - self.assertEqual(response.data['id'], instance.pk) + # Add object-level permission + obj_perm = ObjectPermission( + constraints={'pk': instance1.pk}, + actions=['view'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + # Try GET to permitted object + url = self._get_detail_url(instance1) + self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK) + + # Try GET to non-permitted object + url = self._get_detail_url(instance2) + self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_404_NOT_FOUND) class ListObjectsViewTestCase(APITestCase): brief_fields = [] - def test_list_objects(self): + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_list_objects_anonymous(self): """ - GET a list of objects. + GET a list of objects as an unauthenticated user. """ url = self._get_list_url() response = self.client.get(url, **self.header) self.assertEqual(len(response.data['results']), self.model.objects.count()) + self.assertHttpStatus(response, status.HTTP_200_OK) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_list_objects_brief(self): """ - GET a list of objects using the "brief" parameter. + GET a list of objects using the "brief" parameter as an unauthenticated user. """ url = f'{self._get_list_url()}?brief=1' response = self.client.get(url, **self.header) @@ -76,35 +118,108 @@ class APIViewTestCases: self.assertEqual(len(response.data['results']), self.model.objects.count()) self.assertEqual(sorted(response.data['results'][0]), self.brief_fields) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_list_objects_without_permission(self): + """ + GET a list of objects as an authenticated user without the required permission. + """ + url = self._get_list_url() + + # Try GET without permission + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_403_FORBIDDEN) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_list_objects(self): + """ + GET a list of objects as an authenticated user with permission to view the objects. + """ + self.assertGreaterEqual(self.model.objects.count(), 3, + f"Test requires the creation of at least three {self.model} instances") + instance1, instance2 = self.model.objects.all()[:2] + + # Add object-level permission + obj_perm = ObjectPermission( + constraints={'pk__in': [instance1.pk, instance2.pk]}, + actions=['view'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + # Try GET to permitted objects + response = self.client.get(self._get_list_url(), **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(len(response.data['results']), 2) + class CreateObjectViewTestCase(APITestCase): create_data = [] + def test_create_object_without_permission(self): + """ + POST a single object without permission. + """ + url = self._get_list_url() + + # Try POST without permission + with disable_warnings('django.request'): + response = self.client.post(url, self.create_data[0], format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN) + def test_create_object(self): """ - POST a single object. + POST a single object with permission. """ initial_count = self.model.objects.count() url = self._get_list_url() - response = self.client.post(url, self.create_data[0], format='json', **self.header) + # Add object-level permission + obj_perm = ObjectPermission( + actions=['add'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + response = self.client.post(url, self.create_data[0], format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(self.model.objects.count(), initial_count + 1) self.assertInstanceEqual(self.model.objects.get(pk=response.data['id']), self.create_data[0], api=True) - def test_bulk_create_object(self): + def test_bulk_create_objects(self): """ POST a set of objects in a single request. """ initial_count = self.model.objects.count() url = self._get_list_url() - response = self.client.post(url, self.create_data, format='json', **self.header) + # Add object-level permission + obj_perm = ObjectPermission( + actions=['add'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + response = self.client.post(url, self.create_data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(self.model.objects.count(), initial_count + len(self.create_data)) class UpdateObjectViewTestCase(APITestCase): update_data = {} + def test_update_object_without_permission(self): + """ + PATCH a single object without permission. + """ + url = self._get_detail_url(self.model.objects.first()) + update_data = self.update_data or getattr(self, 'create_data')[0] + + # Try PATCH without permission + with disable_warnings('django.request'): + response = self.client.patch(url, update_data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN) + def test_update_object(self): """ PATCH a single object identified by its numeric ID. @@ -112,22 +227,49 @@ class APIViewTestCases: instance = self.model.objects.first() url = self._get_detail_url(instance) update_data = self.update_data or getattr(self, 'create_data')[0] - response = self.client.patch(url, update_data, format='json', **self.header) + # Add object-level permission + obj_perm = ObjectPermission( + actions=['change'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + response = self.client.patch(url, update_data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) instance.refresh_from_db() self.assertInstanceEqual(instance, self.update_data, api=True) class DeleteObjectViewTestCase(APITestCase): + def test_delete_object_without_permission(self): + """ + DELETE a single object without permission. + """ + url = self._get_detail_url(self.model.objects.first()) + + # Try DELETE without permission + with disable_warnings('django.request'): + response = self.client.delete(url, **self.header) + self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN) + def test_delete_object(self): """ DELETE a single object identified by its numeric ID. """ instance = self.model.objects.first() url = self._get_detail_url(instance) - response = self.client.delete(url, **self.header) + # Add object-level permission + obj_perm = ObjectPermission( + actions=['delete'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + response = self.client.delete(url, **self.header) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertFalse(self.model.objects.filter(pk=instance.pk).exists()) From a8145fe4c275463ecb54a1f2b522359b5f9c19d4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 8 Jun 2020 12:22:29 -0400 Subject: [PATCH 120/505] Add permission assignment to custom test methods --- netbox/circuits/tests/test_api.py | 1 + netbox/dcim/tests/test_api.py | 20 +++++++++ netbox/extras/tests/test_api.py | 6 +++ netbox/extras/tests/test_changelog.py | 17 +++----- netbox/extras/tests/test_customfields.py | 22 ++++++---- netbox/extras/tests/test_tags.py | 11 ++--- netbox/extras/tests/test_webhooks.py | 12 +++--- netbox/ipam/tests/test_api.py | 11 +++++ netbox/utilities/tests/test_api.py | 31 ++++++-------- netbox/virtualization/tests/test_api.py | 53 +++++++++++------------- 10 files changed, 104 insertions(+), 80 deletions(-) diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 71db7234d..4e062cc1a 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -58,6 +58,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase): ) Graph.objects.bulk_create(graphs) + self.add_permissions('circuits.view_provider') url = reverse('circuits-api:provider-graphs', kwargs={'pk': provider.pk}) response = self.client.get(url, **self.header) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 6c6b75c7f..c0e1de5c8 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -106,6 +106,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase): ) Graph.objects.bulk_create(graphs) + self.add_permissions('dcim.view_site') url = reverse('dcim-api:site-graphs', kwargs={'pk': Site.objects.first().pk}) response = self.client.get(url, **self.header) @@ -245,6 +246,7 @@ class RackTest(APIViewTestCases.APIViewTestCase): def test_get_elevation_rack_units(self): rack = Rack.objects.first() + self.add_permissions('dcim.view_rack') url = '{}?q=3'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk})) response = self.client.get(url, **self.header) @@ -270,6 +272,7 @@ class RackTest(APIViewTestCases.APIViewTestCase): GET a single rack elevation. """ rack = Rack.objects.first() + self.add_permissions('dcim.view_rack') url = reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk}) response = self.client.get(url, **self.header) @@ -280,6 +283,7 @@ class RackTest(APIViewTestCases.APIViewTestCase): GET a single rack elevation in SVG format. """ rack = Rack.objects.first() + self.add_permissions('dcim.view_rack') url = '{}?render=svg'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk})) response = self.client.get(url, **self.header) @@ -784,6 +788,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): ) Graph.objects.bulk_create(graphs) + self.add_permissions('dcim.view_device') url = reverse('dcim-api:device-graphs', kwargs={'pk': Device.objects.first().pk}) response = self.client.get(url, **self.header) @@ -794,6 +799,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): """ Check that config context data is included by default in the devices list. """ + self.add_permissions('dcim.view_device') url = reverse('dcim-api:device-list') + '?slug=device-with-context-data' response = self.client.get(url, **self.header) @@ -803,6 +809,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): """ Check that config context data can be excluded by passing ?exclude=config_context. """ + self.add_permissions('dcim.view_device') url = reverse('dcim-api:device-list') + '?exclude=config_context' response = self.client.get(url, **self.header) @@ -820,6 +827,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): 'name': device.name, } + self.add_permissions('dcim.add_device') url = reverse('dcim-api:device-list') response = self.client.post(url, data, format='json', **self.header) @@ -878,6 +886,7 @@ class ConsolePortTest(APIViewTestCases.APIViewTestCase): cable = Cable(termination_a=consoleport, termination_b=consoleserverport, label='Cable 1') cable.save() + self.add_permissions('dcim.view_consoleport') url = reverse('dcim-api:consoleport-trace', kwargs={'pk': consoleport.pk}) response = self.client.get(url, **self.header) @@ -941,6 +950,7 @@ class ConsoleServerPortTest(APIViewTestCases.APIViewTestCase): cable = Cable(termination_a=consoleserverport, termination_b=consoleport, label='Cable 1') cable.save() + self.add_permissions('dcim.view_consoleserverport') url = reverse('dcim-api:consoleserverport-trace', kwargs={'pk': consoleserverport.pk}) response = self.client.get(url, **self.header) @@ -1004,6 +1014,7 @@ class PowerPortTest(APIViewTestCases.APIViewTestCase): cable = Cable(termination_a=powerport, termination_b=poweroutlet, label='Cable 1') cable.save() + self.add_permissions('dcim.view_powerport') url = reverse('dcim-api:powerport-trace', kwargs={'pk': powerport.pk}) response = self.client.get(url, **self.header) @@ -1067,6 +1078,7 @@ class PowerOutletTest(APIViewTestCases.APIViewTestCase): cable = Cable(termination_a=poweroutlet, termination_b=powerport, label='Cable 1') cable.save() + self.add_permissions('dcim.view_poweroutlet') url = reverse('dcim-api:poweroutlet-trace', kwargs={'pk': poweroutlet.pk}) response = self.client.get(url, **self.header) @@ -1143,6 +1155,7 @@ class InterfaceTest(APIViewTestCases.APIViewTestCase): ) Graph.objects.bulk_create(graphs) + self.add_permissions('dcim.view_interface') url = reverse('dcim-api:interface-graphs', kwargs={'pk': Interface.objects.first().pk}) response = self.client.get(url, **self.header) @@ -1446,6 +1459,7 @@ class ConnectionTest(APITestCase): 'termination_b_id': consoleserverport1.pk, } + self.add_permissions('dcim.add_cable') url = reverse('dcim-api:cable-list') response = self.client.post(url, data, format='json', **self.header) @@ -1484,6 +1498,7 @@ class ConnectionTest(APITestCase): device=self.panel2, name='Test Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport2 ) + self.add_permissions('dcim.add_cable') url = reverse('dcim-api:cable-list') cables = [ # Console port to panel1 front @@ -1539,6 +1554,7 @@ class ConnectionTest(APITestCase): 'termination_b_id': poweroutlet1.pk, } + self.add_permissions('dcim.add_cable') url = reverse('dcim-api:cable-list') response = self.client.post(url, data, format='json', **self.header) @@ -1574,6 +1590,7 @@ class ConnectionTest(APITestCase): 'termination_b_id': interface2.pk, } + self.add_permissions('dcim.add_cable') url = reverse('dcim-api:cable-list') response = self.client.post(url, data, format='json', **self.header) @@ -1612,6 +1629,7 @@ class ConnectionTest(APITestCase): device=self.panel2, name='Test Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport2 ) + self.add_permissions('dcim.add_cable') url = reverse('dcim-api:cable-list') cables = [ # Interface1 to panel1 front @@ -1676,6 +1694,7 @@ class ConnectionTest(APITestCase): 'termination_b_id': circuittermination1.pk, } + self.add_permissions('dcim.add_cable') url = reverse('dcim-api:cable-list') response = self.client.post(url, data, format='json', **self.header) @@ -1723,6 +1742,7 @@ class ConnectionTest(APITestCase): device=self.panel2, name='Test Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport2 ) + self.add_permissions('dcim.add_cable') url = reverse('dcim-api:cable-list') cables = [ # Interface to panel1 front diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 2635fbbdc..0c1fcd91c 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -295,6 +295,7 @@ class CreatedUpdatedFilterTest(APITestCase): ) def test_get_rack_created(self): + self.add_permissions('dcim.view_rack') url = reverse('dcim-api:rack-list') response = self.client.get('{}?created=2001-02-03'.format(url), **self.header) @@ -302,6 +303,7 @@ class CreatedUpdatedFilterTest(APITestCase): self.assertEqual(response.data['results'][0]['id'], self.rack2.pk) def test_get_rack_created_gte(self): + self.add_permissions('dcim.view_rack') url = reverse('dcim-api:rack-list') response = self.client.get('{}?created__gte=2001-02-04'.format(url), **self.header) @@ -309,6 +311,7 @@ class CreatedUpdatedFilterTest(APITestCase): self.assertEqual(response.data['results'][0]['id'], self.rack1.pk) def test_get_rack_created_lte(self): + self.add_permissions('dcim.view_rack') url = reverse('dcim-api:rack-list') response = self.client.get('{}?created__lte=2001-02-04'.format(url), **self.header) @@ -316,6 +319,7 @@ class CreatedUpdatedFilterTest(APITestCase): self.assertEqual(response.data['results'][0]['id'], self.rack2.pk) def test_get_rack_last_updated(self): + self.add_permissions('dcim.view_rack') url = reverse('dcim-api:rack-list') response = self.client.get('{}?last_updated=2001-02-03%2001:02:03.000004'.format(url), **self.header) @@ -323,6 +327,7 @@ class CreatedUpdatedFilterTest(APITestCase): self.assertEqual(response.data['results'][0]['id'], self.rack2.pk) def test_get_rack_last_updated_gte(self): + self.add_permissions('dcim.view_rack') url = reverse('dcim-api:rack-list') response = self.client.get('{}?last_updated__gte=2001-02-04%2001:02:03.000004'.format(url), **self.header) @@ -330,6 +335,7 @@ class CreatedUpdatedFilterTest(APITestCase): self.assertEqual(response.data['results'][0]['id'], self.rack1.pk) def test_get_rack_last_updated_lte(self): + self.add_permissions('dcim.view_rack') url = reverse('dcim-api:rack-list') response = self.client.get('{}?last_updated__lte=2001-02-04%2001:02:03.000004'.format(url), **self.header) diff --git a/netbox/extras/tests/test_changelog.py b/netbox/extras/tests/test_changelog.py index 8f01cc3bf..50d9c5be6 100644 --- a/netbox/extras/tests/test_changelog.py +++ b/netbox/extras/tests/test_changelog.py @@ -4,7 +4,6 @@ from rest_framework import status from dcim.models import Site from extras.choices import * -from extras.constants import * from extras.models import CustomField, CustomFieldValue, ObjectChange from utilities.testing import APITestCase @@ -26,7 +25,6 @@ class ChangeLogTest(APITestCase): cf.obj_type.set([ct]) def test_create_object(self): - data = { 'name': 'Test Site 1', 'slug': 'test-site-1', @@ -37,10 +35,10 @@ class ChangeLogTest(APITestCase): 'bar', 'foo' ], } - self.assertEqual(ObjectChange.objects.count(), 0) - url = reverse('dcim-api:site-list') + self.add_permissions('dcim.add_site') + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) @@ -55,7 +53,6 @@ class ChangeLogTest(APITestCase): self.assertListEqual(sorted(oc.object_data['tags']), data['tags']) def test_update_object(self): - site = Site(name='Test Site 1', slug='test-site-1') site.save() @@ -69,10 +66,10 @@ class ChangeLogTest(APITestCase): 'abc', 'xyz' ], } - self.assertEqual(ObjectChange.objects.count(), 0) - + self.add_permissions('dcim.change_site') url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk}) + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) @@ -87,7 +84,6 @@ class ChangeLogTest(APITestCase): self.assertListEqual(sorted(oc.object_data['tags']), data['tags']) def test_delete_object(self): - site = Site( name='Test Site 1', slug='test-site-1' @@ -99,12 +95,11 @@ class ChangeLogTest(APITestCase): obj=site, value='ABC' ) - self.assertEqual(ObjectChange.objects.count(), 0) - + self.add_permissions('dcim.delete_site') url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk}) - response = self.client.delete(url, **self.header) + response = self.client.delete(url, **self.header) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(Site.objects.count(), 0) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 4df06e12f..e543b63e3 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -182,8 +182,9 @@ class CustomFieldAPITest(APITestCase): Validate that custom fields are present on an object even if it has no values defined. """ url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[0].pk}) - response = self.client.get(url, **self.header) + self.add_permissions('dcim.view_site') + response = self.client.get(url, **self.header) self.assertEqual(response.data['name'], self.sites[0].name) self.assertEqual(response.data['custom_fields'], { 'text_field': None, @@ -201,10 +202,10 @@ class CustomFieldAPITest(APITestCase): site2_cfvs = { cfv.field.name: cfv.value for cfv in self.sites[1].custom_field_values.all() } - url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk}) - response = self.client.get(url, **self.header) + self.add_permissions('dcim.view_site') + response = self.client.get(url, **self.header) self.assertEqual(response.data['name'], self.sites[1].name) self.assertEqual(response.data['custom_fields']['text_field'], site2_cfvs['text_field']) self.assertEqual(response.data['custom_fields']['number_field'], site2_cfvs['number_field']) @@ -221,8 +222,9 @@ class CustomFieldAPITest(APITestCase): 'name': 'Site 3', 'slug': 'site-3', } - url = reverse('dcim-api:site-list') + self.add_permissions('dcim.add_site') + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) @@ -263,8 +265,9 @@ class CustomFieldAPITest(APITestCase): 'choice_field': self.cf_select_choice2.pk, }, } - url = reverse('dcim-api:site-list') + self.add_permissions('dcim.add_site') + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) @@ -309,8 +312,9 @@ class CustomFieldAPITest(APITestCase): 'slug': 'site-5', }, ) - url = reverse('dcim-api:site-list') + self.add_permissions('dcim.add_site') + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(len(response.data), len(data)) @@ -367,8 +371,9 @@ class CustomFieldAPITest(APITestCase): 'custom_fields': custom_field_data, }, ) - url = reverse('dcim-api:site-list') + self.add_permissions('dcim.add_site') + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(len(response.data), len(data)) @@ -410,8 +415,9 @@ class CustomFieldAPITest(APITestCase): 'number_field': 1234, }, } - url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk}) + self.add_permissions('dcim.change_site') + response = self.client.patch(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) diff --git a/netbox/extras/tests/test_tags.py b/netbox/extras/tests/test_tags.py index 9b50959bc..8991c58e7 100644 --- a/netbox/extras/tests/test_tags.py +++ b/netbox/extras/tests/test_tags.py @@ -15,16 +15,15 @@ class TaggedItemTest(APITestCase): super().setUp() def test_create_tagged_item(self): - data = { 'name': 'Test Site', 'slug': 'test-site', 'tags': ['Foo', 'Bar', 'Baz'] } - url = reverse('dcim-api:site-list') - response = self.client.post(url, data, format='json', **self.header) + self.add_permissions('dcim.add_site') + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(sorted(response.data['tags']), sorted(data['tags'])) site = Site.objects.get(pk=response.data['id']) @@ -32,20 +31,18 @@ class TaggedItemTest(APITestCase): self.assertEqual(sorted(tags), sorted(data['tags'])) def test_update_tagged_item(self): - site = Site.objects.create( name='Test Site', slug='test-site' ) site.tags.add('Foo', 'Bar', 'Baz') - data = { 'tags': ['Foo', 'Bar', 'New Tag'] } - + self.add_permissions('dcim.change_site') url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk}) - response = self.client.patch(url, data, format='json', **self.header) + response = self.client.patch(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(sorted(response.data['tags']), sorted(data['tags'])) site = Site.objects.get(pk=response.data['id']) diff --git a/netbox/extras/tests/test_webhooks.py b/netbox/extras/tests/test_webhooks.py index f87242404..8f741bbc2 100644 --- a/netbox/extras/tests/test_webhooks.py +++ b/netbox/extras/tests/test_webhooks.py @@ -42,13 +42,13 @@ class WebhookTest(APITestCase): webhook.obj_type.set([site_ct]) def test_enqueue_webhook_create(self): - # Create an object via the REST API data = { 'name': 'Test Site', 'slug': 'test-site', } url = reverse('dcim-api:site-list') + self.add_permissions('dcim.add_site') response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Site.objects.count(), 1) @@ -62,14 +62,13 @@ class WebhookTest(APITestCase): self.assertEqual(job.args[3], ObjectChangeActionChoices.ACTION_CREATE) def test_enqueue_webhook_update(self): - - site = Site.objects.create(name='Site 1', slug='site-1') - # Update an object via the REST API + site = Site.objects.create(name='Site 1', slug='site-1') data = { 'comments': 'Updated the site', } url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk}) + self.add_permissions('dcim.change_site') response = self.client.patch(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) @@ -82,11 +81,10 @@ class WebhookTest(APITestCase): self.assertEqual(job.args[3], ObjectChangeActionChoices.ACTION_UPDATE) def test_enqueue_webhook_delete(self): - - site = Site.objects.create(name='Site 1', slug='site-1') - # Delete an object via the REST API + site = Site.objects.create(name='Site 1', slug='site-1') url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk}) + self.add_permissions('dcim.delete_site') response = self.client.delete(url, **self.header) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index a5cb76953..41f39703c 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -176,6 +176,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase): Prefix.objects.create(prefix=IPNetwork('192.0.2.64/26')) Prefix.objects.create(prefix=IPNetwork('192.0.2.192/27')) url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk}) + self.add_permissions('ipam.view_prefix') # Retrieve all available IPs response = self.client.get(url, **self.header) @@ -190,6 +191,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase): vrf = VRF.objects.create(name='Test VRF 1', rd='1234') prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), vrf=vrf, is_pool=True) url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk}) + self.add_permissions('ipam.add_prefix') # Create four available prefixes with individual requests prefixes_to_be_created = [ @@ -225,6 +227,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase): """ prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), is_pool=True) url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk}) + self.add_permissions('ipam.view_prefix', 'ipam.add_prefix') # Try to create five /30s (only four are available) data = [ @@ -240,6 +243,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase): # Verify that no prefixes were created (the entire /28 is still available) response = self.client.get(url, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(response.data[0]['prefix'], '192.0.2.0/28') # Create four /30s in a single request @@ -253,6 +257,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase): """ prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/29'), is_pool=True) url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk}) + self.add_permissions('ipam.view_prefix', 'ipam.view_ipaddress') # Retrieve all available IPs response = self.client.get(url, **self.header) @@ -271,6 +276,8 @@ class PrefixTest(APIViewTestCases.APIViewTestCase): vrf = VRF.objects.create(name='Test VRF 1', rd='1234') prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/30'), vrf=vrf, is_pool=True) url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk}) + # TODO: ipam.add_prefix should not be required + self.add_permissions('ipam.add_prefix', 'ipam.add_ipaddress') # Create all four available IPs with individual requests for i in range(1, 5): @@ -293,6 +300,8 @@ class PrefixTest(APIViewTestCases.APIViewTestCase): """ prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/29'), is_pool=True) url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk}) + # TODO: ipam.add_prefix, ipam.view_prefix should not be required + self.add_permissions('ipam.add_prefix', 'ipam.view_prefix', 'ipam.view_ipaddress', 'ipam.add_ipaddress') # Try to create nine IPs (only eight are available) data = [{'description': 'Test IP {}'.format(i)} for i in range(1, 10)] # 9 IPs @@ -302,6 +311,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase): # Verify that no IPs were created (eight are still available) response = self.client.get(url, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(len(response.data), 8) # Create all eight available IPs in a single request @@ -411,6 +421,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase): vlan = VLAN.objects.first() Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'), vlan=vlan) + self.add_permissions('ipam.delete_vlan') url = reverse('ipam-api:vlan-detail', kwargs={'pk': vlan.pk}) with disable_warnings('django.request'): response = self.client.delete(url, **self.header) diff --git a/netbox/utilities/tests/test_api.py b/netbox/utilities/tests/test_api.py index 469bb3150..01d4ab8f3 100644 --- a/netbox/utilities/tests/test_api.py +++ b/netbox/utilities/tests/test_api.py @@ -18,7 +18,6 @@ class WritableNestedSerializerTest(APITestCase): """ def setUp(self): - super().setUp() self.region_a = Region.objects.create(name='Region A', slug='region-a') @@ -26,39 +25,36 @@ class WritableNestedSerializerTest(APITestCase): self.site2 = Site.objects.create(region=self.region_a, name='Site 2', slug='site-2') def test_related_by_pk(self): - data = { 'vid': 100, 'name': 'Test VLAN 100', 'site': self.site1.pk, } - url = reverse('ipam-api:vlan-list') - response = self.client.post(url, data, format='json', **self.header) + self.add_permissions('ipam.add_vlan') + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(response.data['site']['id'], self.site1.pk) vlan = VLAN.objects.get(pk=response.data['id']) self.assertEqual(vlan.site, self.site1) def test_related_by_pk_no_match(self): - data = { 'vid': 100, 'name': 'Test VLAN 100', 'site': 999, } - url = reverse('ipam-api:vlan-list') + self.add_permissions('ipam.add_vlan') + with disable_warnings('django.request'): response = self.client.post(url, data, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) self.assertEqual(VLAN.objects.count(), 0) self.assertTrue(response.data['site'][0].startswith("Related object not found")) def test_related_by_attributes(self): - data = { 'vid': 100, 'name': 'Test VLAN 100', @@ -66,17 +62,16 @@ class WritableNestedSerializerTest(APITestCase): 'name': 'Site 1' }, } - url = reverse('ipam-api:vlan-list') - response = self.client.post(url, data, format='json', **self.header) + self.add_permissions('ipam.add_vlan') + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(response.data['site']['id'], self.site1.pk) vlan = VLAN.objects.get(pk=response.data['id']) self.assertEqual(vlan.site, self.site1) def test_related_by_attributes_no_match(self): - data = { 'vid': 100, 'name': 'Test VLAN 100', @@ -84,17 +79,16 @@ class WritableNestedSerializerTest(APITestCase): 'name': 'Site X' }, } - url = reverse('ipam-api:vlan-list') + self.add_permissions('ipam.add_vlan') + with disable_warnings('django.request'): response = self.client.post(url, data, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) self.assertEqual(VLAN.objects.count(), 0) self.assertTrue(response.data['site'][0].startswith("Related object not found")) def test_related_by_attributes_multiple_matches(self): - data = { 'vid': 100, 'name': 'Test VLAN 100', @@ -104,27 +98,26 @@ class WritableNestedSerializerTest(APITestCase): }, }, } - url = reverse('ipam-api:vlan-list') + self.add_permissions('ipam.add_vlan') + with disable_warnings('django.request'): response = self.client.post(url, data, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) self.assertEqual(VLAN.objects.count(), 0) self.assertTrue(response.data['site'][0].startswith("Multiple objects match")) def test_related_by_invalid(self): - data = { 'vid': 100, 'name': 'Test VLAN 100', 'site': 'XXX', } - url = reverse('ipam-api:vlan-list') + self.add_permissions('ipam.add_vlan') + with disable_warnings('django.request'): response = self.client.post(url, data, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) self.assertEqual(VLAN.objects.count(), 0) diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index e436a46c5..6b466116e 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -164,10 +164,10 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): Check that config context data is included by default in the virtual machines list. """ virtualmachine = VirtualMachine.objects.first() - url = reverse('virtualization-api:virtualmachine-list') - url = '{}?id={}'.format(url, virtualmachine.pk) - response = self.client.get(url, **self.header) + url = '{}?id={}'.format(reverse('virtualization-api:virtualmachine-list'), virtualmachine.pk) + self.add_permissions('virtualization.view_virtualmachine') + response = self.client.get(url, **self.header) self.assertEqual(response.data['results'][0].get('config_context', {}).get('A'), 1) def test_config_context_excluded(self): @@ -175,8 +175,9 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): Check that config context data can be excluded by passing ?exclude=config_context. """ url = reverse('virtualization-api:virtualmachine-list') + '?exclude=config_context' - response = self.client.get(url, **self.header) + self.add_permissions('virtualization.view_virtualmachine') + response = self.client.get(url, **self.header) self.assertFalse('config_context' in response.data['results'][0]) def test_unique_name_per_cluster_constraint(self): @@ -188,8 +189,9 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): 'cluster': Cluster.objects.first().pk, } url = reverse('virtualization-api:virtualmachine-list') - response = self.client.post(url, data, format='json', **self.header) + self.add_permissions('virtualization.add_virtualmachine') + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) @@ -224,39 +226,38 @@ class InterfaceTest(APITestCase): self.vlan3 = VLAN.objects.create(name="Test VLAN 3", vid=3) def test_get_interface(self): - url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk}) - response = self.client.get(url, **self.header) + self.add_permissions('dcim.view_interface') + response = self.client.get(url, **self.header) self.assertEqual(response.data['name'], self.interface1.name) def test_list_interfaces(self): - url = reverse('virtualization-api:interface-list') - response = self.client.get(url, **self.header) + self.add_permissions('dcim.view_interface') + response = self.client.get(url, **self.header) self.assertEqual(response.data['count'], 3) def test_list_interfaces_brief(self): - url = reverse('virtualization-api:interface-list') - response = self.client.get('{}?brief=1'.format(url), **self.header) + self.add_permissions('dcim.view_interface') + response = self.client.get('{}?brief=1'.format(url), **self.header) self.assertEqual( sorted(response.data['results'][0]), ['id', 'name', 'url', 'virtual_machine'] ) def test_create_interface(self): - data = { 'virtual_machine': self.virtualmachine.pk, 'name': 'Test Interface 4', } - url = reverse('virtualization-api:interface-list') - response = self.client.post(url, data, format='json', **self.header) + self.add_permissions('dcim.add_interface') + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Interface.objects.count(), 4) interface4 = Interface.objects.get(pk=response.data['id']) @@ -264,7 +265,6 @@ class InterfaceTest(APITestCase): self.assertEqual(interface4.name, data['name']) def test_create_interface_with_802_1q(self): - data = { 'virtual_machine': self.virtualmachine.pk, 'name': 'Test Interface 4', @@ -272,10 +272,10 @@ class InterfaceTest(APITestCase): 'untagged_vlan': self.vlan3.id, 'tagged_vlans': [self.vlan1.id, self.vlan2.id], } - url = reverse('virtualization-api:interface-list') - response = self.client.post(url, data, format='json', **self.header) + self.add_permissions('dcim.add_interface') + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Interface.objects.count(), 4) self.assertEqual(response.data['virtual_machine']['id'], data['virtual_machine']) @@ -284,7 +284,6 @@ class InterfaceTest(APITestCase): self.assertEqual([v['id'] for v in response.data['tagged_vlans']], data['tagged_vlans']) def test_create_interface_bulk(self): - data = [ { 'virtual_machine': self.virtualmachine.pk, @@ -299,10 +298,10 @@ class InterfaceTest(APITestCase): 'name': 'Test Interface 6', }, ] - url = reverse('virtualization-api:interface-list') - response = self.client.post(url, data, format='json', **self.header) + self.add_permissions('dcim.add_interface') + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Interface.objects.count(), 6) self.assertEqual(response.data[0]['name'], data[0]['name']) @@ -310,7 +309,6 @@ class InterfaceTest(APITestCase): self.assertEqual(response.data[2]['name'], data[2]['name']) def test_create_interface_802_1q_bulk(self): - data = [ { 'virtual_machine': self.virtualmachine.pk, @@ -334,10 +332,10 @@ class InterfaceTest(APITestCase): 'tagged_vlans': [self.vlan1.id], }, ] - url = reverse('virtualization-api:interface-list') - response = self.client.post(url, data, format='json', **self.header) + self.add_permissions('dcim.add_interface') + response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Interface.objects.count(), 6) for i in range(0, 3): @@ -346,24 +344,23 @@ class InterfaceTest(APITestCase): self.assertEqual(response.data[i]['untagged_vlan']['id'], data[i]['untagged_vlan']) def test_update_interface(self): - data = { 'virtual_machine': self.virtualmachine.pk, 'name': 'Test Interface X', } - url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk}) - response = self.client.put(url, data, format='json', **self.header) + self.add_permissions('dcim.change_interface') + response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(Interface.objects.count(), 3) interface1 = Interface.objects.get(pk=response.data['id']) self.assertEqual(interface1.name, data['name']) def test_delete_interface(self): - url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk}) - response = self.client.delete(url, **self.header) + self.add_permissions('dcim.delete_interface') + response = self.client.delete(url, **self.header) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(Interface.objects.count(), 2) From c1a37db87187649bb3bc86855a59bf38d34ccf10 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 8 Jun 2020 13:41:12 -0400 Subject: [PATCH 121/505] Fix managers on Graph and ExportTemplate --- netbox/extras/models/models.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 9e000774f..082a153e9 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -232,6 +232,8 @@ class Graph(models.Model): verbose_name='Link URL' ) + objects = RestrictedQuerySet.as_manager() + class Meta: ordering = ('type', 'weight', 'name', 'pk') # (type, weight, name) may be non-unique @@ -299,6 +301,8 @@ class ExportTemplate(models.Model): help_text='Extension to append to the rendered filename' ) + objects = RestrictedQuerySet.as_manager() + class Meta: ordering = ['content_type', 'name'] unique_together = [ From 62224857f0784e5864e5ec81ecffa9a4da319b60 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 8 Jun 2020 14:01:15 -0400 Subject: [PATCH 122/505] Standardize ObjectPermissionTest --- netbox/users/models.py | 3 ++ netbox/users/tests/test_api.py | 83 ++++----------------------------- netbox/utilities/testing/api.py | 12 ++--- 3 files changed, 15 insertions(+), 83 deletions(-) diff --git a/netbox/users/models.py b/netbox/users/models.py index fa3277456..7987ccb7a 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -10,6 +10,7 @@ from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone +from utilities.querysets import RestrictedQuerySet from utilities.utils import flatten_dict @@ -262,6 +263,8 @@ class ObjectPermission(models.Model): help_text="Queryset filter matching the applicable objects of the selected type(s)" ) + objects = RestrictedQuerySet.as_manager() + class Meta: verbose_name = "Permission" diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py index f507192ee..2494c8f31 100644 --- a/netbox/users/tests/test_api.py +++ b/netbox/users/tests/test_api.py @@ -1,10 +1,9 @@ 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 +from utilities.testing import APIViewTestCases, APITestCase class AppTest(APITestCase): @@ -17,7 +16,12 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) -class ObjectPermissionTest(APITestCase): +class ObjectPermissionTest(APIViewTestCases.APIViewTestCase): + model = ObjectPermission + brief_fields = [] + + # TODO: Add a nested serializer for ObjectPermission + test_list_objects_brief = None @classmethod def setUpTestData(cls): @@ -48,43 +52,7 @@ class ObjectPermissionTest(APITestCase): 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 = [ + cls.create_data = [ { 'object_types': ['dcim.site'], 'groups': [groups[0].pk], @@ -107,38 +75,3 @@ class ObjectPermissionTest(APITestCase): '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) diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index fb8b0d795..1e2063a6d 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -170,9 +170,6 @@ class APIViewTestCases: """ POST a single object with permission. """ - initial_count = self.model.objects.count() - url = self._get_list_url() - # Add object-level permission obj_perm = ObjectPermission( actions=['add'] @@ -181,7 +178,8 @@ class APIViewTestCases: obj_perm.users.add(self.user) obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) - response = self.client.post(url, self.create_data[0], format='json', **self.header) + initial_count = self.model.objects.count() + response = self.client.post(self._get_list_url(), self.create_data[0], format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(self.model.objects.count(), initial_count + 1) self.assertInstanceEqual(self.model.objects.get(pk=response.data['id']), self.create_data[0], api=True) @@ -190,9 +188,6 @@ class APIViewTestCases: """ POST a set of objects in a single request. """ - initial_count = self.model.objects.count() - url = self._get_list_url() - # Add object-level permission obj_perm = ObjectPermission( actions=['add'] @@ -201,7 +196,8 @@ class APIViewTestCases: obj_perm.users.add(self.user) obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) - response = self.client.post(url, self.create_data, format='json', **self.header) + initial_count = self.model.objects.count() + response = self.client.post(self._get_list_url(), self.create_data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(self.model.objects.count(), initial_count + len(self.create_data)) From a2955196affd341800bb843d5c5ec8beb7a36f51 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 8 Jun 2020 15:21:11 -0400 Subject: [PATCH 123/505] Remove extraneous permission class from SecretRoleViewSet --- netbox/secrets/api/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 1795e6c0a..9e330b782 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -29,7 +29,6 @@ class SecretRoleViewSet(ModelViewSet): secret_count=Count('secrets') ) serializer_class = serializers.SecretRoleSerializer - permission_classes = [IsAuthenticated] filterset_class = filters.SecretRoleFilterSet From 047286f9c05b6d44a750c2136ee8aad43117708c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 8 Jun 2020 15:27:55 -0400 Subject: [PATCH 124/505] Add a third initial object to VirtualChassisTest --- netbox/dcim/tests/test_api.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index c0e1de5c8..60a6dc122 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1846,6 +1846,9 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase): Device(name='Device 7', device_type=devicetype, device_role=devicerole, site=site), Device(name='Device 8', device_type=devicetype, device_role=devicerole, site=site), Device(name='Device 9', device_type=devicetype, device_role=devicerole, site=site), + Device(name='Device 10', device_type=devicetype, device_role=devicerole, site=site), + Device(name='Device 11', device_type=devicetype, device_role=devicerole, site=site), + Device(name='Device 12', device_type=devicetype, device_role=devicerole, site=site), ) Device.objects.bulk_create(devices) @@ -1859,16 +1862,19 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase): ) Interface.objects.bulk_create(interfaces) - # Create two VirtualChassis with three members each + # Create three VirtualChassis with three members each virtual_chassis = ( VirtualChassis(master=devices[0], domain='domain-1'), VirtualChassis(master=devices[3], domain='domain-2'), + VirtualChassis(master=devices[6], domain='domain-3'), ) VirtualChassis.objects.bulk_create(virtual_chassis) Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis[0], vc_position=2) Device.objects.filter(pk=devices[2].pk).update(virtual_chassis=virtual_chassis[0], vc_position=3) Device.objects.filter(pk=devices[4].pk).update(virtual_chassis=virtual_chassis[1], vc_position=2) Device.objects.filter(pk=devices[5].pk).update(virtual_chassis=virtual_chassis[1], vc_position=3) + Device.objects.filter(pk=devices[7].pk).update(virtual_chassis=virtual_chassis[2], vc_position=2) + Device.objects.filter(pk=devices[8].pk).update(virtual_chassis=virtual_chassis[2], vc_position=3) cls.update_data = { 'master': devices[1].pk, @@ -1877,17 +1883,17 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase): cls.create_data = [ { - 'master': devices[6].pk, - 'domain': 'domain-3', - }, - { - 'master': devices[7].pk, + 'master': devices[9].pk, 'domain': 'domain-4', }, { - 'master': devices[8].pk, + 'master': devices[10].pk, 'domain': 'domain-5', }, + { + 'master': devices[11].pk, + 'domain': 'domain-6', + }, ] From 987414ed7b2d6d2dea0dadb3900699543a4293e1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 8 Jun 2020 15:40:41 -0400 Subject: [PATCH 125/505] Introduce NestedObjectPermissionSerializer --- netbox/users/api/nested_serializers.py | 32 +++++++++++++++++++++----- netbox/users/api/serializers.py | 1 + netbox/users/tests/test_api.py | 5 +--- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py index f7721cf94..f6e5cefbf 100644 --- a/netbox/users/api/nested_serializers.py +++ b/netbox/users/api/nested_serializers.py @@ -1,16 +1,17 @@ from django.contrib.auth.models import Group, User +from django.contrib.contenttypes.models import ContentType +from rest_framework import serializers -from utilities.api import WritableNestedSerializer +from users.models import ObjectPermission +from utilities.api import ContentTypeField, WritableNestedSerializer -_all_ = [ +__all__ = [ + 'NestedGroupSerializer', + 'NestedObjectPermissionSerializer', 'NestedUserSerializer', ] -# -# Groups and users -# - class NestedGroupSerializer(WritableNestedSerializer): class Meta: @@ -23,3 +24,22 @@ class NestedUserSerializer(WritableNestedSerializer): class Meta: model = User fields = ['id', 'username'] + + +class NestedObjectPermissionSerializer(WritableNestedSerializer): + object_types = ContentTypeField( + queryset=ContentType.objects.all(), + many=True + ) + groups = serializers.SerializerMethodField(read_only=True) + users = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = ObjectPermission + fields = ['id', 'object_types', 'groups', 'users', 'actions'] + + def get_groups(self, obj): + return [g.name for g in obj.groups.all()] + + def get_users(self, obj): + return [u.username for u in obj.users.all()] diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index dc5301846..052567e47 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -1,3 +1,4 @@ +from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from users.models import ObjectPermission diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py index 2494c8f31..166473710 100644 --- a/netbox/users/tests/test_api.py +++ b/netbox/users/tests/test_api.py @@ -18,10 +18,7 @@ class AppTest(APITestCase): class ObjectPermissionTest(APIViewTestCases.APIViewTestCase): model = ObjectPermission - brief_fields = [] - - # TODO: Add a nested serializer for ObjectPermission - test_list_objects_brief = None + brief_fields = ['actions', 'groups', 'id', 'object_types', 'users'] @classmethod def setUpTestData(cls): From 7a858cea23cee1d9ea1eb558b2f987338e16e841 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 8 Jun 2020 15:58:54 -0400 Subject: [PATCH 126/505] Extend test_bulk_create_objects() to inspect created objects --- netbox/utilities/testing/api.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index 1e2063a6d..ce4f1d1e5 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -199,7 +199,10 @@ class APIViewTestCases: initial_count = self.model.objects.count() response = self.client.post(self._get_list_url(), self.create_data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(len(response.data), len(self.create_data)) self.assertEqual(self.model.objects.count(), initial_count + len(self.create_data)) + for i, obj in enumerate(response.data): + self.assertInstanceEqual(self.model.objects.get(pk=obj['id']), self.create_data[i], api=True) class UpdateObjectViewTestCase(APITestCase): update_data = {} From 2f53411efcabec5651684715fa820e13a4a5e78b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 8 Jun 2020 16:32:50 -0400 Subject: [PATCH 127/505] Extend assertInstanceEqual() to handle M2M relations to ContentType --- netbox/utilities/testing/views.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 1ba96e395..b3c35ed87 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -1,6 +1,7 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist +from django.db.models import ForeignKey, ManyToManyField from django.forms.models import model_to_dict from django.test import Client, TestCase as _TestCase, override_settings from django.urls import reverse, NoReverseMatch @@ -83,12 +84,15 @@ class TestCase(_TestCase): if api: # Replace ContentType numeric IDs with . - if type(getattr(instance, key)) is ContentType: + field = instance._meta.get_field(key) + if type(field) is ForeignKey and field.related_model is ContentType: ct = ContentType.objects.get(pk=value) model_dict[key] = f'{ct.app_label}.{ct.model}' + elif type(field) is ManyToManyField and field.related_model is ContentType: + model_dict[key] = [f'{ct.app_label}.{ct.model}' for ct in value] # Convert IPNetwork instances to strings - if type(value) is IPNetwork: + elif type(value) is IPNetwork: model_dict[key] = str(value) # Omit any dictionary keys which are not instance attributes From 892c0e3d8be75e7d7e31c97b7bd01bdd5f8e5c0f Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Mon, 8 Jun 2020 17:00:07 -0400 Subject: [PATCH 128/505] Leftover fix --- netbox/dcim/forms.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 0e9d9763e..736e129a3 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2296,9 +2296,9 @@ class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form): label_pattern_count = len(self.cleaned_data['label_pattern']) if label_pattern_count and name_pattern_count != label_pattern_count: raise forms.ValidationError({ - 'label_pattern': 'The provided name pattern will create {} {}}, however {} labels will ' + 'label_pattern': 'The provided name pattern will create {} ports, however {} labels will ' 'be generated. These counts must match.'.format( - name_pattern_count, self.type, label_pattern_count) + name_pattern_count, label_pattern_count) }) From 90bc1cd95162a2193ef1a470a85cd7b74a4e0634 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Mon, 8 Jun 2020 20:04:31 -0400 Subject: [PATCH 129/505] Test forms and views with labels --- netbox/dcim/tests/test_forms.py | 68 +++++++++++++++++++++++++++++++++ netbox/dcim/tests/test_views.py | 4 ++ 2 files changed, 72 insertions(+) diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index 29e741560..116d9affc 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -116,3 +116,71 @@ class DeviceTestCase(TestCase): # Check that the initial value for the cluster group is set automatically when assigning the cluster self.assertEqual(test.initial['cluster_group'], cluster.group.pk) + + +class LabelTestCase(TestCase): + + @classmethod + def setUpTestData(cls): + site = Site.objects.create(name='Site 2', slug='site-2') + manufacturer = Manufacturer.objects.create(name='Manufacturer 2', slug='manufacturer-2') + cls.device_type = DeviceType.objects.create( + manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=1 + ) + device_role = DeviceRole.objects.create( + name='Device Role 2', slug='device-role-2', color='ffff00' + ) + cls.device = Device.objects.create( + name='Device 2', device_type=cls.device_type, device_role=device_role, site=site + ) + + def test_interface_label_count_valid(self): + good_interface_data = { + 'device': self.device.pk, + 'name_pattern': 'eth[0-9]', + # Test that a label CAN be applied to each generated interfaces + 'label_pattern': 'Interface[0-9]', + 'type': InterfaceTypeChoices.TYPE_100ME_FIXED, + } + form = InterfaceCreateForm(good_interface_data) + + print(form.errors) + self.assertTrue(form.is_valid()) + + def test_interface_label_count_mismatch(self): + bad_interface_data = { + 'device': self.device.pk, + 'name_pattern': 'eth[0-9]', + # Test that a label CANNOT be applied to each generated interfaces + 'label_pattern': 'Interface[0-1]', + 'type': InterfaceTypeChoices.TYPE_100ME_FIXED, + } + form = InterfaceCreateForm(bad_interface_data) + + self.assertFalse(form.is_valid()) + self.assertIn('label_pattern', form.errors) + + def test_console_port_template_label_count_valid(self): + bad_console_port_template_data = { + 'device_type': self.device_type, + 'name_pattern': 'Console Port Template[4-6]', + # Test that a label CANNOT be applied to each generated console port templates + 'label_pattern': 'Serial[4-6]', + 'type': ConsolePortTypeChoices.TYPE_RJ45, + } + form = ConsolePortTemplateCreateForm(bad_console_port_template_data) + + self.assertTrue(form.is_valid()) + + def test_console_port_template_label_count_mismatch(self): + bad_console_port_template_data = { + 'device_type': self.device_type, + 'name_pattern': 'Console Port Template[4-6]', + # Test that a label CANNOT be applied to each generated console port templates + 'label_pattern': 'Serial[0-1]', + 'type': ConsolePortTypeChoices.TYPE_RJ45, + } + form = ConsolePortTemplateCreateForm(bad_console_port_template_data) + + self.assertFalse(form.is_valid()) + self.assertIn('label_pattern', form.errors) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 7ee5d7845..65179bf0c 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -699,6 +699,8 @@ class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas cls.bulk_create_data = { 'device_type': devicetypes[1].pk, 'name_pattern': 'Interface Template [4-6]', + # Test that a label can be applied to each generated interface templates + 'label_pattern': 'Interface Template Label [3-5]', 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, 'mgmt_only': True, } @@ -995,6 +997,8 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.bulk_create_data = { 'device': device.pk, 'name_pattern': 'Console Port [4-6]', + # Test that a label can be applied to each generated console ports + 'label_pattern': 'Serial[3-5]', 'type': ConsolePortTypeChoices.TYPE_RJ45, 'description': 'A console port', 'tags': 'Alpha,Bravo,Charlie', From 4301c06d177439356d4f4da207f2af1128984293 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Mon, 8 Jun 2020 23:07:12 -0400 Subject: [PATCH 130/505] Refactorization into LabeledComponentForm --- netbox/dcim/forms.py | 242 +++----------------------------------- netbox/utilities/forms.py | 27 +++++ 2 files changed, 44 insertions(+), 225 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 736e129a3..ca706d6f2 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -22,10 +22,10 @@ from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant, TenantGroup from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, - BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, - CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, - JSONField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, - BOOLEAN_WITH_BLANK_CHOICES, + BOOLEAN_WITH_BLANK_CHOICES, BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, + CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, + form_from_model, JSONField, LabeledComponentForm, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, + StaticSelect2Multiple, TagFilterField, ) from virtualization.models import Cluster, ClusterGroup, VirtualMachine from .choices import * @@ -1039,34 +1039,15 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): } -class ConsolePortTemplateCreateForm(BootstrapMixin, forms.Form): +class ConsolePortTemplateCreateForm(LabeledComponentForm): device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all() ) - name_pattern = ExpandableNameField( - label='Name' - ) - label_pattern = ExpandableNameField( - label='Label', - required=False - ) type = forms.ChoiceField( choices=add_blank_choice(ConsolePortTypeChoices), widget=StaticSelect2() ) - def clean(self): - - # Validate that the number of ports being created from both the name_pattern and label_pattern are equal - name_pattern_count = len(self.cleaned_data['name_pattern']) - label_pattern_count = len(self.cleaned_data['label_pattern']) - if label_pattern_count and name_pattern_count != label_pattern_count: - raise forms.ValidationError({ - 'label_pattern': 'The provided name pattern will create {} ports, however {} labels will ' - 'be generated. These counts must match.'.format( - name_pattern_count, label_pattern_count) - }) - class ConsolePortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( @@ -1095,34 +1076,15 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): } -class ConsoleServerPortTemplateCreateForm(BootstrapMixin, forms.Form): +class ConsoleServerPortTemplateCreateForm(LabeledComponentForm): device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all() ) - name_pattern = ExpandableNameField( - label='Name' - ) - label_pattern = ExpandableNameField( - label='Label', - required=False - ) type = forms.ChoiceField( choices=add_blank_choice(ConsolePortTypeChoices), widget=StaticSelect2() ) - def clean(self): - - # Validate that the number of ports being created from both the name_pattern and label_pattern are equal - name_pattern_count = len(self.cleaned_data['name_pattern']) - label_pattern_count = len(self.cleaned_data['label_pattern']) - if label_pattern_count and name_pattern_count != label_pattern_count: - raise forms.ValidationError({ - 'label_pattern': 'The provided name pattern will create {} ports, however {} labels will ' - 'be generated. These counts must match.'.format( - name_pattern_count, label_pattern_count) - }) - class ConsoleServerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( @@ -1151,17 +1113,10 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): } -class PowerPortTemplateCreateForm(BootstrapMixin, forms.Form): +class PowerPortTemplateCreateForm(LabeledComponentForm): device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all() ) - name_pattern = ExpandableNameField( - label='Name' - ) - label_pattern = ExpandableNameField( - label='Label', - required=False - ) type = forms.ChoiceField( choices=add_blank_choice(PowerPortTypeChoices), required=False @@ -1177,18 +1132,6 @@ class PowerPortTemplateCreateForm(BootstrapMixin, forms.Form): help_text="Allocated power draw (watts)" ) - def clean(self): - - # Validate that the number of ports being created from both the name_pattern and label_pattern are equal - name_pattern_count = len(self.cleaned_data['name_pattern']) - label_pattern_count = len(self.cleaned_data['label_pattern']) - if label_pattern_count and name_pattern_count != label_pattern_count: - raise forms.ValidationError({ - 'label_pattern': 'The provided name pattern will create {} ports, however {} labels will ' - 'be generated. These counts must match.'.format( - name_pattern_count, label_pattern_count) - }) - class PowerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( @@ -1237,17 +1180,10 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): ) -class PowerOutletTemplateCreateForm(BootstrapMixin, forms.Form): +class PowerOutletTemplateCreateForm(LabeledComponentForm): device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all() ) - name_pattern = ExpandableNameField( - label='Name' - ) - label_pattern = ExpandableNameField( - label='Label', - required=False - ) type = forms.ChoiceField( choices=add_blank_choice(PowerOutletTypeChoices), required=False @@ -1273,18 +1209,6 @@ class PowerOutletTemplateCreateForm(BootstrapMixin, forms.Form): device_type=device_type ) - def clean(self): - - # Validate that the number of ports being created from both the name_pattern and label_pattern are equal - name_pattern_count = len(self.cleaned_data['name_pattern']) - label_pattern_count = len(self.cleaned_data['label_pattern']) - if label_pattern_count and name_pattern_count != label_pattern_count: - raise forms.ValidationError({ - 'label_pattern': 'The provided name pattern will create {} ports, however {} labels will ' - 'be generated. These counts must match.'.format( - name_pattern_count, label_pattern_count) - }) - class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( @@ -1319,17 +1243,10 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): } -class InterfaceTemplateCreateForm(BootstrapMixin, forms.Form): +class InterfaceTemplateCreateForm(LabeledComponentForm): device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all() ) - name_pattern = ExpandableNameField( - label='Name' - ) - label_pattern = ExpandableNameField( - label='Label', - required=False - ) type = forms.ChoiceField( choices=InterfaceTypeChoices, widget=StaticSelect2() @@ -1339,18 +1256,6 @@ class InterfaceTemplateCreateForm(BootstrapMixin, forms.Form): label='Management only' ) - def clean(self): - - # Validate that the number of ports being created from both the name_pattern and label_pattern are equal - name_pattern_count = len(self.cleaned_data['name_pattern']) - label_pattern_count = len(self.cleaned_data['label_pattern']) - if label_pattern_count and name_pattern_count != label_pattern_count: - raise forms.ValidationError({ - 'label_pattern': 'The provided name pattern will create {} interfaces, however {} labels will ' - 'be generated. These counts must match.'.format( - name_pattern_count, label_pattern_count) - }) - class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( @@ -2271,36 +2176,17 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt # Bulk device component creation # -class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form): +class DeviceBulkAddComponentForm(LabeledComponentForm): pk = forms.ModelMultipleChoiceField( queryset=Device.objects.all(), widget=forms.MultipleHiddenInput() ) - name_pattern = ExpandableNameField( - label='Name' - ) - label_pattern = ExpandableNameField( - label='Label', - required=False - ) def clean_tags(self): # Because we're feeding TagField data (on the bulk edit form) to another TagField (on the model form), we # must first convert the list of tags to a string. return ','.join(self.cleaned_data.get('tags')) - def clean(self): - - # Validate that the number of ports being created from both the name_pattern and label_pattern are equal - name_pattern_count = len(self.cleaned_data['name_pattern']) - label_pattern_count = len(self.cleaned_data['label_pattern']) - if label_pattern_count and name_pattern_count != label_pattern_count: - raise forms.ValidationError({ - 'label_pattern': 'The provided name pattern will create {} ports, however {} labels will ' - 'be generated. These counts must match.'.format( - name_pattern_count, label_pattern_count) - }) - # # Console ports @@ -2332,17 +2218,10 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm): } -class ConsolePortCreateForm(BootstrapMixin, forms.Form): +class ConsolePortCreateForm(LabeledComponentForm): device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer') ) - name_pattern = ExpandableNameField( - label='Name' - ) - label_pattern = ExpandableNameField( - label='Label', - required=False - ) type = forms.ChoiceField( choices=add_blank_choice(ConsolePortTypeChoices), required=False, @@ -2356,18 +2235,6 @@ class ConsolePortCreateForm(BootstrapMixin, forms.Form): required=False ) - def clean(self): - - # Validate that the number of ports being created from both the name_pattern and label_pattern are equal - name_pattern_count = len(self.cleaned_data['name_pattern']) - label_pattern_count = len(self.cleaned_data['label_pattern']) - if label_pattern_count and name_pattern_count != label_pattern_count: - raise forms.ValidationError({ - 'label_pattern': 'The provided name pattern will create {} ports, however {} labels will ' - 'be generated. These counts must match.'.format( - name_pattern_count, label_pattern_count) - }) - class ConsolePortBulkCreateForm( form_from_model(ConsolePort, ['type', 'description', 'tags']), @@ -2434,17 +2301,10 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): } -class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form): +class ConsoleServerPortCreateForm(LabeledComponentForm): device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer') ) - name_pattern = ExpandableNameField( - label='Name' - ) - label_pattern = ExpandableNameField( - label='Label', - required=False - ) type = forms.ChoiceField( choices=add_blank_choice(ConsolePortTypeChoices), required=False, @@ -2458,18 +2318,6 @@ class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form): required=False ) - def clean(self): - - # Validate that the number of ports being created from both the name_pattern and label_pattern are equal - name_pattern_count = len(self.cleaned_data['name_pattern']) - label_pattern_count = len(self.cleaned_data['label_pattern']) - if label_pattern_count and name_pattern_count != label_pattern_count: - raise forms.ValidationError({ - 'label_pattern': 'The provided name pattern will create {} ports, however {} labels will ' - 'be generated. These counts must match.'.format( - name_pattern_count, label_pattern_count) - }) - class ConsoleServerPortBulkCreateForm( form_from_model(ConsoleServerPort, ['type', 'description', 'tags']), @@ -2550,17 +2398,10 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm): } -class PowerPortCreateForm(BootstrapMixin, forms.Form): +class PowerPortCreateForm(LabeledComponentForm): device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer') ) - name_pattern = ExpandableNameField( - label='Name' - ) - label_pattern = ExpandableNameField( - label='Label', - required=False - ) type = forms.ChoiceField( choices=add_blank_choice(PowerPortTypeChoices), required=False, @@ -2583,17 +2424,6 @@ class PowerPortCreateForm(BootstrapMixin, forms.Form): tags = TagField( required=False ) - def clean(self): - - # Validate that the number of ports being created from both the name_pattern and label_pattern are equal - name_pattern_count = len(self.cleaned_data['name_pattern']) - label_pattern_count = len(self.cleaned_data['label_pattern']) - if label_pattern_count and name_pattern_count != label_pattern_count: - raise forms.ValidationError({ - 'label_pattern': 'The provided name pattern will create {} ports, however {} labels will ' - 'be generated. These counts must match.'.format( - name_pattern_count, label_pattern_count) - }) class PowerPortBulkCreateForm( @@ -2674,17 +2504,10 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm): ) -class PowerOutletCreateForm(BootstrapMixin, forms.Form): +class PowerOutletCreateForm(LabeledComponentForm): device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer') ) - name_pattern = ExpandableNameField( - label='Name' - ) - label_pattern = ExpandableNameField( - label='Label', - required=False - ) type = forms.ChoiceField( choices=add_blank_choice(PowerOutletTypeChoices), required=False, @@ -2715,18 +2538,6 @@ class PowerOutletCreateForm(BootstrapMixin, forms.Form): ) self.fields['power_port'].queryset = PowerPort.objects.filter(device=device) - def clean(self): - - # Validate that the number of ports being created from both the name_pattern and label_pattern are equal - name_pattern_count = len(self.cleaned_data['name_pattern']) - label_pattern_count = len(self.cleaned_data['label_pattern']) - if label_pattern_count and name_pattern_count != label_pattern_count: - raise forms.ValidationError({ - 'label_pattern': 'The provided name pattern will create {} ports, however {} labels will ' - 'be generated. These counts must match.'.format( - name_pattern_count, label_pattern_count) - }) - class PowerOutletBulkCreateForm( form_from_model(PowerOutlet, ['type', 'feed_leg', 'description', 'tags']), @@ -2915,17 +2726,11 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk) -class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form): +class InterfaceCreateForm(InterfaceCommonForm, LabeledComponentForm): + component_type = 'interface' device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer') ) - name_pattern = ExpandableNameField( - label='Name' - ) - label_pattern = ExpandableNameField( - label='Label', - required=False - ) type = forms.ChoiceField( choices=InterfaceTypeChoices, widget=StaticSelect2(), @@ -3006,25 +2811,12 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form): self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', device.site.pk) self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk) - def clean(self): - - # Validate that the number of ports being created from both the name_pattern and label_pattern are equal - name_pattern_count = len(self.cleaned_data['name_pattern']) - label_pattern_count = len(self.cleaned_data['label_pattern']) - if label_pattern_count and name_pattern_count != label_pattern_count: - raise forms.ValidationError({ - 'label_pattern': 'The provided name pattern will create {} interfaces, however {} labels will ' - 'be generated. These counts must match.'.format( - name_pattern_count, label_pattern_count) - }) - - class InterfaceBulkCreateForm( form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only', 'description', 'tags']), DeviceBulkAddComponentForm ): - pass + component_type = 'interface' class InterfaceBulkEditForm( diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 3d8dbe33f..8e80168ab 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -804,6 +804,33 @@ class ImportForm(BootstrapMixin, forms.Form): }) +class LabeledComponentForm(BootstrapMixin, forms.Form): + """ + Base form for adding label pattern validation to `Create` forms + """ + component_type = 'port' + + name_pattern = ExpandableNameField( + label='Name' + ) + label_pattern = ExpandableNameField( + label='Label', + required=False + ) + + def clean(self): + + # Validate that the number of components being created from both the name_pattern and label_pattern are equal + name_pattern_count = len(self.cleaned_data['name_pattern']) + label_pattern_count = len(self.cleaned_data['label_pattern']) + if label_pattern_count and name_pattern_count != label_pattern_count: + raise forms.ValidationError({ + 'label_pattern': 'The provided name pattern will create {} {}s, however {} labels will ' + 'be generated. These counts must match.'.format( + name_pattern_count, self.component_type, label_pattern_count) + }, code='label_pattern_mismatch') + + class TableConfigForm(BootstrapMixin, forms.Form): """ Form for configuring user's table preferences. From e0037c7f7045c264559db6a1fe73da151b5d3938 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Mon, 8 Jun 2020 23:07:53 -0400 Subject: [PATCH 131/505] pycodestyle --- netbox/dcim/api/serializers.py | 14 +++++++------- netbox/dcim/tests/test_forms.py | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index a2f576aca..ffa027af6 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -261,7 +261,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = ConsoleServerPortTemplate - fields = ['id', 'device_type', 'name', 'label', 'type'] + fields = ['id', 'device_type', 'name', 'label', 'type'] class PowerPortTemplateSerializer(ValidatedModelSerializer): @@ -274,7 +274,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = PowerPortTemplate - fields = ['id', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw'] + fields = ['id', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw'] class PowerOutletTemplateSerializer(ValidatedModelSerializer): @@ -295,7 +295,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer): class Meta: model = PowerOutletTemplate - fields = ['id', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg'] + fields = ['id', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg'] class InterfaceTemplateSerializer(ValidatedModelSerializer): @@ -446,7 +446,7 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer) class Meta: model = ConsoleServerPort fields = [ - 'id', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint', + 'id', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'tags', ] @@ -464,7 +464,7 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer): class Meta: model = ConsolePort fields = [ - 'id', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint', + 'id', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'tags', ] @@ -494,7 +494,7 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer): class Meta: model = PowerOutlet fields = [ - 'id', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'connected_endpoint_type', + 'id', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'tags', ] @@ -512,7 +512,7 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer): class Meta: model = PowerPort fields = [ - 'id', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'connected_endpoint_type', + 'id', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'tags', ] diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index 116d9affc..1fc18c3d3 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -133,7 +133,7 @@ class LabelTestCase(TestCase): cls.device = Device.objects.create( name='Device 2', device_type=cls.device_type, device_role=device_role, site=site ) - + def test_interface_label_count_valid(self): good_interface_data = { 'device': self.device.pk, @@ -143,7 +143,7 @@ class LabelTestCase(TestCase): 'type': InterfaceTypeChoices.TYPE_100ME_FIXED, } form = InterfaceCreateForm(good_interface_data) - + print(form.errors) self.assertTrue(form.is_valid()) @@ -159,7 +159,7 @@ class LabelTestCase(TestCase): self.assertFalse(form.is_valid()) self.assertIn('label_pattern', form.errors) - + def test_console_port_template_label_count_valid(self): bad_console_port_template_data = { 'device_type': self.device_type, From 5cdaaed311d605b91278fd3404a67fe12b7d0067 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Wed, 10 Jun 2020 14:04:55 -0400 Subject: [PATCH 132/505] Use a single migration for `labels` --- ...port_label.py => 0107_component_labels.py} | 14 +++++++++-- .../dcim/migrations/0107_interface_label.py | 23 ------------------- 2 files changed, 12 insertions(+), 25 deletions(-) rename netbox/dcim/migrations/{0108_port_label.py => 0107_component_labels.py} (77%) delete mode 100644 netbox/dcim/migrations/0107_interface_label.py diff --git a/netbox/dcim/migrations/0108_port_label.py b/netbox/dcim/migrations/0107_component_labels.py similarity index 77% rename from netbox/dcim/migrations/0108_port_label.py rename to netbox/dcim/migrations/0107_component_labels.py index af0aa1962..9aa496134 100644 --- a/netbox/dcim/migrations/0108_port_label.py +++ b/netbox/dcim/migrations/0107_component_labels.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.7 on 2020-06-05 14:32 +# Generated by Django 3.0.7 on 2020-06-04 20:37 from django.db import migrations, models @@ -6,10 +6,20 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('dcim', '0107_interface_label'), + ('dcim', '0106_role_default_color'), ] operations = [ + migrations.AddField( + model_name='interface', + name='label', + field=models.CharField(blank=True, max_length=64), + ), + migrations.AddField( + model_name='interfacetemplate', + name='label', + field=models.CharField(blank=True, max_length=64), + ), migrations.AddField( model_name='consoleport', name='label', diff --git a/netbox/dcim/migrations/0107_interface_label.py b/netbox/dcim/migrations/0107_interface_label.py deleted file mode 100644 index 48970d25d..000000000 --- a/netbox/dcim/migrations/0107_interface_label.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.0.7 on 2020-06-04 20:37 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0106_role_default_color'), - ] - - operations = [ - migrations.AddField( - model_name='interface', - name='label', - field=models.CharField(blank=True, max_length=64), - ), - migrations.AddField( - model_name='interfacetemplate', - name='label', - field=models.CharField(blank=True, max_length=64), - ), - ] From 88ae522c9a3be60ece80e13a02815f716458dbf8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 10 Jun 2020 14:55:46 -0400 Subject: [PATCH 133/505] Closes #4742: Add tagging for cables, power panels, and rack reservations --- docs/release-notes/version-2.9.md | 4 +++ netbox/dcim/api/serializers.py | 9 ++++-- netbox/dcim/filters.py | 3 ++ netbox/dcim/forms.py | 25 ++++++++++++---- netbox/dcim/migrations/0107_add_tags.py | 30 +++++++++++++++++++ netbox/dcim/models/__init__.py | 3 ++ netbox/dcim/tables.py | 16 ++++++++-- netbox/dcim/tests/test_views.py | 3 ++ netbox/templates/dcim/cable.html | 1 + netbox/templates/dcim/inc/cable_form.html | 1 + netbox/templates/dcim/powerpanel.html | 1 + netbox/templates/dcim/rackreservation.html | 1 + .../templates/dcim/rackreservation_edit.html | 1 + 13 files changed, 86 insertions(+), 12 deletions(-) create mode 100644 netbox/dcim/migrations/0107_add_tags.py diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index 7c63af51c..ce480035e 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -8,6 +8,10 @@ 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. +### Enhancements + +* [#4742](https://github.com/netbox-community/netbox/issues/4742) - Add tagging for cables, power panels, and rack reservations + ### 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}`. diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 9ac58dc3a..9174085b8 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -165,10 +165,11 @@ class RackReservationSerializer(ValidatedModelSerializer): rack = NestedRackSerializer() user = NestedUserSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True) + tags = TagListSerializerField(required=False) class Meta: model = RackReservation - fields = ['id', 'rack', 'units', 'created', 'user', 'tenant', 'description'] + fields = ['id', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags'] class RackElevationDetailFilterSerializer(serializers.Serializer): @@ -640,12 +641,13 @@ class CableSerializer(ValidatedModelSerializer): termination_b = serializers.SerializerMethodField(read_only=True) status = ChoiceField(choices=CableStatusChoices, required=False) length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False) + tags = TagListSerializerField(required=False) class Meta: model = Cable fields = [ 'id', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type', 'termination_b_id', - 'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit', + 'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags', ] def _get_termination(self, obj, side): @@ -729,11 +731,12 @@ class PowerPanelSerializer(ValidatedModelSerializer): allow_null=True, default=None ) + tags = TagListSerializerField(required=False) powerfeed_count = serializers.IntegerField(read_only=True) class Meta: model = PowerPanel - fields = ['id', 'site', 'rack_group', 'name', 'powerfeed_count'] + fields = ['id', 'site', 'rack_group', 'name', 'tags', 'powerfeed_count'] class PowerFeedSerializer(TaggitSerializer, CustomFieldModelSerializer): diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 8c24180bb..92f7f8241 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -298,6 +298,7 @@ class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet): to_field_name='username', label='User (name)', ) + tag = TagFilter() class Meta: model = RackReservation @@ -1117,6 +1118,7 @@ class CableFilterSet(BaseFilterSet): method='filter_device', field_name='device__tenant__slug' ) + tag = TagFilter() class Meta: model = Cable @@ -1265,6 +1267,7 @@ class PowerPanelFilterSet(BaseFilterSet): lookup_expr='in', label='Rack group (ID)', ) + tag = TagFilter() class Meta: model = PowerPanel diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 94cf51fcd..cd728cd19 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -750,11 +750,14 @@ class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm): ), widget=StaticSelect2() ) + tags = TagField( + required=False + ) class Meta: model = RackReservation fields = [ - 'rack', 'units', 'user', 'tenant_group', 'tenant', 'description', + 'rack', 'units', 'user', 'tenant_group', 'tenant', 'description', 'tags', ] def __init__(self, *args, **kwargs): @@ -825,7 +828,7 @@ class RackReservationCSVForm(CSVModelForm): self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) -class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm): +class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=RackReservation.objects.all(), widget=forms.MultipleHiddenInput() @@ -851,6 +854,7 @@ class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm): class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): + model = RackReservation field_order = ['q', 'site', 'group_id', 'tenant_group', 'tenant'] q = forms.CharField( required=False, @@ -872,6 +876,7 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): null_option=True, ) ) + tag = TagFilterField(model) # @@ -3662,11 +3667,14 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm): class CableForm(BootstrapMixin, forms.ModelForm): + tags = TagField( + required=False + ) class Meta: model = Cable fields = [ - 'type', 'status', 'label', 'color', 'length', 'length_unit', + 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags', ] widgets = { 'status': StaticSelect2, @@ -3799,7 +3807,7 @@ class CableCSVForm(CSVModelForm): return length_unit if length_unit is not None else '' -class CableBulkEditForm(BootstrapMixin, BulkEditForm): +class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Cable.objects.all(), widget=forms.MultipleHiddenInput @@ -3912,6 +3920,7 @@ class CableFilterForm(BootstrapMixin, forms.Form): required=False, label='Device' ) + tag = TagFilterField(model) # @@ -4325,11 +4334,14 @@ class PowerPanelForm(BootstrapMixin, forms.ModelForm): queryset=RackGroup.objects.all(), required=False ) + tags = TagField( + required=False + ) class Meta: model = PowerPanel fields = [ - 'site', 'rack_group', 'name', + 'site', 'rack_group', 'name', 'tags', ] @@ -4359,7 +4371,7 @@ class PowerPanelCSVForm(CSVModelForm): self.fields['rack_group'].queryset = self.fields['rack_group'].queryset.filter(**params) -class PowerPanelBulkEditForm(BootstrapMixin, BulkEditForm): +class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=PowerPanel.objects.all(), widget=forms.MultipleHiddenInput @@ -4420,6 +4432,7 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): null_option=True, ) ) + tag = TagFilterField(model) # diff --git a/netbox/dcim/migrations/0107_add_tags.py b/netbox/dcim/migrations/0107_add_tags.py new file mode 100644 index 000000000..2f7d29b96 --- /dev/null +++ b/netbox/dcim/migrations/0107_add_tags.py @@ -0,0 +1,30 @@ +# Generated by Django 3.0.6 on 2020-06-10 18:32 + +from django.db import migrations +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0042_customfield_manager'), + ('dcim', '0106_role_default_color'), + ] + + operations = [ + migrations.AddField( + model_name='cable', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='powerpanel', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='rackreservation', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 73220d1f0..f7411ca56 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -832,6 +832,7 @@ class RackReservation(ChangeLoggedModel): description = models.CharField( max_length=200 ) + tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() @@ -1832,6 +1833,7 @@ class PowerPanel(ChangeLoggedModel): name = models.CharField( max_length=50 ) + tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() @@ -2106,6 +2108,7 @@ class Cable(ChangeLoggedModel): blank=True, null=True ) + tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 9018625a0..0d7b4bb7a 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -399,6 +399,9 @@ class RackReservationTable(BaseTable): orderable=False, verbose_name='Units' ) + tags = TagColumn( + url_name='dcim:rackreservation_list' + ) actions = tables.TemplateColumn( template_code=RACKRESERVATION_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, @@ -408,7 +411,8 @@ class RackReservationTable(BaseTable): class Meta(BaseTable.Meta): model = RackReservation fields = ( - 'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'actions', + 'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags', + 'actions', ) default_columns = ( 'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description', 'actions', @@ -1086,12 +1090,15 @@ class CableTable(BaseTable): order_by='_abs_length' ) color = ColorColumn() + tags = TagColumn( + url_name='dcim:cable_list' + ) class Meta(BaseTable.Meta): model = Cable fields = ( 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', - 'status', 'type', 'color', 'length', + 'status', 'type', 'color', 'length', 'tags', ) default_columns = ( 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', @@ -1245,10 +1252,13 @@ class PowerPanelTable(BaseTable): template_code=POWERPANEL_POWERFEED_COUNT, verbose_name='Feeds' ) + tags = TagColumn( + url_name='dcim:powerpanel_list' + ) class Meta(BaseTable.Meta): model = PowerPanel - fields = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count') + fields = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count', 'tags') default_columns = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count') diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index cfbb2b95f..82657afb9 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -202,6 +202,7 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'user': user3.pk, 'tenant': None, 'description': 'Rack reservation', + 'tags': 'Alpha,Bravo,Charlie', } cls.csv_data = ( @@ -1510,6 +1511,7 @@ class CableTestCase( 'color': 'c0c0c0', 'length': 100, 'length_unit': CableLengthUnitChoices.UNIT_FOOT, + 'tags': 'Alpha,Bravo,Charlie', } cls.csv_data = ( @@ -1609,6 +1611,7 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'site': sites[1].pk, 'rack_group': rackgroups[1].pk, 'name': 'Power Panel X', + 'tags': 'Alpha,Bravo,Charlie', } cls.csv_data = ( diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index e6a2fa008..91c7b1a94 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -81,6 +81,7 @@
+ {% include 'extras/inc/tags_panel.html' with tags=cable.tags.all url='dcim:cable_list' %} {% plugin_left_page cable %}
diff --git a/netbox/templates/dcim/inc/cable_form.html b/netbox/templates/dcim/inc/cable_form.html index a52cc302e..98eca17d2 100644 --- a/netbox/templates/dcim/inc/cable_form.html +++ b/netbox/templates/dcim/inc/cable_form.html @@ -29,5 +29,6 @@ {% endif %}
+ {% render_field form.tags %}
diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html index 3ee8d80e0..90956d2a3 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -82,6 +82,7 @@
+ {% include 'extras/inc/tags_panel.html' with tags=powerpanel.tags.all url='dcim:powerpanel_list' %} {% plugin_left_page powerpanel %}
diff --git a/netbox/templates/dcim/rackreservation.html b/netbox/templates/dcim/rackreservation.html index d4bbbc97d..ab0fc0bba 100644 --- a/netbox/templates/dcim/rackreservation.html +++ b/netbox/templates/dcim/rackreservation.html @@ -124,6 +124,7 @@
+ {% include 'extras/inc/tags_panel.html' with tags=rackreservation.tags.all url='dcim:rackreservation_list' %} {% plugin_left_page rackreservation %}
diff --git a/netbox/templates/dcim/rackreservation_edit.html b/netbox/templates/dcim/rackreservation_edit.html index b2304974e..3db8f6d72 100644 --- a/netbox/templates/dcim/rackreservation_edit.html +++ b/netbox/templates/dcim/rackreservation_edit.html @@ -16,6 +16,7 @@ {% render_field form.tenant_group %} {% render_field form.tenant %} {% render_field form.description %} + {% render_field form.tags %}
{% endblock %} From f041c762ac4f33153de34e32fc41485185dd9207 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Wed, 10 Jun 2020 14:59:23 -0400 Subject: [PATCH 134/505] Prevent the table cell from rendering empty for `interface.label` Co-authored-by: Jeremy Stretch --- netbox/templates/dcim/interface.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index d35504368..5714c8940 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -60,7 +60,7 @@ Label - {{ interface.label }} + {{ interface.label|placeholder }} Type From 58b4f6abca2f488dd47976f435466b6e60b4078f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 10 Jun 2020 15:05:15 -0400 Subject: [PATCH 135/505] Update v2.9 release notes --- docs/release-notes/index.md | 2 +- docs/release-notes/version-2.9.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md index 364b2cd9d..f314c5371 120000 --- a/docs/release-notes/index.md +++ b/docs/release-notes/index.md @@ -1 +1 @@ -version-2.8.md \ No newline at end of file +version-2.9.md \ No newline at end of file diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index ce480035e..a190662d2 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -14,6 +14,7 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo ### Configuration Changes +* If in use, LDAP authentication must be enabled by setting `REMOTE_AUTH_BACKEND` to `'netbox.authentication.LDAPBackend'`. (LDAP configuration parameters in `ldap_config.py` remain unchanged.) * `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 From 3b54d6f8e505211cd209a2332f12ff369b049b9c Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Wed, 10 Jun 2020 15:11:47 -0400 Subject: [PATCH 136/505] No need to reference the model itself --- netbox/dcim/models/device_component_templates.py | 10 +++++----- netbox/dcim/models/device_components.py | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index acb5f0d46..3530d77de 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -72,7 +72,7 @@ class ConsolePortTemplate(ComponentTemplateModel): label = models.CharField( max_length=64, blank=True, - help_text="The physical label for this console port" + help_text="Physical label" ) type = models.CharField( max_length=50, @@ -117,7 +117,7 @@ class ConsoleServerPortTemplate(ComponentTemplateModel): label = models.CharField( max_length=64, blank=True, - help_text="The physical label for this console server port" + help_text="Physical label" ) type = models.CharField( max_length=50, @@ -162,7 +162,7 @@ class PowerPortTemplate(ComponentTemplateModel): label = models.CharField( max_length=64, blank=True, - help_text="The physical label for this power supply port" + help_text="Physical label" ) type = models.CharField( max_length=50, @@ -221,7 +221,7 @@ class PowerOutletTemplate(ComponentTemplateModel): label = models.CharField( max_length=64, blank=True, - help_text="The physical label for this power outlet" + help_text="Physical label" ) type = models.CharField( max_length=50, @@ -294,7 +294,7 @@ class InterfaceTemplate(ComponentTemplateModel): label = models.CharField( max_length=64, blank=True, - help_text="The physical label for this interface" + help_text="Physical label" ) type = models.CharField( max_length=50, diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index b8331365d..5a79519a2 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -234,7 +234,7 @@ class ConsolePort(CableTermination, ComponentModel): label = models.CharField( max_length=64, blank=True, - help_text="The physical label for this console port" + help_text="Physical label" ) _name = NaturalOrderingField( target_field='name', @@ -308,7 +308,7 @@ class ConsoleServerPort(CableTermination, ComponentModel): label = models.CharField( max_length=64, blank=True, - help_text="The physical label for this console server port" + help_text="Physical label" ) type = models.CharField( max_length=50, @@ -370,7 +370,7 @@ class PowerPort(CableTermination, ComponentModel): label = models.CharField( max_length=64, blank=True, - help_text="The physical label for this power supply port" + help_text="Physical label" ) type = models.CharField( max_length=50, @@ -540,7 +540,7 @@ class PowerOutlet(CableTermination, ComponentModel): label = models.CharField( max_length=64, blank=True, - help_text="The physical label for this power outlet" + help_text="Physical label" ) type = models.CharField( max_length=50, @@ -636,7 +636,7 @@ class Interface(CableTermination, ComponentModel): label = models.CharField( max_length=64, blank=True, - help_text="The physical label for this interface" + help_text="Physical label" ) _connected_interface = models.OneToOneField( to='self', From 4078d9b66908e6af2c69740af4c1f5c7be6a1cfe Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 10 Jun 2020 15:22:49 -0400 Subject: [PATCH 137/505] Remove extraneous test --- netbox/dcim/tests/test_api.py | 38 ++++++++++------------------------- 1 file changed, 11 insertions(+), 27 deletions(-) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 60a6dc122..e579f9cd0 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -242,31 +242,6 @@ class RackTest(APIViewTestCases.APIViewTestCase): }, ] - # TODO: Document this test - def test_get_elevation_rack_units(self): - rack = Rack.objects.first() - - self.add_permissions('dcim.view_rack') - url = '{}?q=3'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk})) - response = self.client.get(url, **self.header) - - self.assertEqual(response.data['count'], 13) - - url = '{}?q=U3'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk})) - response = self.client.get(url, **self.header) - - self.assertEqual(response.data['count'], 11) - - url = '{}?q=10'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk})) - response = self.client.get(url, **self.header) - - self.assertEqual(response.data['count'], 1) - - url = '{}?q=U20'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk})) - response = self.client.get(url, **self.header) - - self.assertEqual(response.data['count'], 1) - def test_get_rack_elevation(self): """ GET a single rack elevation. @@ -274,10 +249,19 @@ class RackTest(APIViewTestCases.APIViewTestCase): rack = Rack.objects.first() self.add_permissions('dcim.view_rack') url = reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk}) - response = self.client.get(url, **self.header) + # Retrieve all units + response = self.client.get(url, **self.header) self.assertEqual(response.data['count'], 42) + # Search for specific units + response = self.client.get(f'{url}?q=3', **self.header) + self.assertEqual(response.data['count'], 13) + response = self.client.get(f'{url}?q=U3', **self.header) + self.assertEqual(response.data['count'], 11) + response = self.client.get(f'{url}?q=U10', **self.header) + self.assertEqual(response.data['count'], 1) + def test_get_rack_elevation_svg(self): """ GET a single rack elevation in SVG format. @@ -285,8 +269,8 @@ class RackTest(APIViewTestCases.APIViewTestCase): rack = Rack.objects.first() self.add_permissions('dcim.view_rack') url = '{}?render=svg'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk})) - response = self.client.get(url, **self.header) + response = self.client.get(url, **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(response.get('Content-Type'), 'image/svg+xml') From 1f727f565f468c1ba67c5a3e5b01b25f7827b4d9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 10 Jun 2020 16:11:28 -0400 Subject: [PATCH 138/505] Adopted fix from #4743 and updated API tests --- netbox/ipam/api/views.py | 2 +- netbox/ipam/tests/test_api.py | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index dd3652b1f..69adc0c29 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -164,7 +164,7 @@ class PrefixViewSet(CustomFieldModelViewSet): 200: serializers.AvailableIPSerializer(many=True), } ) - @action(detail=True, url_path='available-ips', methods=['get', 'post']) + @action(detail=True, url_path='available-ips', methods=['get', 'post'], queryset=IPAddress.objects.all()) @advisory_lock(ADVISORY_LOCK_KEYS['available-ips']) def available_ips(self, request, pk=None): """ diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 41f39703c..70ae738b5 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -276,8 +276,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase): vrf = VRF.objects.create(name='Test VRF 1', rd='1234') prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/30'), vrf=vrf, is_pool=True) url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk}) - # TODO: ipam.add_prefix should not be required - self.add_permissions('ipam.add_prefix', 'ipam.add_ipaddress') + self.add_permissions('ipam.add_ipaddress') # Create all four available IPs with individual requests for i in range(1, 5): @@ -300,8 +299,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase): """ prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/29'), is_pool=True) url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk}) - # TODO: ipam.add_prefix, ipam.view_prefix should not be required - self.add_permissions('ipam.add_prefix', 'ipam.view_prefix', 'ipam.view_ipaddress', 'ipam.add_ipaddress') + self.add_permissions('ipam.view_ipaddress', 'ipam.add_ipaddress') # Try to create nine IPs (only eight are available) data = [{'description': 'Test IP {}'.format(i)} for i in range(1, 10)] # 9 IPs From e13320f58d5060e9bdd9958cbbf50d07d0a156f1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 10 Jun 2020 16:37:35 -0400 Subject: [PATCH 139/505] Fix permissions enforcement for VirtualChassisEditView --- netbox/dcim/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index de2bf80e5..846cec506 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2233,7 +2233,7 @@ class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, V def get(self, request, pk): - virtual_chassis = get_object_or_404(VirtualChassis, pk=pk) + virtual_chassis = get_object_or_404(self.queryset, pk=pk) VCMemberFormSet = modelformset_factory( model=Device, form=forms.DeviceVCMembershipForm, @@ -2254,7 +2254,7 @@ class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, V def post(self, request, pk): - virtual_chassis = get_object_or_404(VirtualChassis, pk=pk) + virtual_chassis = get_object_or_404(self.queryset, pk=pk) VCMemberFormSet = modelformset_factory( model=Device, form=forms.DeviceVCMembershipForm, From 81d08ac50bd4425c728bcb4de41922738f657014 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Wed, 10 Jun 2020 16:41:52 -0400 Subject: [PATCH 140/505] Stay consistent with codebase: i vs pos --- netbox/utilities/views.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 462e45819..1fe407dad 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -921,13 +921,13 @@ class ComponentCreateView(GetReturnURLMixin, View): names = form.cleaned_data['name_pattern'] labels = form.cleaned_data.get('label_pattern') - for pos, name in enumerate(names): - label = labels[pos] if labels else None + for i, name in enumerate(names): + label = labels[i] if labels else None # Initialize the individual component form data['name'] = name data['label'] = label if hasattr(form, 'get_iterative_data'): - data.update(form.get_iterative_data(pos)) + data.update(form.get_iterative_data(i)) component_form = self.model_form(data) if component_form.is_valid(): @@ -1009,8 +1009,8 @@ class BulkComponentCreateView(GetReturnURLMixin, View): names = data['name_pattern'] labels = data['label_pattern'] - for pos, name in enumerate(names): - label = labels[pos] if labels else None + for i, name in enumerate(names): + label = labels[i] if labels else None component_data = { self.parent_field: obj.pk, From 8e9dc9f20e801fb5263274550717049407ad9ff5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 10 Jun 2020 16:51:35 -0400 Subject: [PATCH 141/505] Add EditObjectViewTestCase for VirtualChassis --- netbox/dcim/tests/test_views.py | 58 ++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 82657afb9..cc7b8aa60 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -323,6 +323,7 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase): # TODO: Change base class to PrimaryObjectViewTestCase +# Blocked by absence of bulk import view for DeviceTypes class DeviceTypeTestCase( ViewTestCases.GetObjectViewTestCase, ViewTestCases.CreateObjectViewTestCase, @@ -803,6 +804,7 @@ class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase # TODO: Change base class to DeviceComponentTemplateViewTestCase +# Blocked by absence of bulk edit view for DeviceBays class DeviceBayTemplateTestCase( ViewTestCases.EditObjectViewTestCase, ViewTestCases.DeleteObjectViewTestCase, @@ -1451,6 +1453,7 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): # TODO: Change base class to PrimaryObjectViewTestCase +# Blocked by lack of common creation view for cables (termination A must be initialized) class CableTestCase( ViewTestCases.GetObjectViewTestCase, ViewTestCases.EditObjectViewTestCase, @@ -1532,8 +1535,10 @@ class CableTestCase( # TODO: Change base class to PrimaryObjectViewTestCase +# Blocked by standard creation, bulk creation views for VirtualChassis (member devices must be selected in bulk) class VirtualChassisTestCase( ViewTestCases.GetObjectViewTestCase, + ViewTestCases.EditObjectViewTestCase, ViewTestCases.DeleteObjectViewTestCase, ViewTestCases.ListObjectsViewTestCase, ViewTestCases.BulkEditObjectsViewTestCase, @@ -1554,32 +1559,39 @@ class VirtualChassisTestCase( ) # Create 9 member Devices - device1 = Device.objects.create( - device_type=device_type, device_role=device_role, name='Device 1', site=site - ) - device2 = Device.objects.create( - device_type=device_type, device_role=device_role, name='Device 2', site=site - ) - device3 = Device.objects.create( - device_type=device_type, device_role=device_role, name='Device 3', site=site - ) - device4 = Device.objects.create( - device_type=device_type, device_role=device_role, name='Device 4', site=site - ) - device5 = Device.objects.create( - device_type=device_type, device_role=device_role, name='Device 5', site=site - ) - device6 = Device.objects.create( - device_type=device_type, device_role=device_role, name='Device 6', site=site + devices = ( + Device(device_type=device_type, device_role=device_role, name='Device 1', site=site), + Device(device_type=device_type, device_role=device_role, name='Device 2', site=site), + Device(device_type=device_type, device_role=device_role, name='Device 3', site=site), + Device(device_type=device_type, device_role=device_role, name='Device 4', site=site), + Device(device_type=device_type, device_role=device_role, name='Device 5', site=site), + Device(device_type=device_type, device_role=device_role, name='Device 6', site=site), + Device(device_type=device_type, device_role=device_role, name='Device 7', site=site), + Device(device_type=device_type, device_role=device_role, name='Device 8', site=site), + Device(device_type=device_type, device_role=device_role, name='Device 9', site=site), ) + Device.objects.bulk_create(devices) # Create three VirtualChassis with two members each - vc1 = VirtualChassis.objects.create(master=device1, domain='test-domain-1') - Device.objects.filter(pk=device2.pk).update(virtual_chassis=vc1, vc_position=2) - vc2 = VirtualChassis.objects.create(master=device3, domain='test-domain-2') - Device.objects.filter(pk=device4.pk).update(virtual_chassis=vc2, vc_position=2) - vc3 = VirtualChassis.objects.create(master=device5, domain='test-domain-3') - Device.objects.filter(pk=device6.pk).update(virtual_chassis=vc3, vc_position=2) + vc1 = VirtualChassis.objects.create(master=devices[0], domain='domain-1') + Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=vc1, vc_position=2) + Device.objects.filter(pk=devices[2].pk).update(virtual_chassis=vc1, vc_position=3) + vc2 = VirtualChassis.objects.create(master=devices[3], domain='domain-2') + Device.objects.filter(pk=devices[4].pk).update(virtual_chassis=vc2, vc_position=2) + Device.objects.filter(pk=devices[5].pk).update(virtual_chassis=vc2, vc_position=3) + vc3 = VirtualChassis.objects.create(master=devices[6], domain='domain-3') + Device.objects.filter(pk=devices[7].pk).update(virtual_chassis=vc3, vc_position=2) + Device.objects.filter(pk=devices[8].pk).update(virtual_chassis=vc3, vc_position=3) + + cls.form_data = { + 'master': devices[1].pk, + 'domain': 'domain-x', + # Management form data for VC members + 'form-TOTAL_FORMS': 0, + 'form-INITIAL_FORMS': 3, + 'form-MIN_NUM_FORMS': 0, + 'form-MAX_NUM_FORMS': 1000, + } class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase): From d26fcc991862251c8756a0c6e543fada8d6ca7a4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 10 Jun 2020 16:56:24 -0400 Subject: [PATCH 142/505] Annotated blocked TODO items --- netbox/extras/tests/test_views.py | 2 ++ netbox/ipam/tests/test_views.py | 1 + netbox/virtualization/tests/test_views.py | 1 + 3 files changed, 4 insertions(+) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 6d41886fc..9ad29fd80 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -11,6 +11,7 @@ from utilities.testing import ViewTestCases, TestCase # TODO: Change base class to PrimaryObjectViewTestCase +# Blocked by #3703 class TagTestCase( ViewTestCases.GetObjectViewTestCase, ViewTestCases.EditObjectViewTestCase, @@ -43,6 +44,7 @@ class TagTestCase( # TODO: Change base class to PrimaryObjectViewTestCase +# Blocked by absence of standard create/edit, bulk create views class ConfigContextTestCase( ViewTestCases.GetObjectViewTestCase, ViewTestCases.DeleteObjectViewTestCase, diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 794284dba..43ac565ad 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -334,6 +334,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): # TODO: Update base class to PrimaryObjectViewTestCase +# Blocked by absence of standard creation view class ServiceTestCase( ViewTestCases.GetObjectViewTestCase, ViewTestCases.EditObjectViewTestCase, diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 9fde12186..b6f5be8b2 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -186,6 +186,7 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): # TODO: Update base class to DeviceComponentViewTestCase +# Blocked by #4721 class InterfaceTestCase( ViewTestCases.GetObjectViewTestCase, ViewTestCases.EditObjectViewTestCase, From e3a8638471a0fd7ea33788e4bd047ead31751c81 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Wed, 10 Jun 2020 22:04:45 -0400 Subject: [PATCH 143/505] Move `__str__()` to the abstract class --- .../dcim/models/device_component_templates.py | 30 ++++--------------- netbox/dcim/models/device_components.py | 30 ++++--------------- 2 files changed, 10 insertions(+), 50 deletions(-) diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 3530d77de..53080d8f6 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -30,6 +30,11 @@ class ComponentTemplateModel(models.Model): class Meta: abstract = True + def __str__(self): + if self.label: + return f"{self.name} ({self.label})" + return self.name + def instantiate(self, device): """ Instantiate a new component on the specified Device. @@ -84,11 +89,6 @@ class ConsolePortTemplate(ComponentTemplateModel): ordering = ('device_type', '_name') unique_together = ('device_type', 'name') - def __str__(self): - if self.label: - return f"{self.name} ({self.label})" - return self.name - def instantiate(self, device): return ConsolePort( device=device, @@ -129,11 +129,6 @@ class ConsoleServerPortTemplate(ComponentTemplateModel): ordering = ('device_type', '_name') unique_together = ('device_type', 'name') - def __str__(self): - if self.label: - return f"{self.name} ({self.label})" - return self.name - def instantiate(self, device): return ConsoleServerPort( device=device, @@ -186,11 +181,6 @@ class PowerPortTemplate(ComponentTemplateModel): ordering = ('device_type', '_name') unique_together = ('device_type', 'name') - def __str__(self): - if self.label: - return f"{self.name} ({self.label})" - return self.name - def instantiate(self, device): return PowerPort( device=device, @@ -246,11 +236,6 @@ class PowerOutletTemplate(ComponentTemplateModel): ordering = ('device_type', '_name') unique_together = ('device_type', 'name') - def __str__(self): - if self.label: - return f"{self.name} ({self.label})" - return self.name - def clean(self): # Validate power port assignment @@ -309,11 +294,6 @@ class InterfaceTemplate(ComponentTemplateModel): ordering = ('device_type', '_name') unique_together = ('device_type', 'name') - def __str__(self): - if self.label: - return f"{self.name} ({self.label})" - return self.name - def instantiate(self, device): return Interface( device=device, diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 5a79519a2..022b2733d 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -44,6 +44,11 @@ class ComponentModel(models.Model): class Meta: abstract = True + def __str__(self): + if self.label: + return f"{self.name} ({self.label})" + return self.name + def to_objectchange(self, action): # Annotate the parent Device/VM try: @@ -266,11 +271,6 @@ class ConsolePort(CableTermination, ComponentModel): ordering = ('device', '_name') unique_together = ('device', 'name') - def __str__(self): - if self.label: - return f"{self.name} ({self.label})" - return self.name - def get_absolute_url(self): return self.device.get_absolute_url() @@ -328,11 +328,6 @@ class ConsoleServerPort(CableTermination, ComponentModel): ordering = ('device', '_name') unique_together = ('device', 'name') - def __str__(self): - if self.label: - return f"{self.name} ({self.label})" - return self.name - def get_absolute_url(self): return self.device.get_absolute_url() @@ -416,11 +411,6 @@ class PowerPort(CableTermination, ComponentModel): ordering = ('device', '_name') unique_together = ('device', 'name') - def __str__(self): - if self.label: - return f"{self.name} ({self.label})" - return self.name - def get_absolute_url(self): return self.device.get_absolute_url() @@ -573,11 +563,6 @@ class PowerOutlet(CableTermination, ComponentModel): ordering = ('device', '_name') unique_together = ('device', 'name') - def __str__(self): - if self.label: - return f"{self.name} ({self.label})" - return self.name - def get_absolute_url(self): return self.device.get_absolute_url() @@ -718,11 +703,6 @@ class Interface(CableTermination, ComponentModel): ordering = ('device', CollateAsChar('_name')) unique_together = ('device', 'name') - def __str__(self): - if self.label: - return f"{self.name} ({self.label})" - return self.name - def get_absolute_url(self): return reverse('dcim:interface', kwargs={'pk': self.pk}) From cf81a8979f13b0481156bc8f9b0037d02ffa82e5 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Wed, 10 Jun 2020 22:10:45 -0400 Subject: [PATCH 144/505] Avoid `component_type` on subclasses --- netbox/dcim/forms.py | 3 +-- netbox/utilities/forms.py | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index ca706d6f2..81f8775ec 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2727,7 +2727,6 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): class InterfaceCreateForm(InterfaceCommonForm, LabeledComponentForm): - component_type = 'interface' device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer') ) @@ -2816,7 +2815,7 @@ class InterfaceBulkCreateForm( form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only', 'description', 'tags']), DeviceBulkAddComponentForm ): - component_type = 'interface' + pass class InterfaceBulkEditForm( diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 8e80168ab..1dfa3d608 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -808,8 +808,6 @@ class LabeledComponentForm(BootstrapMixin, forms.Form): """ Base form for adding label pattern validation to `Create` forms """ - component_type = 'port' - name_pattern = ExpandableNameField( label='Name' ) @@ -825,9 +823,9 @@ class LabeledComponentForm(BootstrapMixin, forms.Form): label_pattern_count = len(self.cleaned_data['label_pattern']) if label_pattern_count and name_pattern_count != label_pattern_count: raise forms.ValidationError({ - 'label_pattern': 'The provided name pattern will create {} {}s, however {} labels will ' + 'label_pattern': 'The provided name pattern will create {} components, however {} labels will ' 'be generated. These counts must match.'.format( - name_pattern_count, self.component_type, label_pattern_count) + name_pattern_count, label_pattern_count) }, code='label_pattern_mismatch') From 25cbab2ea4b5c8a9b79ed67759bf9ad155aad595 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Wed, 10 Jun 2020 22:16:46 -0400 Subject: [PATCH 145/505] Avoid checking `field` twice on all iterations Yields a small performance improvement Co-authored-by: Jeremy Stretch --- 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 1fe407dad..ee39707e8 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -937,7 +937,7 @@ class ComponentCreateView(GetReturnURLMixin, View): # Assign errors on the child form's name/label field to name_pattern/label_pattern on the parent form if field == 'name': field = 'name_pattern' - if field == 'label': + elif field == 'label': field = 'label_pattern' for e in errors: form.add_error(field, '{}: {}'.format(name, ', '.join(e))) From c1eea166c9ed76dfafdce87feefd1857ea3ae5dc Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Wed, 10 Jun 2020 22:58:52 -0400 Subject: [PATCH 146/505] Fix tests for LabeledComponentForm * Added docstring * Removed redundant tests since refactoration --- netbox/dcim/tests/test_forms.py | 34 ++++----------------------------- 1 file changed, 4 insertions(+), 30 deletions(-) diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index 1fc18c3d3..aadc2cbfc 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -135,23 +135,22 @@ class LabelTestCase(TestCase): ) def test_interface_label_count_valid(self): - good_interface_data = { + """Test that a `label` can be generated for each generated `name` from `name_pattern` on InterfaceCreateForm""" + interface_data = { 'device': self.device.pk, 'name_pattern': 'eth[0-9]', - # Test that a label CAN be applied to each generated interfaces 'label_pattern': 'Interface[0-9]', 'type': InterfaceTypeChoices.TYPE_100ME_FIXED, } - form = InterfaceCreateForm(good_interface_data) + form = InterfaceCreateForm(interface_data) - print(form.errors) self.assertTrue(form.is_valid()) def test_interface_label_count_mismatch(self): + """Test that a `label` cannot be generated for each generated `name` from `name_pattern` due to invalid `label_pattern` on InterfaceCreateForm""" bad_interface_data = { 'device': self.device.pk, 'name_pattern': 'eth[0-9]', - # Test that a label CANNOT be applied to each generated interfaces 'label_pattern': 'Interface[0-1]', 'type': InterfaceTypeChoices.TYPE_100ME_FIXED, } @@ -159,28 +158,3 @@ class LabelTestCase(TestCase): self.assertFalse(form.is_valid()) self.assertIn('label_pattern', form.errors) - - def test_console_port_template_label_count_valid(self): - bad_console_port_template_data = { - 'device_type': self.device_type, - 'name_pattern': 'Console Port Template[4-6]', - # Test that a label CANNOT be applied to each generated console port templates - 'label_pattern': 'Serial[4-6]', - 'type': ConsolePortTypeChoices.TYPE_RJ45, - } - form = ConsolePortTemplateCreateForm(bad_console_port_template_data) - - self.assertTrue(form.is_valid()) - - def test_console_port_template_label_count_mismatch(self): - bad_console_port_template_data = { - 'device_type': self.device_type, - 'name_pattern': 'Console Port Template[4-6]', - # Test that a label CANNOT be applied to each generated console port templates - 'label_pattern': 'Serial[0-1]', - 'type': ConsolePortTypeChoices.TYPE_RJ45, - } - form = ConsolePortTemplateCreateForm(bad_console_port_template_data) - - self.assertFalse(form.is_valid()) - self.assertIn('label_pattern', form.errors) From a37d06064a9a2200fa6007d4000c68dd8fe81bec Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Thu, 11 Jun 2020 10:19:53 -0400 Subject: [PATCH 147/505] Add `label` to DeviceBay models and serializers --- netbox/dcim/api/serializers.py | 4 ++-- netbox/dcim/migrations/0107_component_labels.py | 10 ++++++++++ netbox/dcim/models/device_component_templates.py | 8 +++++--- netbox/dcim/models/device_components.py | 7 +++++++ 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index ffa027af6..3c360c4a1 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -331,7 +331,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer): class Meta: model = DeviceBayTemplate - fields = ['id', 'device_type', 'name'] + fields = ['id', 'device_type', 'name', 'label'] # @@ -603,7 +603,7 @@ class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer): class Meta: model = DeviceBay - fields = ['id', 'device', 'name', 'description', 'installed_device', 'tags'] + fields = ['id', 'device', 'name', 'label', 'description', 'installed_device', 'tags'] # diff --git a/netbox/dcim/migrations/0107_component_labels.py b/netbox/dcim/migrations/0107_component_labels.py index 9aa496134..8e5ab8156 100644 --- a/netbox/dcim/migrations/0107_component_labels.py +++ b/netbox/dcim/migrations/0107_component_labels.py @@ -60,4 +60,14 @@ class Migration(migrations.Migration): name='label', field=models.CharField(blank=True, max_length=64), ), + migrations.AddField( + model_name='devicebay', + name='label', + field=models.CharField(blank=True, max_length=64), + ), + migrations.AddField( + model_name='devicebaytemplate', + name='label', + field=models.CharField(blank=True, max_length=64), + ), ] diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 53080d8f6..f966b0616 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -433,14 +433,16 @@ class DeviceBayTemplate(ComponentTemplateModel): max_length=100, blank=True ) + label = models.CharField( + max_length=64, + blank=True, + help_text="Physical label" + ) class Meta: ordering = ('device_type', '_name') unique_together = ('device_type', 'name') - def __str__(self): - return self.name - def instantiate(self, device): return DeviceBay( device=device, diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 022b2733d..a542b0197 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -1009,6 +1009,11 @@ class DeviceBay(ComponentModel): max_length=100, blank=True ) + label = models.CharField( + max_length=64, + blank=True, + help_text="Physical label" + ) installed_device = models.OneToOneField( to='dcim.Device', on_delete=models.SET_NULL, @@ -1025,6 +1030,8 @@ class DeviceBay(ComponentModel): unique_together = ('device', 'name') def __str__(self): + if self.label: + return '{} - {} ({})'.format(self.device.name, self.name, self.label) return '{} - {}'.format(self.device.name, self.name) def get_absolute_url(self): From 7a54bd9f2a31aaa45cb1c4661f3278a62447bc26 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 11 Jun 2020 13:42:20 -0400 Subject: [PATCH 148/505] Changelog and migrations fix for #4615 --- docs/release-notes/version-2.9.md | 1 + netbox/dcim/migrations/{0107_add_tags.py => 0108_add_tags.py} | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) rename netbox/dcim/migrations/{0107_add_tags.py => 0108_add_tags.py} (94%) diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index a190662d2..60f554f4d 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -10,6 +10,7 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo ### Enhancements +* [#4615](https://github.com/netbox-community/netbox/issues/4615) - Add `label` field for all device components * [#4742](https://github.com/netbox-community/netbox/issues/4742) - Add tagging for cables, power panels, and rack reservations ### Configuration Changes diff --git a/netbox/dcim/migrations/0107_add_tags.py b/netbox/dcim/migrations/0108_add_tags.py similarity index 94% rename from netbox/dcim/migrations/0107_add_tags.py rename to netbox/dcim/migrations/0108_add_tags.py index 2f7d29b96..670f1f0e9 100644 --- a/netbox/dcim/migrations/0107_add_tags.py +++ b/netbox/dcim/migrations/0108_add_tags.py @@ -8,7 +8,7 @@ class Migration(migrations.Migration): dependencies = [ ('extras', '0042_customfield_manager'), - ('dcim', '0106_role_default_color'), + ('dcim', '0107_component_labels'), ] operations = [ From 057a02220568f8f50de66d31c0d292616fc7fbb7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 11 Jun 2020 16:12:50 -0400 Subject: [PATCH 149/505] Clean up and improve UI view tests --- netbox/dcim/tests/test_views.py | 17 ++- netbox/ipam/tests/test_views.py | 11 +- netbox/utilities/testing/views.py | 204 +++++++++++++++++++++--------- 3 files changed, 160 insertions(+), 72 deletions(-) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index eb1e47a66..e9201e8fa 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1062,8 +1062,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): } cls.bulk_edit_data = { - 'device': device.pk, - 'type': ConsolePortTypeChoices.TYPE_RJ45, + 'type': ConsolePortTypeChoices.TYPE_RJ11, 'description': 'New description', } @@ -1163,8 +1162,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): } cls.bulk_edit_data = { - 'device': device.pk, - 'type': PowerOutletTypeChoices.TYPE_IEC_C13, + 'type': PowerOutletTypeChoices.TYPE_IEC_C15, 'power_port': powerports[1].pk, 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, 'description': 'New description', @@ -1238,9 +1236,8 @@ class InterfaceTestCase( } cls.bulk_edit_data = { - 'device': device.pk, - 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, - 'enabled': False, + 'type': InterfaceTypeChoices.TYPE_1GE_FIXED, + 'enabled': True, 'lag': interfaces[3].pk, 'mac_address': EUI('01:02:03:04:05:06'), 'mtu': 2000, @@ -1442,8 +1439,6 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): } cls.bulk_edit_data = { - 'device': device.pk, - 'manufacturer': manufacturer.pk, 'part_id': '123456', 'description': 'New description', } @@ -1597,6 +1592,10 @@ class VirtualChassisTestCase( 'form-MAX_NUM_FORMS': 1000, } + cls.bulk_edit_data = { + 'domain': 'domain-x', + } + class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = PowerPanel diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 43ac565ad..d4d5a857c 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -5,6 +5,7 @@ from netaddr import IPNetwork from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from ipam.choices import * from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from tenancy.models import Tenant from utilities.testing import ViewTestCases @@ -14,6 +15,12 @@ class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase): @classmethod def setUpTestData(cls): + tenants = ( + Tenant(name='Tenant A', slug='tenant-a'), + Tenant(name='Tenant B', slug='tenant-b'), + ) + Tenant.objects.bulk_create(tenants) + VRF.objects.bulk_create([ VRF(name='VRF 1', rd='65000:1'), VRF(name='VRF 2', rd='65000:2'), @@ -23,7 +30,7 @@ class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.form_data = { 'name': 'VRF X', 'rd': '65000:999', - 'tenant': None, + 'tenant': tenants[0].pk, 'enforce_unique': True, 'description': 'A new VRF', 'tags': 'Alpha,Bravo,Charlie', @@ -37,7 +44,7 @@ class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) cls.bulk_edit_data = { - 'tenant': None, + 'tenant': tenants[1].pk, 'enforce_unique': False, 'description': 'New description', } diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index b3c35ed87..62a982eba 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -178,7 +178,7 @@ class ViewTestCases: self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 403) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_get_object_with_model_permission(self): + def test_get_object_with_permission(self): instance = self.model.objects.first() # Add model-level permission @@ -193,7 +193,7 @@ class ViewTestCases: self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 200) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_get_object_with_object_permission(self): + def test_get_object_with_constrained_permission(self): instance1, instance2 = self.model.objects.all()[:2] # Add object-level permission @@ -214,6 +214,8 @@ class ViewTestCases: class CreateObjectViewTestCase(ModelViewTestCase): """ Create a single new instance. + + :form_data: Data to be used when creating a new object. """ form_data = {} @@ -234,10 +236,10 @@ class ViewTestCases: self.assertHttpStatus(response, 403) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_create_object_with_model_permission(self): + def test_create_object_with_permission(self): initial_count = self.model.objects.count() - # Assign model-level permission + # Assign unconstrained permission obj_perm = ObjectPermission( actions=['add'] ) @@ -258,12 +260,12 @@ class ViewTestCases: 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): + def test_create_object_with_constrained_permission(self): initial_count = self.model.objects.count() - # Assign object-level permission + # Assign constrained permission obj_perm = ObjectPermission( - constraints={'pk__gt': 0}, # Dummy permission to allow all + constraints={'pk': 0}, # Dummy permission to deny all actions=['add'] ) obj_perm.save() @@ -273,7 +275,19 @@ class ViewTestCases: # Try GET with object-level permission self.assertHttpStatus(self.client.get(self._get_url('add')), 200) - # Try to create permitted object + # Try to create an object (not permitted) + 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 + + # Update the ObjectPermission to allow creation + obj_perm.constraints = {'pk__gt': 0} + obj_perm.save() + + # Try to create an object (permitted) request = { 'path': self._get_url('add'), 'data': post_data(self.form_data), @@ -282,22 +296,11 @@ class ViewTestCases: 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.constraints = {'pk': 0} - obj_perm.save() - - # 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): """ Edit a single existing instance. + + :form_data: Data to be used when updating the first existing object. """ form_data = {} @@ -318,7 +321,7 @@ class ViewTestCases: self.assertHttpStatus(self.client.post(**request), 403) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_edit_object_with_model_permission(self): + def test_edit_object_with_permission(self): instance = self.model.objects.first() # Assign model-level permission @@ -341,10 +344,10 @@ class ViewTestCases: 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): + def test_edit_object_with_constrained_permission(self): instance1, instance2 = self.model.objects.all()[:2] - # Assign object-level permission + # Assign constrained permission obj_perm = ObjectPermission( constraints={'pk': instance1.pk}, actions=['change'] @@ -395,7 +398,7 @@ class ViewTestCases: self.assertHttpStatus(self.client.post(**request), 403) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_delete_object_with_model_permission(self): + def test_delete_object_with_permission(self): instance = self.model.objects.first() # Assign model-level permission @@ -419,7 +422,7 @@ class ViewTestCases: self.model.objects.get(pk=instance.pk) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_delete_object_with_object_permission(self): + def test_delete_object_with_constrained_permission(self): instance1, instance2 = self.model.objects.all()[:2] # Assign object-level permission @@ -473,7 +476,7 @@ class ViewTestCases: self.assertHttpStatus(self.client.get(self._get_url('list')), 403) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_list_objects_with_model_permission(self): + def test_list_objects_with_permission(self): # Add model-level permission obj_perm = ObjectPermission( @@ -493,7 +496,7 @@ class ViewTestCases: self.assertEqual(response.get('Content-Type'), 'text/csv') @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_list_objects_with_object_permission(self): + def test_list_objects_with_constrained_permission(self): instance1, instance2 = self.model.objects.all()[:2] # Add object-level permission @@ -506,38 +509,87 @@ class ViewTestCases: 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) - - # TODO: Verify that only the permitted object is returned + response = self.client.get(self._get_url('list')) + self.assertHttpStatus(response, 200) + content = str(response.content) + if hasattr(self.model, 'name'): + self.assertIn(instance1.name, content) + self.assertNotIn(instance2.name, content) + else: + self.assertIn(instance1.get_absolute_url(), content) + self.assertNotIn(instance2.get_absolute_url(), content) class BulkCreateObjectsViewTestCase(ModelViewTestCase): """ Create multiple instances using a single form. Expects the creation of three new instances by default. + + :bulk_create_count: The number of objects expected to be created (default: 3). + :bulk_create_data: A dictionary of data to be used for bulk object creation. """ bulk_create_count = 3 bulk_create_data = {} @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_bulk_create_objects(self): + def test_bulk_create_objects_without_permission(self): + request = { + 'path': self._get_url('add'), + 'data': post_data(self.bulk_create_data), + } + + # Try POST without permission + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.post(**request), 403) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_bulk_create_objects_with_permission(self): initial_count = self.model.objects.count() request = { 'path': self._get_url('add'), 'data': post_data(self.bulk_create_data), } - # Attempt to make the request without required permissions - with disable_warnings('django.request'): - self.assertHttpStatus(self.client.post(**request), 403) - - # Assign object-level permission - obj_perm = ObjectPermission(actions=['add']) + # Assign non-constrained permission + obj_perm = ObjectPermission( + actions=['add'], + ) obj_perm.save() obj_perm.users.add(self.user) obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + # Bulk create objects response = self.client.post(**request) self.assertHttpStatus(response, 302) + self.assertEqual(initial_count + self.bulk_create_count, self.model.objects.count()) + for instance in self.model.objects.order_by('-pk')[:self.bulk_create_count]: + self.assertInstanceEqual(instance, self.bulk_create_data) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_bulk_create_objects_with_constrained_permission(self): + initial_count = self.model.objects.count() + request = { + 'path': self._get_url('add'), + 'data': post_data(self.bulk_create_data), + } + + # Assign constrained permission + obj_perm = ObjectPermission( + actions=['add'], + constraints={'pk': 0} # Dummy constraint to deny all + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + # Attempt to make the request with unmet constraints + self.assertHttpStatus(self.client.post(**request), 200) + self.assertEqual(self.model.objects.count(), initial_count) + + # Update the ObjectPermission to allow creation + obj_perm.constraints = {'pk__gt': 0} # Dummy constraint to allow all + obj_perm.save() + + response = self.client.post(**request) + self.assertHttpStatus(response, 302) self.assertEqual(initial_count + self.bulk_create_count, self.model.objects.count()) for instance in self.model.objects.order_by('-pk')[:self.bulk_create_count]: self.assertInstanceEqual(instance, self.bulk_create_data) @@ -545,6 +597,8 @@ class ViewTestCases: class BulkImportObjectsViewTestCase(ModelViewTestCase): """ Create multiple instances from imported data. + + :csv_data: A list of CSV-formatted lines (starting with the headers) to be used for bulk object import. """ csv_data = () @@ -567,7 +621,7 @@ class ViewTestCases: self.assertHttpStatus(response, 403) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_bulk_import_objects_with_model_permission(self): + def test_bulk_import_objects_with_permission(self): initial_count = self.model.objects.count() data = { 'csv': self._get_csv_data(), @@ -589,30 +643,39 @@ class ViewTestCases: 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): + def test_bulk_import_objects_with_constrained_permission(self): initial_count = self.model.objects.count() data = { 'csv': self._get_csv_data(), } - # Assign object-level permission + # Assign constrained permission obj_perm = ObjectPermission( - constraints={'pk__gt': 0}, # Dummy permission to allow all + constraints={'pk': 0}, # Dummy permission to deny all actions=['add'] ) obj_perm.save() obj_perm.users.add(self.user) obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) - # Test import with object-level permission + # Attempt to import non-permitted objects + self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) + self.assertEqual(self.model.objects.count(), initial_count) + + # Update permission constraints + obj_perm.constraints = {'pk__gt': 0} # Dummy permission to allow all + obj_perm.save() + + # Import permitted objects 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. + + :bulk_edit_data: A dictionary of data to be used when bulk editing a set of objects. This data should differ + from that used for initial object creation within setUpTestData(). """ bulk_edit_data = {} @@ -633,7 +696,7 @@ class ViewTestCases: 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): + def test_bulk_edit_objects_with_permission(self): pk_list = self.model.objects.values_list('pk', flat=True)[:3] data = { 'pk': pk_list, @@ -657,8 +720,9 @@ class ViewTestCases: 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] + def test_bulk_edit_objects_with_constrained_permission(self): + initial_instances = self.model.objects.all()[:3] + pk_list = list(self.model.objects.values_list('pk', flat=True)[:3]) data = { 'pk': pk_list, '_apply': True, # Form button @@ -667,22 +731,33 @@ class ViewTestCases: # Append the form data to the request data.update(post_data(self.bulk_edit_data)) - # Assign object-level permission + # Dynamically determine a constraint that will *not* be matched by the updated objects. + attr_name = list(self.bulk_edit_data.keys())[0] + field = self.model._meta.get_field(attr_name) + value = field.value_from_object(self.model.objects.first()) + + # Assign constrained permission obj_perm = ObjectPermission( - constraints={'pk__in': list(pk_list)}, + constraints={attr_name: value}, actions=['change'] ) obj_perm.save() obj_perm.users.add(self.user) obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) - # Try POST with model-level permission + # Attempt to bulk edit permitted objects into a non-permitted state + response = self.client.post(self._get_url('bulk_edit'), data) + self.assertHttpStatus(response, 200) + + # Update permission constraints + obj_perm.constraints = {'pk__gt': 0} + obj_perm.save() + + # Bulk edit permitted objects 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. @@ -705,7 +780,7 @@ class ViewTestCases: 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): + def test_bulk_delete_objects_with_permission(self): pk_list = self.model.objects.values_list('pk', flat=True) data = { 'pk': pk_list, @@ -713,7 +788,7 @@ class ViewTestCases: '_confirm': True, # Form button } - # Assign model-level permission + # Assign unconstrained permission obj_perm = ObjectPermission( actions=['delete'] ) @@ -726,7 +801,8 @@ class ViewTestCases: self.assertEqual(self.model.objects.count(), 0) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_bulk_delete_objects_with_object_permission(self): + def test_bulk_delete_objects_with_constrained_permission(self): + initial_count = self.model.objects.count() pk_list = self.model.objects.values_list('pk', flat=True) data = { 'pk': pk_list, @@ -734,21 +810,27 @@ class ViewTestCases: '_confirm': True, # Form button } - # Assign object-level permission + # Assign constrained permission obj_perm = ObjectPermission( - constraints={'pk__in': list(pk_list)}, + constraints={'pk': 0}, # Dummy permission to deny all actions=['delete'] ) obj_perm.save() obj_perm.users.add(self.user) obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) - # Try POST with object-level permission + # Attempt to bulk delete non-permitted objects + self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302) + self.assertEqual(self.model.objects.count(), initial_count) + + # Update permission constraints + obj_perm.constraints = {'pk__gt': 0} # Dummy permission to allow all + obj_perm.save() + + # Bulk delete permitted objects 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 da906f48d90161af90703e1cd49bad9f769b8bf7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 12 Jun 2020 09:48:23 -0400 Subject: [PATCH 150/505] Standardize add, import, and export functionality for tags --- netbox/extras/forms.py | 12 +++++++++++ netbox/extras/models/tags.py | 10 +++++++++ netbox/extras/tests/test_views.py | 18 ++++++++-------- netbox/extras/urls.py | 2 ++ netbox/extras/views.py | 33 +++++++++++++++++------------- netbox/templates/extras/tag.html | 2 +- netbox/templates/inc/nav_menu.html | 6 ++++++ 7 files changed, 58 insertions(+), 25 deletions(-) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index cb9930ae2..217f55b70 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -1,6 +1,7 @@ from django import forms from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType +from django.utils.safestring import mark_safe from mptt.forms import TreeNodeMultipleChoiceField from taggit.forms import TagField as TagField_ @@ -161,6 +162,17 @@ class TagForm(BootstrapMixin, forms.ModelForm): ] +class TagCSVForm(CSVModelForm): + slug = SlugField() + + class Meta: + model = Tag + fields = Tag.csv_headers + help_texts = { + 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), + } + + class AddRemoveTagsForm(forms.Form): def __init__(self, *args, **kwargs): diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index d5792ebda..9bb90f21e 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -24,6 +24,8 @@ class Tag(TagBase, ChangeLoggedModel): objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'color', 'description'] + def get_absolute_url(self): return reverse('extras:tag', args=[self.slug]) @@ -34,6 +36,14 @@ class Tag(TagBase, ChangeLoggedModel): slug += "_%d" % i return slug + def to_csv(self): + return ( + self.name, + self.slug, + self.color, + self.description + ) + class TaggedItem(GenericTaggedItemBase): tag = models.ForeignKey( diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 9ad29fd80..b3abf5b22 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -10,16 +10,7 @@ from extras.models import ConfigContext, ObjectChange, Tag from utilities.testing import ViewTestCases, TestCase -# TODO: Change base class to PrimaryObjectViewTestCase -# Blocked by #3703 -class TagTestCase( - ViewTestCases.GetObjectViewTestCase, - ViewTestCases.EditObjectViewTestCase, - ViewTestCases.DeleteObjectViewTestCase, - ViewTestCases.ListObjectsViewTestCase, - ViewTestCases.BulkEditObjectsViewTestCase, - ViewTestCases.BulkDeleteObjectsViewTestCase -): +class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Tag @classmethod @@ -38,6 +29,13 @@ class TagTestCase( 'comments': 'Some comments', } + cls.csv_data = ( + "name,slug,color,description", + "Tag 4,tag-4,ff0000,Fourth tag", + "Tag 5,tag-5,00ff00,Fifth tag", + "Tag 6,tag-6,0000ff,Sixth tag", + ) + cls.bulk_edit_data = { 'color': '00ff00', } diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 3eee303a3..3007e6524 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -9,6 +9,8 @@ urlpatterns = [ # Tags path('tags/', views.TagListView.as_view(), name='tag_list'), + path('tags/add/', views.TagEditView.as_view(), name='tag_add'), + path('tags/import/', views.TagBulkImportView.as_view(), name='tag_import'), path('tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'), path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'), path('tags//', views.TagView.as_view(), name='tag'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index e80aa1d62..0e6700f06 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -13,14 +13,13 @@ 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, + BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView, ObjectPermissionRequiredMixin, ) -from . import filters, forms +from . import filters, forms, tables from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem from .reports import get_report, get_reports from .scripts import get_scripts, run_script -from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable # @@ -35,8 +34,7 @@ class TagListView(ObjectListView): ) filterset = filters.TagFilterSet filterset_form = forms.TagFilterForm - table = TagTable - action_buttons = () + table = tables.TagTable class TagView(ObjectView): @@ -52,7 +50,7 @@ class TagView(ObjectView): ) # Generate a table of all items tagged with this Tag - items_table = TaggedItemTable(tagged_items) + items_table = tables.TaggedItemTable(tagged_items) paginate = { 'paginator_class': EnhancedPaginator, 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) @@ -78,13 +76,20 @@ class TagDeleteView(ObjectDeleteView): default_return_url = 'extras:tag_list' +class TagBulkImportView(BulkImportView): + queryset = Tag.objects.all() + model_form = forms.TagCSVForm + table = tables.TagTable + default_return_url = 'extras:tag_list' + + class TagBulkEditView(BulkEditView): queryset = Tag.objects.annotate( items=Count('extras_taggeditem_items', distinct=True) ).order_by( 'name' ) - table = TagTable + table = tables.TagTable form = forms.TagBulkEditForm default_return_url = 'extras:tag_list' @@ -95,7 +100,7 @@ class TagBulkDeleteView(BulkDeleteView): ).order_by( 'name' ) - table = TagTable + table = tables.TagTable default_return_url = 'extras:tag_list' @@ -107,7 +112,7 @@ class ConfigContextListView(ObjectListView): queryset = ConfigContext.objects.all() filterset = filters.ConfigContextFilterSet filterset_form = forms.ConfigContextFilterForm - table = ConfigContextTable + table = tables.ConfigContextTable action_buttons = ('add',) @@ -143,7 +148,7 @@ class ConfigContextEditView(ObjectEditView): class ConfigContextBulkEditView(BulkEditView): queryset = ConfigContext.objects.all() filterset = filters.ConfigContextFilterSet - table = ConfigContextTable + table = tables.ConfigContextTable form = forms.ConfigContextBulkEditForm default_return_url = 'extras:configcontext_list' @@ -155,7 +160,7 @@ class ConfigContextDeleteView(ObjectDeleteView): class ConfigContextBulkDeleteView(BulkDeleteView): queryset = ConfigContext.objects.all() - table = ConfigContextTable + table = tables.ConfigContextTable default_return_url = 'extras:configcontext_list' @@ -197,7 +202,7 @@ class ObjectChangeListView(ObjectListView): queryset = ObjectChange.objects.prefetch_related('user', 'changed_object_type') filterset = filters.ObjectChangeFilterSet filterset_form = forms.ObjectChangeFilterForm - table = ObjectChangeTable + table = tables.ObjectChangeTable template_name = 'extras/objectchange_list.html' action_buttons = ('export',) @@ -214,7 +219,7 @@ class ObjectChangeView(ObjectView): ).exclude( pk=objectchange.pk ) - related_changes_table = ObjectChangeTable( + related_changes_table = tables.ObjectChangeTable( data=related_changes[:50], orderable=False ) @@ -267,7 +272,7 @@ class ObjectChangeLogView(View): Q(changed_object_type=content_type, changed_object_id=obj.pk) | Q(related_object_type=content_type, related_object_id=obj.pk) ) - objectchanges_table = ObjectChangeTable( + objectchanges_table = tables.ObjectChangeTable( data=objectchanges, orderable=False ) diff --git a/netbox/templates/extras/tag.html b/netbox/templates/extras/tag.html index 0c20bcbdc..ff54a4800 100644 --- a/netbox/templates/extras/tag.html +++ b/netbox/templates/extras/tag.html @@ -85,7 +85,7 @@ Description - {{ tag.description }} + {{ tag.description|placeholder }}
diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index 765df31cc..0e0e2b981 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -101,6 +101,12 @@
  • + {% if perms.extras.add_tag %} +
    + + +
    + {% endif %} Tags From 7dc4f8d5ccf0b941c0fae0b5c16e486f16ae4038 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 12 Jun 2020 09:58:59 -0400 Subject: [PATCH 151/505] Remove TagField --- netbox/circuits/forms.py | 8 ++-- netbox/dcim/forms.py | 80 +++++++++++++++++++++++----------- netbox/extras/forms.py | 20 ++++----- netbox/ipam/forms.py | 24 +++++++--- netbox/secrets/forms.py | 5 ++- netbox/tenancy/forms.py | 5 ++- netbox/virtualization/forms.py | 18 +++++--- 7 files changed, 101 insertions(+), 59 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 2185d1eab..341a7a9b7 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -3,8 +3,8 @@ from django import forms from dcim.models import Region, Site from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm, - TagField, ) +from extras.models import Tag from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( @@ -23,7 +23,8 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider class ProviderForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() comments = CommentField() - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -165,7 +166,8 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): queryset=CircuitType.objects.all() ) comments = CommentField() - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 831f7be9e..534097cb2 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -14,8 +14,9 @@ from timezone_field import TimeZoneFormField from circuits.models import Circuit, Provider from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm, CustomFieldModelForm, - LocalConfigContextFilterForm, TagField, + LocalConfigContextFilterForm, ) +from extras.models import Tag from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN from ipam.models import IPAddress, VLAN from tenancy.forms import TenancyFilterForm, TenancyForm @@ -225,7 +226,8 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) slug = SlugField() comments = CommentField() - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -481,7 +483,8 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): required=False ) comments = CommentField() - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -750,7 +753,8 @@ class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm): ), widget=StaticSelect2() ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -912,7 +916,8 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm): slug_source='model' ) comments = CommentField() - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -1716,11 +1721,14 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): required=False ) comments = CommentField() - tags = TagField(required=False) local_context_data = JSONField( required=False, label='' ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = Device @@ -2209,7 +2217,8 @@ class ConsolePortFilterForm(DeviceComponentFilterForm): class ConsolePortForm(BootstrapMixin, forms.ModelForm): - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -2236,7 +2245,8 @@ class ConsolePortCreateForm(LabeledComponentForm): max_length=100, required=False ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -2292,7 +2302,8 @@ class ConsoleServerPortFilterForm(DeviceComponentFilterForm): class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -2319,7 +2330,8 @@ class ConsoleServerPortCreateForm(LabeledComponentForm): max_length=100, required=False ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -2389,7 +2401,8 @@ class PowerPortFilterForm(DeviceComponentFilterForm): class PowerPortForm(BootstrapMixin, forms.ModelForm): - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -2426,7 +2439,8 @@ class PowerPortCreateForm(LabeledComponentForm): max_length=100, required=False ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -2486,7 +2500,8 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm): queryset=PowerPort.objects.all(), required=False ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -2530,7 +2545,8 @@ class PowerOutletCreateForm(LabeledComponentForm): max_length=100, required=False ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -2689,7 +2705,8 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): }, ) ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -2773,7 +2790,8 @@ class InterfaceCreateForm(InterfaceCommonForm, LabeledComponentForm): required=False, widget=StaticSelect2(), ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) untagged_vlan = DynamicModelChoiceField( @@ -2985,7 +3003,8 @@ class FrontPortFilterForm(DeviceComponentFilterForm): class FrontPortForm(BootstrapMixin, forms.ModelForm): - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -3176,7 +3195,8 @@ class RearPortFilterForm(DeviceComponentFilterForm): class RearPortForm(BootstrapMixin, forms.ModelForm): - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -3279,7 +3299,8 @@ class DeviceBayFilterForm(DeviceComponentFilterForm): class DeviceBayForm(BootstrapMixin, forms.ModelForm): - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -3300,7 +3321,8 @@ class DeviceBayCreateForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField( label='Name' ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -3330,7 +3352,8 @@ class DeviceBayBulkCreateForm( form_from_model(DeviceBay, ['description', 'tags']), DeviceBulkAddComponentForm ): - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -3634,7 +3657,8 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm): class CableForm(BootstrapMixin, forms.ModelForm): - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -3963,7 +3987,8 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm): queryset=Manufacturer.objects.all(), required=False ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -4111,7 +4136,8 @@ class DeviceSelectionForm(forms.Form): class VirtualChassisForm(BootstrapMixin, forms.ModelForm): - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -4301,7 +4327,8 @@ class PowerPanelForm(BootstrapMixin, forms.ModelForm): queryset=RackGroup.objects.all(), required=False ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -4425,7 +4452,8 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): required=False ) comments = CommentField() - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 217f55b70..24dfe33ba 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -3,7 +3,6 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.utils.safestring import mark_safe from mptt.forms import TreeNodeMultipleChoiceField -from taggit.forms import TagField as TagField_ from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup @@ -143,15 +142,6 @@ class CustomFieldFilterForm(forms.Form): # Tags # -class TagField(TagField_): - - def widget_attrs(self, widget): - # Apply the "tagfield" CSS class to trigger the special API-based selection widget for tags - return { - 'class': 'tagfield' - } - - class TagForm(BootstrapMixin, forms.ModelForm): slug = SlugField() @@ -179,8 +169,14 @@ class AddRemoveTagsForm(forms.Form): super().__init__(*args, **kwargs) # Add add/remove tags fields - self.fields['add_tags'] = TagField(required=False) - self.fields['remove_tags'] = TagField(required=False) + self.fields['add_tags'] = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + self.fields['remove_tags'] = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class TagFilterForm(BootstrapMixin, forms.Form): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index fc1352ec9..e27041724 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -4,8 +4,8 @@ from django.core.validators import MaxValueValidator, MinValueValidator from dcim.models import Device, Interface, Rack, Region, Site from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm, - TagField, ) +from extras.models import Tag from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( @@ -33,7 +33,8 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([ # class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -141,7 +142,8 @@ class AggregateForm(BootstrapMixin, CustomFieldModelForm): rir = DynamicModelChoiceField( queryset=RIR.objects.all() ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -292,7 +294,10 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): queryset=Role.objects.all(), required=False ) - tags = TagField(required=False) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = Prefix @@ -584,7 +589,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel required=False, label='Make this the primary IP for the device/VM' ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -990,7 +996,10 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): queryset=Role.objects.all(), required=False ) - tags = TagField(required=False) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = VLAN @@ -1157,7 +1166,8 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm): min_value=SERVICE_PORT_MIN, max_value=SERVICE_PORT_MAX ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 296469900..f62c72293 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -5,8 +5,8 @@ from django import forms from dcim.models import Device from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm, - TagField, ) +from extras.models import Tag from utilities.forms import ( APISelectMultiple, BootstrapMixin, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, StaticSelect2Multiple, TagFilterField, @@ -90,7 +90,8 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm): role = DynamicModelChoiceField( queryset=SecretRole.objects.all() ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index bf100f43a..5bd0657b6 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -2,8 +2,8 @@ from django import forms from extras.forms import ( AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelCSVForm, - TagField, ) +from extras.models import Tag from utilities.forms import ( APISelect, APISelectMultiple, BootstrapMixin, CommentField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, TagFilterField, @@ -57,7 +57,8 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm): required=False ) comments = CommentField() - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 2f2ee4950..942368f19 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -7,8 +7,8 @@ from dcim.forms import INTERFACE_MODE_HELP_TEXT from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm, - TagField, ) +from extras.models import Tag from ipam.models import IPAddress, VLAN from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant @@ -83,7 +83,8 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): required=False ) comments = CommentField() - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -312,13 +313,14 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): queryset=Platform.objects.all(), required=False ) - tags = TagField( - required=False - ) local_context_data = JSONField( required=False, label='' ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = VirtualMachine @@ -590,7 +592,8 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): }, ) ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -697,7 +700,8 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form): }, ) ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) From e905a36fb2be1a2d4867d96fc7c44b83002d2fba Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 12 Jun 2020 11:33:23 -0400 Subject: [PATCH 152/505] Update tests for tag changes --- netbox/circuits/tests/test_views.py | 5 +- netbox/dcim/tests/test_views.py | 70 ++++++++++++++--------- netbox/ipam/tests/test_views.py | 12 ++-- netbox/tenancy/tests/test_views.py | 2 +- netbox/utilities/testing/utils.py | 9 ++- netbox/utilities/testing/views.py | 19 +++++- netbox/virtualization/tests/test_views.py | 10 ++-- 7 files changed, 84 insertions(+), 43 deletions(-) diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 9cc7af6ae..38365521a 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -26,7 +26,7 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'noc_contact': 'noc@example.com', 'admin_contact': 'admin@example.com', 'comments': 'Another provider', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( @@ -106,7 +106,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'commit_rate': 1000, 'description': 'A new circuit', 'comments': 'Some comments', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( @@ -124,5 +124,4 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'commit_rate': 2000, 'description': 'New description', 'comments': 'New comments', - } diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index e9201e8fa..49f6f67b0 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -94,7 +94,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'contact_phone': '123-555-9999', 'contact_email': 'hank@stricklandpropane.com', 'comments': 'Test site', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( @@ -202,7 +202,7 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'user': user3.pk, 'tenant': None, 'description': 'Rack reservation', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( @@ -268,7 +268,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'outer_depth': 500, 'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER, 'comments': 'Some comments', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( @@ -359,7 +359,7 @@ class DeviceTypeTestCase( 'is_full_depth': True, 'subdevice_role': '', # CharField 'comments': 'Some comments', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.bulk_edit_data = { @@ -967,7 +967,7 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'vc_position': None, 'vc_priority': None, 'comments': 'A new device', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), 'local_context_data': None, } @@ -1001,12 +1001,14 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): ConsolePort(device=device, name='Console Port 3'), ]) + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'device': device.pk, 'name': 'Console Port X', 'type': ConsolePortTypeChoices.TYPE_RJ45, 'description': 'A console port', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_create_data = { @@ -1016,7 +1018,7 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): 'label_pattern': 'Serial[3-5]', 'type': ConsolePortTypeChoices.TYPE_RJ45, 'description': 'A console port', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_edit_data = { @@ -1045,12 +1047,14 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): ConsoleServerPort(device=device, name='Console Server Port 3'), ]) + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'device': device.pk, 'name': 'Console Server Port X', 'type': ConsolePortTypeChoices.TYPE_RJ45, 'description': 'A console server port', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_create_data = { @@ -1058,7 +1062,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): 'name_pattern': 'Console Server Port [4-6]', 'type': ConsolePortTypeChoices.TYPE_RJ45, 'description': 'A console server port', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_edit_data = { @@ -1087,6 +1091,8 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): PowerPort(device=device, name='Power Port 3'), ]) + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'device': device.pk, 'name': 'Power Port X', @@ -1094,7 +1100,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): 'maximum_draw': 100, 'allocated_draw': 50, 'description': 'A power port', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_create_data = { @@ -1104,7 +1110,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): 'maximum_draw': 100, 'allocated_draw': 50, 'description': 'A power port', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_edit_data = { @@ -1141,6 +1147,8 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): PowerOutlet(device=device, name='Power Outlet 3', power_port=powerports[0]), ]) + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'device': device.pk, 'name': 'Power Outlet X', @@ -1148,7 +1156,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): 'power_port': powerports[1].pk, 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, 'description': 'A power outlet', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_create_data = { @@ -1158,7 +1166,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): 'power_port': powerports[1].pk, 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, 'description': 'A power outlet', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_edit_data = { @@ -1202,6 +1210,8 @@ class InterfaceTestCase( ) VLAN.objects.bulk_create(vlans) + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'device': device.pk, 'virtual_machine': None, @@ -1216,7 +1226,7 @@ class InterfaceTestCase( 'mode': InterfaceModeChoices.MODE_TAGGED, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_create_data = { @@ -1232,7 +1242,7 @@ class InterfaceTestCase( 'mode': InterfaceModeChoices.MODE_TAGGED, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_edit_data = { @@ -1279,6 +1289,8 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): FrontPort(device=device, name='Front Port 3', rear_port=rearports[2]), ]) + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'device': device.pk, 'name': 'Front Port X', @@ -1286,7 +1298,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): 'rear_port': rearports[3].pk, 'rear_port_position': 1, 'description': 'New description', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_create_data = { @@ -1297,7 +1309,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): '{}:1'.format(rp.pk) for rp in rearports[3:6] ], 'description': 'New description', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_edit_data = { @@ -1326,13 +1338,15 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): RearPort(device=device, name='Rear Port 3'), ]) + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'device': device.pk, 'name': 'Rear Port X', 'type': PortTypeChoices.TYPE_8P8C, 'positions': 3, 'description': 'A rear port', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_create_data = { @@ -1341,7 +1355,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): 'type': PortTypeChoices.TYPE_8P8C, 'positions': 3, 'description': 'A rear port', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_edit_data = { @@ -1373,18 +1387,20 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): DeviceBay(device=device, name='Device Bay 3'), ]) + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'device': device.pk, 'name': 'Device Bay X', 'description': 'A device bay', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_create_data = { 'device': device.pk, 'name_pattern': 'Device Bay [4-6]', 'description': 'A device bay', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_edit_data = { @@ -1413,6 +1429,8 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): InventoryItem(device=device, name='Inventory Item 3'), ]) + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'device': device.pk, 'manufacturer': manufacturer.pk, @@ -1423,7 +1441,7 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): 'serial': '123ABC', 'asset_tag': 'ABC123', 'description': 'An inventory item', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_create_data = { @@ -1435,7 +1453,7 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): 'part_id': '123456', 'serial': '123ABC', 'description': 'An inventory item', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_edit_data = { @@ -1513,7 +1531,7 @@ class CableTestCase( 'color': 'c0c0c0', 'length': 100, 'length_unit': CableLengthUnitChoices.UNIT_FOOT, - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( @@ -1626,7 +1644,7 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'site': sites[1].pk, 'rack_group': rackgroups[1].pk, 'name': 'Power Panel X', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( @@ -1680,7 +1698,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'amperage': 100, 'max_utilization': 50, 'comments': 'New comments', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), # Connection 'cable': None, diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index d4d5a857c..b704664c1 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -33,7 +33,7 @@ class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'tenant': tenants[0].pk, 'enforce_unique': True, 'description': 'A new VRF', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( @@ -100,7 +100,7 @@ class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'rir': rirs[1].pk, 'date_added': datetime.date(2020, 1, 1), 'description': 'A new aggregate', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( @@ -183,7 +183,7 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'role': roles[1].pk, 'is_pool': True, 'description': 'A new prefix', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( @@ -232,7 +232,7 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'nat_inside': None, 'dns_name': 'example', 'description': 'A new IP address', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( @@ -320,7 +320,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'status': VLANStatusChoices.STATUS_RESERVED, 'role': roles[1].pk, 'description': 'A new VLAN', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( @@ -376,7 +376,7 @@ class ServiceTestCase( 'port': 999, 'ipaddresses': [], 'description': 'A new service', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index ca2c2633f..4e00b648c 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -55,7 +55,7 @@ class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'group': tenant_groups[1].pk, 'description': 'A new tenant', 'comments': 'Some comments', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py index fd8c70f05..d763012f0 100644 --- a/netbox/utilities/testing/utils.py +++ b/netbox/utilities/testing/utils.py @@ -14,7 +14,14 @@ def post_data(data): if value is None: ret[key] = '' elif type(value) in (list, tuple): - ret[key] = value + if value and hasattr(value[0], 'pk'): + # Value is a list of instances + ret[key] = [v.pk for v in value] + else: + ret[key] = value + elif hasattr(value, 'pk'): + # Value is an instance + ret[key] = value.pk else: ret[key] = str(value) diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 62a982eba..7c9631686 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -5,8 +5,10 @@ from django.db.models import ForeignKey, ManyToManyField from django.forms.models import model_to_dict from django.test import Client, TestCase as _TestCase, override_settings from django.urls import reverse, NoReverseMatch +from django.utils.text import slugify from netaddr import IPNetwork +from extras.models import Tag from users.models import ObjectPermission from utilities.permissions import resolve_permission_ct from .utils import disable_warnings, post_data @@ -48,7 +50,7 @@ class TestCase(_TestCase): obj_perm.object_types.add(ct) # - # Convenience methods + # Custom assertions # def assertHttpStatus(self, response, expected_status): @@ -75,7 +77,7 @@ class TestCase(_TestCase): # TODO: Differentiate between tags assigned to the instance and a M2M field for tags (ex: ConfigContext) if key == 'tags': - model_dict[key] = ','.join(sorted([tag.name for tag in value])) + model_dict[key] = sorted(value) # Convert ManyToManyField to list of instance PKs elif model_dict[key] and type(value) in (list, tuple) and hasattr(value[0], 'pk'): @@ -102,6 +104,19 @@ class TestCase(_TestCase): self.assertDictEqual(model_dict, relevant_data) + # + # Convenience methods + # + + @classmethod + def create_tags(cls, *names): + """ + Create and return a Tag instance for each name given. + """ + tags = [Tag(name=name, slug=slugify(name)) for name in names] + Tag.objects.bulk_create(tags) + return tags + # # UI Tests diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index b6f5be8b2..0ccf8a9b1 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -97,7 +97,7 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'tenant': None, 'site': sites[1].pk, 'comments': 'Some comments', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), } cls.csv_data = ( @@ -161,7 +161,7 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'memory': 32768, 'disk': 4000, 'comments': 'Some comments', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': cls.create_tags('Alpha', 'Bravo', 'Charlie'), 'local_context_data': None, } @@ -228,6 +228,8 @@ class InterfaceTestCase( ) VLAN.objects.bulk_create(vlans) + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'virtual_machine': virtualmachines[1].pk, 'name': 'Interface X', @@ -240,7 +242,7 @@ class InterfaceTestCase( 'mode': InterfaceModeChoices.MODE_TAGGED, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_create_data = { @@ -255,7 +257,7 @@ class InterfaceTestCase( 'mode': InterfaceModeChoices.MODE_TAGGED, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], - 'tags': 'Alpha,Bravo,Charlie', + 'tags': tags, } cls.bulk_edit_data = { From ce5fd7955fa4e8a32b3e78442bcd09b8cc9c4d8c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 16 Jun 2020 10:25:37 -0400 Subject: [PATCH 153/505] Catch and log evaluation of RestrictedQuerySet without calling restrict() --- netbox/utilities/querysets.py | 51 +++++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py index 1ac79e90a..32fa0ddfa 100644 --- a/netbox/utilities/querysets.py +++ b/netbox/utilities/querysets.py @@ -1,3 +1,5 @@ +import logging + from django.db.models import Q, QuerySet from utilities.permissions import permission_is_exempt @@ -16,6 +18,34 @@ class DummyQuerySet: class RestrictedQuerySet(QuerySet): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Initialize the is_restricted flag to False. This indicates that the QuerySet has not yet been restricted. + self.is_restricted = False + + def _check_restriction(self): + # Raise a warning if the QuerySet is evaluated without first calling restrict(). + if not getattr(self, 'is_restricted', False): + logger = logging.getLogger('netbox.RestrictedQuerySet') + logger.warning(f'Evaluation of RestrictedQuerySet prior to calling restrict(): {self.model}') + + def _clone(self): + + # Persist the is_restricted flag when cloning the QuerySet. + c = super()._clone() + c.is_restricted = self.is_restricted + + return c + + def _fetch_all(self): + self._check_restriction() + return super()._fetch_all() + + def count(self): + self._check_restriction() + return super().count() + def restrict(self, user, action): """ Filter the QuerySet to return only objects on which the specified user has been granted the specified @@ -31,16 +61,21 @@ class RestrictedQuerySet(QuerySet): # Bypass restriction for superusers and exempt views if user.is_superuser or permission_is_exempt(permission_required): - return self + qs = self # 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() + elif not user.is_authenticated or permission_required not in user.get_all_permissions(): + qs = self.none() # Filter the queryset to include only objects with allowed attributes - attrs = Q() - for perm_attrs in user._object_perm_cache[permission_required]: - if perm_attrs: - attrs |= Q(**perm_attrs) + else: + attrs = Q() + for perm_attrs in user._object_perm_cache[permission_required]: + if perm_attrs: + attrs |= Q(**perm_attrs) + qs = self.filter(attrs) - return self.filter(attrs) + # Mark the QuerySet as having been restricted + qs.is_restricted = True + + return qs From ffb43a8534cc7680cce07a8b67bcb2cd5a44eee9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 16 Jun 2020 12:20:21 -0400 Subject: [PATCH 154/505] Introduce unrestricted() method on RestrictedQuerySet --- netbox/utilities/querysets.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py index 32fa0ddfa..558e926a9 100644 --- a/netbox/utilities/querysets.py +++ b/netbox/utilities/querysets.py @@ -21,20 +21,22 @@ class RestrictedQuerySet(QuerySet): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Initialize the is_restricted flag to False. This indicates that the QuerySet has not yet been restricted. - self.is_restricted = False + # Initialize the allow_evaluation flag to False. This indicates that the QuerySet has not yet been restricted. + self.allow_evaluation = False def _check_restriction(self): - # Raise a warning if the QuerySet is evaluated without first calling restrict(). - if not getattr(self, 'is_restricted', False): + # Raise a warning if the QuerySet is evaluated without first calling restrict() or unrestricted(). + if not getattr(self, 'allow_evaluation', False): logger = logging.getLogger('netbox.RestrictedQuerySet') - logger.warning(f'Evaluation of RestrictedQuerySet prior to calling restrict(): {self.model}') + logger.warning( + f'Evaluation of RestrictedQuerySet prior to calling restrict() or unrestricted(): {self.model}' + ) def _clone(self): - # Persist the is_restricted flag when cloning the QuerySet. + # Persist the allow_evaluation flag when cloning the QuerySet. c = super()._clone() - c.is_restricted = self.is_restricted + c.allow_evaluation = self.allow_evaluation return c @@ -46,6 +48,14 @@ class RestrictedQuerySet(QuerySet): self._check_restriction() return super().count() + def unrestricted(self): + """ + Bypass restriction for the QuerySet. This is necessary in cases where we are not interacting with the objects + directly (e.g. when filtering by related object). + """ + self.allow_evaluation = True + return self + def restrict(self, user, action): """ Filter the QuerySet to return only objects on which the specified user has been granted the specified @@ -75,7 +85,7 @@ class RestrictedQuerySet(QuerySet): attrs |= Q(**perm_attrs) qs = self.filter(attrs) - # Mark the QuerySet as having been restricted - qs.is_restricted = True + # Allow QuerySet evaluation + qs.allow_evaluation = True return qs From 5d724f6b840e28dee5305d8a8fae4334e50dd7cb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 16 Jun 2020 13:03:38 -0400 Subject: [PATCH 155/505] Fix up permissions enforcement for home view, global search --- netbox/netbox/views.py | 43 +++++++++++------------------------------- 1 file changed, 11 insertions(+), 32 deletions(-) diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 7ac5f550b..b7cdf0131 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -13,11 +13,12 @@ from circuits.filters import CircuitFilterSet, ProviderFilterSet from circuits.models import Circuit, Provider from circuits.tables import CircuitTable, ProviderTable from dcim.filters import ( - CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, PowerFeedFilterSet, RackFilterSet, RackGroupFilterSet, SiteFilterSet, - VirtualChassisFilterSet, + CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, PowerFeedFilterSet, RackFilterSet, RackGroupFilterSet, + SiteFilterSet, VirtualChassisFilterSet, ) from dcim.models import ( - Cable, ConsolePort, Device, DeviceType, Interface, PowerPanel, PowerFeed, PowerPort, Rack, RackGroup, Site, VirtualChassis + Cable, ConsolePort, Device, DeviceType, Interface, PowerPanel, PowerFeed, PowerPort, Rack, RackGroup, Site, + VirtualChassis, ) from dcim.tables import ( CableTable, DeviceTable, DeviceTypeTable, PowerFeedTable, RackTable, RackGroupTable, SiteTable, @@ -43,14 +44,12 @@ SEARCH_MAX_RESULTS = 15 SEARCH_TYPES = OrderedDict(( # Circuits ('provider', { - 'permission': 'circuits.view_provider', 'queryset': Provider.objects.annotate(count_circuits=Count('circuits')), 'filterset': ProviderFilterSet, 'table': ProviderTable, 'url': 'circuits:provider_list', }), ('circuit', { - 'permission': 'circuits.view_circuit', 'queryset': Circuit.objects.prefetch_related( 'type', 'provider', 'tenant', 'terminations__site' ).annotate_sites(), @@ -60,35 +59,30 @@ SEARCH_TYPES = OrderedDict(( }), # DCIM ('site', { - 'permission': 'dcim.view_site', 'queryset': Site.objects.prefetch_related('region', 'tenant'), 'filterset': SiteFilterSet, 'table': SiteTable, 'url': 'dcim:site_list', }), ('rack', { - 'permission': 'dcim.view_rack', 'queryset': Rack.objects.prefetch_related('site', 'group', 'tenant', 'role'), 'filterset': RackFilterSet, 'table': RackTable, 'url': 'dcim:rack_list', }), ('rackgroup', { - 'permission': 'dcim.view_rackgroup', 'queryset': RackGroup.objects.prefetch_related('site').annotate(rack_count=Count('racks')), 'filterset': RackGroupFilterSet, 'table': RackGroupTable, 'url': 'dcim:rackgroup_list', }), ('devicetype', { - 'permission': 'dcim.view_devicetype', 'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances')), 'filterset': DeviceTypeFilterSet, 'table': DeviceTypeTable, 'url': 'dcim:devicetype_list', }), ('device', { - 'permission': 'dcim.view_device', 'queryset': Device.objects.prefetch_related( 'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6', ), @@ -97,21 +91,18 @@ SEARCH_TYPES = OrderedDict(( 'url': 'dcim:device_list', }), ('virtualchassis', { - 'permission': 'dcim.view_virtualchassis', 'queryset': VirtualChassis.objects.prefetch_related('master').annotate(member_count=Count('members')), 'filterset': VirtualChassisFilterSet, 'table': VirtualChassisTable, 'url': 'dcim:virtualchassis_list', }), ('cable', { - 'permission': 'dcim.view_cable', 'queryset': Cable.objects.all(), 'filterset': CableFilterSet, 'table': CableTable, 'url': 'dcim:cable_list', }), ('powerfeed', { - 'permission': 'dcim.view_powerfeed', 'queryset': PowerFeed.objects.all(), 'filterset': PowerFeedFilterSet, 'table': PowerFeedTable, @@ -119,14 +110,12 @@ SEARCH_TYPES = OrderedDict(( }), # Virtualization ('cluster', { - 'permission': 'virtualization.view_cluster', 'queryset': Cluster.objects.prefetch_related('type', 'group'), 'filterset': ClusterFilterSet, 'table': ClusterTable, 'url': 'virtualization:cluster_list', }), ('virtualmachine', { - 'permission': 'virtualization.view_virtualmachine', 'queryset': VirtualMachine.objects.prefetch_related( 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', ), @@ -136,35 +125,30 @@ SEARCH_TYPES = OrderedDict(( }), # IPAM ('vrf', { - 'permission': 'ipam.view_vrf', 'queryset': VRF.objects.prefetch_related('tenant'), 'filterset': VRFFilterSet, 'table': VRFTable, 'url': 'ipam:vrf_list', }), ('aggregate', { - 'permission': 'ipam.view_aggregate', 'queryset': Aggregate.objects.prefetch_related('rir'), 'filterset': AggregateFilterSet, 'table': AggregateTable, 'url': 'ipam:aggregate_list', }), ('prefix', { - 'permission': 'ipam.view_prefix', 'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'), 'filterset': PrefixFilterSet, 'table': PrefixTable, 'url': 'ipam:prefix_list', }), ('ipaddress', { - 'permission': 'ipam.view_ipaddress', 'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'), 'filterset': IPAddressFilterSet, 'table': IPAddressTable, 'url': 'ipam:ipaddress_list', }), ('vlan', { - 'permission': 'ipam.view_vlan', 'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role'), 'filterset': VLANFilterSet, 'table': VLANTable, @@ -172,7 +156,6 @@ SEARCH_TYPES = OrderedDict(( }), # Secrets ('secret', { - 'permission': 'secrets.view_secret', 'queryset': Secret.objects.prefetch_related('role', 'device'), 'filterset': SecretFilterSet, 'table': SecretTable, @@ -180,7 +163,6 @@ SEARCH_TYPES = OrderedDict(( }), # Tenancy ('tenant', { - 'permission': 'tenancy.view_tenant', 'queryset': Tenant.objects.prefetch_related('group'), 'filterset': TenantFilterSet, 'table': TenantTable, @@ -242,6 +224,8 @@ class HomeView(View): } + changelog = ObjectChange.objects.restrict(request.user, 'view').prefetch_related('user', 'changed_object_type') + # Check whether a new release is available. (Only for staff/superusers.) new_release = None if request.user.is_staff or request.user.is_superuser: @@ -258,7 +242,7 @@ class HomeView(View): 'search_form': SearchForm(), 'stats': stats, 'report_results': ReportResult.objects.order_by('-created')[:10], - 'changelog': ObjectChange.objects.prefetch_related('user', 'changed_object_type')[:15], + 'changelog': changelog[:15], 'new_release': new_release, }) @@ -278,17 +262,12 @@ class SearchView(View): if form.is_valid(): - # Searching for a single type of object - obj_types = [] if form.cleaned_data['obj_type']: - obj_type = form.cleaned_data['obj_type'] - if request.user.has_perm(SEARCH_TYPES[obj_type]['permission']): - obj_types.append(form.cleaned_data['obj_type']) - # Searching all object types + # Searching for a single type of object + obj_types = [form.cleaned_data['obj_type']] else: - for obj_type in SEARCH_TYPES.keys(): - if request.user.has_perm(SEARCH_TYPES[obj_type]['permission']): - obj_types.append(obj_type) + # Searching all object types + obj_types = SEARCH_TYPES.keys() for obj_type in obj_types: From fa0ff8be39caa8c0d73668efea32ff60ec5b34d4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 16 Jun 2020 13:06:03 -0400 Subject: [PATCH 156/505] Restrict ExportTemplates available via export button --- netbox/utilities/templatetags/buttons.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py index 85f75f79e..da40ce9d5 100644 --- a/netbox/utilities/templatetags/buttons.py +++ b/netbox/utilities/templatetags/buttons.py @@ -97,7 +97,8 @@ def import_button(url): @register.inclusion_tag('buttons/export.html', takes_context=True) def export_button(context, content_type=None): if content_type is not None: - export_templates = ExportTemplate.objects.filter(content_type=content_type) + user = context['request'].user + export_templates = ExportTemplate.objects.restrict(user, 'view').filter(content_type=content_type) else: export_templates = [] From ed0b38c7a7cdbcbc843582f640a59cecd1a601fc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 16 Jun 2020 13:15:58 -0400 Subject: [PATCH 157/505] Bypass restriction for querysets used for filtering by related object --- netbox/circuits/filters.py | 30 +++--- netbox/dcim/filters.py | 154 +++++++++++++++---------------- netbox/extras/filters.py | 32 +++---- netbox/ipam/filters.py | 66 ++++++------- netbox/secrets/filters.py | 8 +- netbox/tenancy/filters.py | 16 ++-- netbox/utilities/filters.py | 2 +- netbox/virtualization/filters.py | 46 ++++----- 8 files changed, 177 insertions(+), 177 deletions(-) diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index 206dcc305..a81d6acca 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -24,13 +24,13 @@ class ProviderFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilte label='Search', ) region_id = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='circuits__terminations__site__region', lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='circuits__terminations__site__region', lookup_expr='in', to_field_name='slug', @@ -38,12 +38,12 @@ class ProviderFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilte ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='circuits__terminations__site', - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), label='Site', ) site = django_filters.ModelMultipleChoiceFilter( field_name='circuits__terminations__site__slug', - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), to_field_name='slug', label='Site (slug)', ) @@ -78,22 +78,22 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldFilterSet, TenancyFilterSet, Cr label='Search', ) provider_id = django_filters.ModelMultipleChoiceFilter( - queryset=Provider.objects.all(), + queryset=Provider.objects.unrestricted(), label='Provider (ID)', ) provider = django_filters.ModelMultipleChoiceFilter( field_name='provider__slug', - queryset=Provider.objects.all(), + queryset=Provider.objects.unrestricted(), to_field_name='slug', label='Provider (slug)', ) type_id = django_filters.ModelMultipleChoiceFilter( - queryset=CircuitType.objects.all(), + queryset=CircuitType.objects.unrestricted(), label='Circuit type (ID)', ) type = django_filters.ModelMultipleChoiceFilter( field_name='type__slug', - queryset=CircuitType.objects.all(), + queryset=CircuitType.objects.unrestricted(), to_field_name='slug', label='Circuit type (slug)', ) @@ -103,23 +103,23 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldFilterSet, TenancyFilterSet, Cr ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='terminations__site', - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( field_name='terminations__site__slug', - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), to_field_name='slug', label='Site (slug)', ) region_id = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='terminations__site__region', lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='terminations__site__region', lookup_expr='in', to_field_name='slug', @@ -150,16 +150,16 @@ class CircuitTerminationFilterSet(BaseFilterSet): label='Search', ) circuit_id = django_filters.ModelMultipleChoiceFilter( - queryset=Circuit.objects.all(), + queryset=Circuit.objects.unrestricted(), label='Circuit', ) site_id = django_filters.ModelMultipleChoiceFilter( - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( field_name='site__slug', - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), to_field_name='slug', label='Site (slug)', ) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 92f7f8241..d22511ede 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -62,12 +62,12 @@ __all__ = ( class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), label='Parent region (ID)', ) parent = django_filters.ModelMultipleChoiceFilter( field_name='parent__slug', - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), to_field_name='slug', label='Parent region (slug)', ) @@ -87,13 +87,13 @@ class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Creat null_value=None ) region_id = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='region', lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='region', lookup_expr='in', to_field_name='slug', @@ -131,35 +131,35 @@ class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Creat class RackGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): region_id = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='site__region', lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='site__region', lookup_expr='in', to_field_name='slug', label='Region (slug)', ) site_id = django_filters.ModelMultipleChoiceFilter( - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( field_name='site__slug', - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), to_field_name='slug', label='Site (slug)', ) parent_id = django_filters.ModelMultipleChoiceFilter( - queryset=RackGroup.objects.all(), + queryset=RackGroup.objects.unrestricted(), label='Rack group (ID)', ) parent = django_filters.ModelMultipleChoiceFilter( field_name='parent__slug', - queryset=RackGroup.objects.all(), + queryset=RackGroup.objects.unrestricted(), to_field_name='slug', label='Rack group (slug)', ) @@ -182,36 +182,36 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Creat label='Search', ) region_id = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='site__region', lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='site__region', lookup_expr='in', to_field_name='slug', label='Region (slug)', ) site_id = django_filters.ModelMultipleChoiceFilter( - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( field_name='site__slug', - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), to_field_name='slug', label='Site (slug)', ) group_id = TreeNodeMultipleChoiceFilter( - queryset=RackGroup.objects.all(), + queryset=RackGroup.objects.unrestricted(), field_name='group', lookup_expr='in', label='Rack group (ID)', ) group = TreeNodeMultipleChoiceFilter( - queryset=RackGroup.objects.all(), + queryset=RackGroup.objects.unrestricted(), field_name='group', lookup_expr='in', to_field_name='slug', @@ -222,12 +222,12 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Creat null_value=None ) role_id = django_filters.ModelMultipleChoiceFilter( - queryset=RackRole.objects.all(), + queryset=RackRole.objects.unrestricted(), label='Role (ID)', ) role = django_filters.ModelMultipleChoiceFilter( field_name='role__slug', - queryset=RackRole.objects.all(), + queryset=RackRole.objects.unrestricted(), to_field_name='slug', label='Role (slug)', ) @@ -261,28 +261,28 @@ class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet): label='Search', ) rack_id = django_filters.ModelMultipleChoiceFilter( - queryset=Rack.objects.all(), + queryset=Rack.objects.unrestricted(), label='Rack (ID)', ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='rack__site', - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( field_name='rack__site__slug', - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), to_field_name='slug', label='Site (slug)', ) group_id = TreeNodeMultipleChoiceFilter( - queryset=RackGroup.objects.all(), + queryset=RackGroup.objects.unrestricted(), field_name='rack__group', lookup_expr='in', label='Rack group (ID)', ) group = TreeNodeMultipleChoiceFilter( - queryset=RackGroup.objects.all(), + queryset=RackGroup.objects.unrestricted(), field_name='rack__group', lookup_expr='in', to_field_name='slug', @@ -328,12 +328,12 @@ class DeviceTypeFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFil label='Search', ) manufacturer_id = django_filters.ModelMultipleChoiceFilter( - queryset=Manufacturer.objects.all(), + queryset=Manufacturer.objects.unrestricted(), label='Manufacturer (ID)', ) manufacturer = django_filters.ModelMultipleChoiceFilter( field_name='manufacturer__slug', - queryset=Manufacturer.objects.all(), + queryset=Manufacturer.objects.unrestricted(), to_field_name='slug', label='Manufacturer (slug)', ) @@ -410,7 +410,7 @@ class DeviceTypeFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFil class DeviceTypeComponentFilterSet(NameSlugSearchFilterSet): devicetype_id = django_filters.ModelMultipleChoiceFilter( - queryset=DeviceType.objects.all(), + queryset=DeviceType.objects.unrestricted(), field_name='device_type_id', label='Device type (ID)', ) @@ -482,12 +482,12 @@ class DeviceRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class PlatformFilterSet(BaseFilterSet, NameSlugSearchFilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( field_name='manufacturer', - queryset=Manufacturer.objects.all(), + queryset=Manufacturer.objects.unrestricted(), label='Manufacturer (ID)', ) manufacturer = django_filters.ModelMultipleChoiceFilter( field_name='manufacturer__slug', - queryset=Manufacturer.objects.all(), + queryset=Manufacturer.objects.unrestricted(), to_field_name='slug', label='Manufacturer (slug)', ) @@ -510,81 +510,81 @@ class DeviceFilterSet( ) manufacturer_id = django_filters.ModelMultipleChoiceFilter( field_name='device_type__manufacturer', - queryset=Manufacturer.objects.all(), + queryset=Manufacturer.objects.unrestricted(), label='Manufacturer (ID)', ) manufacturer = django_filters.ModelMultipleChoiceFilter( field_name='device_type__manufacturer__slug', - queryset=Manufacturer.objects.all(), + queryset=Manufacturer.objects.unrestricted(), to_field_name='slug', label='Manufacturer (slug)', ) device_type_id = django_filters.ModelMultipleChoiceFilter( - queryset=DeviceType.objects.all(), + queryset=DeviceType.objects.unrestricted(), label='Device type (ID)', ) role_id = django_filters.ModelMultipleChoiceFilter( field_name='device_role_id', - queryset=DeviceRole.objects.all(), + queryset=DeviceRole.objects.unrestricted(), label='Role (ID)', ) role = django_filters.ModelMultipleChoiceFilter( field_name='device_role__slug', - queryset=DeviceRole.objects.all(), + queryset=DeviceRole.objects.unrestricted(), to_field_name='slug', label='Role (slug)', ) platform_id = django_filters.ModelMultipleChoiceFilter( - queryset=Platform.objects.all(), + queryset=Platform.objects.unrestricted(), label='Platform (ID)', ) platform = django_filters.ModelMultipleChoiceFilter( field_name='platform__slug', - queryset=Platform.objects.all(), + queryset=Platform.objects.unrestricted(), to_field_name='slug', label='Platform (slug)', ) region_id = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='site__region', lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='site__region', lookup_expr='in', to_field_name='slug', label='Region (slug)', ) site_id = django_filters.ModelMultipleChoiceFilter( - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( field_name='site__slug', - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), to_field_name='slug', label='Site name (slug)', ) rack_group_id = TreeNodeMultipleChoiceFilter( - queryset=RackGroup.objects.all(), + queryset=RackGroup.objects.unrestricted(), field_name='rack__group', lookup_expr='in', label='Rack group (ID)', ) rack_id = django_filters.ModelMultipleChoiceFilter( field_name='rack', - queryset=Rack.objects.all(), + queryset=Rack.objects.unrestricted(), label='Rack (ID)', ) cluster_id = django_filters.ModelMultipleChoiceFilter( - queryset=Cluster.objects.all(), + queryset=Cluster.objects.unrestricted(), label='VM cluster (ID)', ) model = django_filters.ModelMultipleChoiceFilter( field_name='device_type__slug', - queryset=DeviceType.objects.all(), + queryset=DeviceType.objects.unrestricted(), to_field_name='slug', label='Device model (slug)', ) @@ -609,7 +609,7 @@ class DeviceFilterSet( ) virtual_chassis_id = django_filters.ModelMultipleChoiceFilter( field_name='virtual_chassis', - queryset=VirtualChassis.objects.all(), + queryset=VirtualChassis.objects.unrestricted(), label='Virtual chassis (ID)', ) virtual_chassis_member = django_filters.BooleanFilter( @@ -707,13 +707,13 @@ class DeviceComponentFilterSet(django_filters.FilterSet): label='Search', ) region_id = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='device__site__region', lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='device__site__region', lookup_expr='in', to_field_name='slug', @@ -721,22 +721,22 @@ class DeviceComponentFilterSet(django_filters.FilterSet): ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='device__site', - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( field_name='device__site__slug', - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), to_field_name='slug', label='Site name (slug)', ) device_id = django_filters.ModelMultipleChoiceFilter( - queryset=Device.objects.all(), + queryset=Device.objects.unrestricted(), label='Device (ID)', ) device = django_filters.ModelMultipleChoiceFilter( field_name='device__name', - queryset=Device.objects.all(), + queryset=Device.objects.unrestricted(), to_field_name='name', label='Device (name)', ) @@ -843,7 +843,7 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet): ) lag_id = django_filters.ModelMultipleChoiceFilter( field_name='lag', - queryset=Interface.objects.all(), + queryset=Interface.objects.unrestricted(), label='LAG interface (ID)', ) mac_address = MultiValueMACAddressFilter() @@ -950,13 +950,13 @@ class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet): label='Search', ) region_id = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='device__site__region', lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='device__site__region', lookup_expr='in', to_field_name='slug', @@ -964,35 +964,35 @@ class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet): ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='device__site', - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( field_name='device__site__slug', - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), to_field_name='slug', label='Site name (slug)', ) device_id = django_filters.ModelChoiceFilter( - queryset=Device.objects.all(), + queryset=Device.objects.unrestricted(), label='Device (ID)', ) device = django_filters.ModelChoiceFilter( - queryset=Device.objects.all(), + queryset=Device.objects.unrestricted(), to_field_name='name', label='Device (name)', ) parent_id = django_filters.ModelMultipleChoiceFilter( - queryset=InventoryItem.objects.all(), + queryset=InventoryItem.objects.unrestricted(), label='Parent inventory item (ID)', ) manufacturer_id = django_filters.ModelMultipleChoiceFilter( - queryset=Manufacturer.objects.all(), + queryset=Manufacturer.objects.unrestricted(), label='Manufacturer (ID)', ) manufacturer = django_filters.ModelMultipleChoiceFilter( field_name='manufacturer__slug', - queryset=Manufacturer.objects.all(), + queryset=Manufacturer.objects.unrestricted(), to_field_name='slug', label='Manufacturer (slug)', ) @@ -1023,13 +1023,13 @@ class VirtualChassisFilterSet(BaseFilterSet): label='Search', ) region_id = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='master__site__region', lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='master__site__region', lookup_expr='in', to_field_name='slug', @@ -1037,23 +1037,23 @@ class VirtualChassisFilterSet(BaseFilterSet): ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='master__site', - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( field_name='master__site__slug', - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), to_field_name='slug', label='Site name (slug)', ) tenant_id = django_filters.ModelMultipleChoiceFilter( field_name='master__tenant', - queryset=Tenant.objects.all(), + queryset=Tenant.objects.unrestricted(), label='Tenant (ID)', ) tenant = django_filters.ModelMultipleChoiceFilter( field_name='master__tenant__slug', - queryset=Tenant.objects.all(), + queryset=Tenant.objects.unrestricted(), to_field_name='slug', label='Tenant (slug)', ) @@ -1239,30 +1239,30 @@ class PowerPanelFilterSet(BaseFilterSet): label='Search', ) region_id = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='site__region', lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='site__region', lookup_expr='in', to_field_name='slug', label='Region (slug)', ) site_id = django_filters.ModelMultipleChoiceFilter( - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( field_name='site__slug', - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), to_field_name='slug', label='Site name (slug)', ) rack_group_id = TreeNodeMultipleChoiceFilter( - queryset=RackGroup.objects.all(), + queryset=RackGroup.objects.unrestricted(), field_name='rack_group', lookup_expr='in', label='Rack group (ID)', @@ -1288,13 +1288,13 @@ class PowerFeedFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilt label='Search', ) region_id = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='power_panel__site__region', lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='power_panel__site__region', lookup_expr='in', to_field_name='slug', @@ -1302,22 +1302,22 @@ class PowerFeedFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilt ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='power_panel__site', - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( field_name='power_panel__site__slug', - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), to_field_name='slug', label='Site name (slug)', ) power_panel_id = django_filters.ModelMultipleChoiceFilter( - queryset=PowerPanel.objects.all(), + queryset=PowerPanel.objects.unrestricted(), label='Power panel (ID)', ) rack_id = django_filters.ModelMultipleChoiceFilter( field_name='rack', - queryset=Rack.objects.all(), + queryset=Rack.objects.unrestricted(), label='Rack (ID)', ) tag = TagFilter() diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 7ccdb1d86..46db6d032 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -130,89 +130,89 @@ class ConfigContextFilterSet(BaseFilterSet): ) region_id = django_filters.ModelMultipleChoiceFilter( field_name='regions', - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), label='Region', ) region = django_filters.ModelMultipleChoiceFilter( field_name='regions__slug', - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), to_field_name='slug', label='Region (slug)', ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='sites', - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), label='Site', ) site = django_filters.ModelMultipleChoiceFilter( field_name='sites__slug', - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), to_field_name='slug', label='Site (slug)', ) role_id = django_filters.ModelMultipleChoiceFilter( field_name='roles', - queryset=DeviceRole.objects.all(), + queryset=DeviceRole.objects.unrestricted(), label='Role', ) role = django_filters.ModelMultipleChoiceFilter( field_name='roles__slug', - queryset=DeviceRole.objects.all(), + queryset=DeviceRole.objects.unrestricted(), to_field_name='slug', label='Role (slug)', ) platform_id = django_filters.ModelMultipleChoiceFilter( field_name='platforms', - queryset=Platform.objects.all(), + queryset=Platform.objects.unrestricted(), label='Platform', ) platform = django_filters.ModelMultipleChoiceFilter( field_name='platforms__slug', - queryset=Platform.objects.all(), + queryset=Platform.objects.unrestricted(), to_field_name='slug', label='Platform (slug)', ) cluster_group_id = django_filters.ModelMultipleChoiceFilter( field_name='cluster_groups', - queryset=ClusterGroup.objects.all(), + queryset=ClusterGroup.objects.unrestricted(), label='Cluster group', ) cluster_group = django_filters.ModelMultipleChoiceFilter( field_name='cluster_groups__slug', - queryset=ClusterGroup.objects.all(), + queryset=ClusterGroup.objects.unrestricted(), to_field_name='slug', label='Cluster group (slug)', ) cluster_id = django_filters.ModelMultipleChoiceFilter( field_name='clusters', - queryset=Cluster.objects.all(), + queryset=Cluster.objects.unrestricted(), label='Cluster', ) tenant_group_id = django_filters.ModelMultipleChoiceFilter( field_name='tenant_groups', - queryset=TenantGroup.objects.all(), + queryset=TenantGroup.objects.unrestricted(), label='Tenant group', ) tenant_group = django_filters.ModelMultipleChoiceFilter( field_name='tenant_groups__slug', - queryset=TenantGroup.objects.all(), + queryset=TenantGroup.objects.unrestricted(), to_field_name='slug', label='Tenant group (slug)', ) tenant_id = django_filters.ModelMultipleChoiceFilter( field_name='tenants', - queryset=Tenant.objects.all(), + queryset=Tenant.objects.unrestricted(), label='Tenant', ) tenant = django_filters.ModelMultipleChoiceFilter( field_name='tenants__slug', - queryset=Tenant.objects.all(), + queryset=Tenant.objects.unrestricted(), to_field_name='slug', label='Tenant (slug)', ) tag = django_filters.ModelMultipleChoiceFilter( field_name='tags__slug', - queryset=Tag.objects.all(), + queryset=Tag.objects.unrestricted(), to_field_name='slug', label='Tag (slug)', ) diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 6641d1c8b..7662d5825 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -71,12 +71,12 @@ class AggregateFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilt label='Prefix', ) rir_id = django_filters.ModelMultipleChoiceFilter( - queryset=RIR.objects.all(), + queryset=RIR.objects.unrestricted(), label='RIR (ID)', ) rir = django_filters.ModelMultipleChoiceFilter( field_name='rir__slug', - queryset=RIR.objects.all(), + queryset=RIR.objects.unrestricted(), to_field_name='slug', label='RIR (slug)', ) @@ -148,40 +148,40 @@ class PrefixFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Cre label='Mask length', ) vrf_id = django_filters.ModelMultipleChoiceFilter( - queryset=VRF.objects.all(), + queryset=VRF.objects.unrestricted(), label='VRF', ) vrf = django_filters.ModelMultipleChoiceFilter( field_name='vrf__rd', - queryset=VRF.objects.all(), + queryset=VRF.objects.unrestricted(), to_field_name='rd', label='VRF (RD)', ) region_id = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='site__region', lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='site__region', lookup_expr='in', to_field_name='slug', label='Region (slug)', ) site_id = django_filters.ModelMultipleChoiceFilter( - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( field_name='site__slug', - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), to_field_name='slug', label='Site (slug)', ) vlan_id = django_filters.ModelMultipleChoiceFilter( - queryset=VLAN.objects.all(), + queryset=VLAN.objects.unrestricted(), label='VLAN (ID)', ) vlan_vid = django_filters.NumberFilter( @@ -189,12 +189,12 @@ class PrefixFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Cre label='VLAN number (1-4095)', ) role_id = django_filters.ModelMultipleChoiceFilter( - queryset=Role.objects.all(), + queryset=Role.objects.unrestricted(), label='Role (ID)', ) role = django_filters.ModelMultipleChoiceFilter( field_name='role__slug', - queryset=Role.objects.all(), + queryset=Role.objects.unrestricted(), to_field_name='slug', label='Role (slug)', ) @@ -290,12 +290,12 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, label='Mask length', ) vrf_id = django_filters.ModelMultipleChoiceFilter( - queryset=VRF.objects.all(), + queryset=VRF.objects.unrestricted(), label='VRF', ) vrf = django_filters.ModelMultipleChoiceFilter( field_name='vrf__rd', - queryset=VRF.objects.all(), + queryset=VRF.objects.unrestricted(), to_field_name='rd', label='VRF (RD)', ) @@ -311,23 +311,23 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, ) virtual_machine_id = django_filters.ModelMultipleChoiceFilter( field_name='interface__virtual_machine', - queryset=VirtualMachine.objects.all(), + queryset=VirtualMachine.objects.unrestricted(), label='Virtual machine (ID)', ) virtual_machine = django_filters.ModelMultipleChoiceFilter( field_name='interface__virtual_machine__name', - queryset=VirtualMachine.objects.all(), + queryset=VirtualMachine.objects.unrestricted(), to_field_name='name', label='Virtual machine (name)', ) interface = django_filters.ModelMultipleChoiceFilter( field_name='interface__name', - queryset=Interface.objects.all(), + queryset=Interface.objects.unrestricted(), to_field_name='name', label='Interface (ID)', ) interface_id = django_filters.ModelMultipleChoiceFilter( - queryset=Interface.objects.all(), + queryset=Interface.objects.unrestricted(), label='Interface (ID)', ) assigned_to_interface = django_filters.BooleanFilter( @@ -394,25 +394,25 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): region_id = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='site__region', lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='site__region', lookup_expr='in', to_field_name='slug', label='Region (slug)', ) site_id = django_filters.ModelMultipleChoiceFilter( - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( field_name='site__slug', - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), to_field_name='slug', label='Site (slug)', ) @@ -428,45 +428,45 @@ class VLANFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Creat label='Search', ) region_id = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='site__region', lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='site__region', lookup_expr='in', to_field_name='slug', label='Region (slug)', ) site_id = django_filters.ModelMultipleChoiceFilter( - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( field_name='site__slug', - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), to_field_name='slug', label='Site (slug)', ) group_id = django_filters.ModelMultipleChoiceFilter( - queryset=VLANGroup.objects.all(), + queryset=VLANGroup.objects.unrestricted(), label='Group (ID)', ) group = django_filters.ModelMultipleChoiceFilter( field_name='group__slug', - queryset=VLANGroup.objects.all(), + queryset=VLANGroup.objects.unrestricted(), to_field_name='slug', label='Group', ) role_id = django_filters.ModelMultipleChoiceFilter( - queryset=Role.objects.all(), + queryset=Role.objects.unrestricted(), label='Role (ID)', ) role = django_filters.ModelMultipleChoiceFilter( field_name='role__slug', - queryset=Role.objects.all(), + queryset=Role.objects.unrestricted(), to_field_name='slug', label='Role (slug)', ) @@ -497,22 +497,22 @@ class ServiceFilterSet(BaseFilterSet, CreatedUpdatedFilterSet): label='Search', ) device_id = django_filters.ModelMultipleChoiceFilter( - queryset=Device.objects.all(), + queryset=Device.objects.unrestricted(), label='Device (ID)', ) device = django_filters.ModelMultipleChoiceFilter( field_name='device__name', - queryset=Device.objects.all(), + queryset=Device.objects.unrestricted(), to_field_name='name', label='Device (name)', ) virtual_machine_id = django_filters.ModelMultipleChoiceFilter( - queryset=VirtualMachine.objects.all(), + queryset=VirtualMachine.objects.unrestricted(), label='Virtual machine (ID)', ) virtual_machine = django_filters.ModelMultipleChoiceFilter( field_name='virtual_machine__name', - queryset=VirtualMachine.objects.all(), + queryset=VirtualMachine.objects.unrestricted(), to_field_name='name', label='Virtual machine (name)', ) diff --git a/netbox/secrets/filters.py b/netbox/secrets/filters.py index 78f25952a..fee9b4981 100644 --- a/netbox/secrets/filters.py +++ b/netbox/secrets/filters.py @@ -26,22 +26,22 @@ class SecretFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterS label='Search', ) role_id = django_filters.ModelMultipleChoiceFilter( - queryset=SecretRole.objects.all(), + queryset=SecretRole.objects.unrestricted(), label='Role (ID)', ) role = django_filters.ModelMultipleChoiceFilter( field_name='role__slug', - queryset=SecretRole.objects.all(), + queryset=SecretRole.objects.unrestricted(), to_field_name='slug', label='Role (slug)', ) device_id = django_filters.ModelMultipleChoiceFilter( - queryset=Device.objects.all(), + queryset=Device.objects.unrestricted(), label='Device (ID)', ) device = django_filters.ModelMultipleChoiceFilter( field_name='device__name', - queryset=Device.objects.all(), + queryset=Device.objects.unrestricted(), to_field_name='name', label='Device (name)', ) diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py index af5ee0b2c..42137d7ca 100644 --- a/netbox/tenancy/filters.py +++ b/netbox/tenancy/filters.py @@ -15,12 +15,12 @@ __all__ = ( class TenantGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( - queryset=TenantGroup.objects.all(), + queryset=TenantGroup.objects.unrestricted(), label='Tenant group (ID)', ) parent = django_filters.ModelMultipleChoiceFilter( field_name='parent__slug', - queryset=TenantGroup.objects.all(), + queryset=TenantGroup.objects.unrestricted(), to_field_name='slug', label='Tenant group group (slug)', ) @@ -36,13 +36,13 @@ class TenantFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterS label='Search', ) group_id = TreeNodeMultipleChoiceFilter( - queryset=TenantGroup.objects.all(), + queryset=TenantGroup.objects.unrestricted(), field_name='group', lookup_expr='in', label='Tenant group (ID)', ) group = TreeNodeMultipleChoiceFilter( - queryset=TenantGroup.objects.all(), + queryset=TenantGroup.objects.unrestricted(), field_name='group', lookup_expr='in', to_field_name='slug', @@ -70,24 +70,24 @@ class TenancyFilterSet(django_filters.FilterSet): An inheritable FilterSet for models which support Tenant assignment. """ tenant_group_id = TreeNodeMultipleChoiceFilter( - queryset=TenantGroup.objects.all(), + queryset=TenantGroup.objects.unrestricted(), field_name='tenant__group', lookup_expr='in', label='Tenant Group (ID)', ) tenant_group = TreeNodeMultipleChoiceFilter( - queryset=TenantGroup.objects.all(), + queryset=TenantGroup.objects.unrestricted(), field_name='tenant__group', to_field_name='slug', lookup_expr='in', label='Tenant Group (slug)', ) tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), + queryset=Tenant.objects.unrestricted(), label='Tenant (ID)', ) tenant = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), + queryset=Tenant.objects.unrestricted(), field_name='tenant__slug', to_field_name='slug', label='Tenant (slug)', diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index f628ca917..b13c55f40 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -102,7 +102,7 @@ class TagFilter(django_filters.ModelMultipleChoiceFilter): kwargs.setdefault('field_name', 'tags__slug') kwargs.setdefault('to_field_name', 'slug') kwargs.setdefault('conjoined', True) - kwargs.setdefault('queryset', Tag.objects.all()) + kwargs.setdefault('queryset', Tag.objects.unrestricted()) super().__init__(*args, **kwargs) diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index a54b6ab28..7e8349cf1 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -40,45 +40,45 @@ class ClusterFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Cr label='Search', ) region_id = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='site__region', lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='site__region', lookup_expr='in', to_field_name='slug', label='Region (slug)', ) site_id = django_filters.ModelMultipleChoiceFilter( - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( field_name='site__slug', - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), to_field_name='slug', label='Site (slug)', ) group_id = django_filters.ModelMultipleChoiceFilter( - queryset=ClusterGroup.objects.all(), + queryset=ClusterGroup.objects.unrestricted(), label='Parent group (ID)', ) group = django_filters.ModelMultipleChoiceFilter( field_name='group__slug', - queryset=ClusterGroup.objects.all(), + queryset=ClusterGroup.objects.unrestricted(), to_field_name='slug', label='Parent group (slug)', ) type_id = django_filters.ModelMultipleChoiceFilter( - queryset=ClusterType.objects.all(), + queryset=ClusterType.objects.unrestricted(), label='Cluster type (ID)', ) type = django_filters.ModelMultipleChoiceFilter( field_name='type__slug', - queryset=ClusterType.objects.all(), + queryset=ClusterType.objects.unrestricted(), to_field_name='slug', label='Cluster type (slug)', ) @@ -114,38 +114,38 @@ class VirtualMachineFilterSet( ) cluster_group_id = django_filters.ModelMultipleChoiceFilter( field_name='cluster__group', - queryset=ClusterGroup.objects.all(), + queryset=ClusterGroup.objects.unrestricted(), label='Cluster group (ID)', ) cluster_group = django_filters.ModelMultipleChoiceFilter( field_name='cluster__group__slug', - queryset=ClusterGroup.objects.all(), + queryset=ClusterGroup.objects.unrestricted(), to_field_name='slug', label='Cluster group (slug)', ) cluster_type_id = django_filters.ModelMultipleChoiceFilter( field_name='cluster__type', - queryset=ClusterType.objects.all(), + queryset=ClusterType.objects.unrestricted(), label='Cluster type (ID)', ) cluster_type = django_filters.ModelMultipleChoiceFilter( field_name='cluster__type__slug', - queryset=ClusterType.objects.all(), + queryset=ClusterType.objects.unrestricted(), to_field_name='slug', label='Cluster type (slug)', ) cluster_id = django_filters.ModelMultipleChoiceFilter( - queryset=Cluster.objects.all(), + queryset=Cluster.objects.unrestricted(), label='Cluster (ID)', ) region_id = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='cluster__site__region', lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), + queryset=Region.objects.unrestricted(), field_name='cluster__site__region', lookup_expr='in', to_field_name='slug', @@ -153,32 +153,32 @@ class VirtualMachineFilterSet( ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='cluster__site', - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( field_name='cluster__site__slug', - queryset=Site.objects.all(), + queryset=Site.objects.unrestricted(), to_field_name='slug', label='Site (slug)', ) role_id = django_filters.ModelMultipleChoiceFilter( - queryset=DeviceRole.objects.all(), + queryset=DeviceRole.objects.unrestricted(), label='Role (ID)', ) role = django_filters.ModelMultipleChoiceFilter( field_name='role__slug', - queryset=DeviceRole.objects.all(), + queryset=DeviceRole.objects.unrestricted(), to_field_name='slug', label='Role (slug)', ) platform_id = django_filters.ModelMultipleChoiceFilter( - queryset=Platform.objects.all(), + queryset=Platform.objects.unrestricted(), label='Platform (ID)', ) platform = django_filters.ModelMultipleChoiceFilter( field_name='platform__slug', - queryset=Platform.objects.all(), + queryset=Platform.objects.unrestricted(), to_field_name='slug', label='Platform (slug)', ) @@ -208,12 +208,12 @@ class InterfaceFilterSet(BaseFilterSet): ) virtual_machine_id = django_filters.ModelMultipleChoiceFilter( field_name='virtual_machine', - queryset=VirtualMachine.objects.all(), + queryset=VirtualMachine.objects.unrestricted(), label='Virtual machine (ID)', ) virtual_machine = django_filters.ModelMultipleChoiceFilter( field_name='virtual_machine__name', - queryset=VirtualMachine.objects.all(), + queryset=VirtualMachine.objects.unrestricted(), to_field_name='name', label='Virtual machine', ) From 0f8df8c985c3a22a25afbf32c16804b41f025854 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 16 Jun 2020 13:31:51 -0400 Subject: [PATCH 158/505] Add unrestricted() to Rack methods --- netbox/dcim/models/__init__.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index f7411ca56..236979b4a 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -695,7 +695,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel): return [u for u in elevation.values()] - def get_available_units(self, u_height=1, rack_face=None, exclude=list()): + def get_available_units(self, u_height=1, rack_face=None, exclude=None): """ Return a list of units within the rack available to accommodate a device of a given U height (default 1). Optionally exclude one or more devices when calculating empty units (needed when moving a device from one @@ -705,9 +705,10 @@ class Rack(ChangeLoggedModel, CustomFieldModel): :param rack_face: The face of the rack (front or rear) required; 'None' if device is full depth :param exclude: List of devices IDs to exclude (useful when moving a device within a rack) """ - # Gather all devices which consume U space within the rack - devices = self.devices.prefetch_related('device_type').filter(position__gte=1).exclude(pk__in=exclude) + devices = self.devices.unrestricted().prefetch_related('device_type').filter(position__gte=1) + if exclude is not None: + devices = devices.exclude(pk__in=exclude) # Initialize the rack unit skeleton units = list(range(1, self.u_height + 1)) @@ -735,7 +736,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel): Return a dictionary mapping all reserved units within the rack to their reservation. """ reserved_units = {} - for r in self.reservations.all(): + for r in self.reservations.unrestricted(): for u in r.units: reserved_units[u] = r return reserved_units @@ -789,7 +790,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel): """ Determine the utilization rate of power in the rack and return it as a percentage. """ - power_stats = PowerFeed.objects.filter( + power_stats = PowerFeed.objects.unrestricted().filter( rack=self ).annotate( allocated_draw_total=Sum('connected_endpoint__poweroutlets__connected_endpoint__allocated_draw'), From ffa3a229b505dcc68f1c195c991092461079eab0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 16 Jun 2020 14:00:43 -0400 Subject: [PATCH 159/505] Fix restriction violations for provider, circuit models --- netbox/circuits/views.py | 7 ++++++- netbox/templates/circuits/inc/circuit_termination.html | 2 +- netbox/templates/circuits/provider.html | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 5da912f0a..f100dd3c7 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -136,18 +136,23 @@ class CircuitView(ObjectView): queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant__group') def get(self, request, pk): - circuit = get_object_or_404(self.queryset, pk=pk) + termination_a = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related( 'site__region', 'connected_endpoint__device' ).filter( circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A ).first() + if termination_a and termination_a.connected_endpoint: + termination_a.ip_addresses = termination_a.connected_endpoint.ip_addresses.restrict(request.user, 'view') + termination_z = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related( 'site__region', 'connected_endpoint__device' ).filter( circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z ).first() + if termination_z and termination_z.connected_endpoint: + termination_z.ip_addresses = termination_z.connected_endpoint.ip_addresses.restrict(request.user, 'view') return render(request, 'circuits/circuit.html', { 'circuit': circuit, diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index 8db715711..30d875657 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -90,7 +90,7 @@ IP Addressing {% if termination.connected_endpoint %} - {% for ip in termination.connected_endpoint.ip_addresses.all %} + {% for ip in termination.ip_addresses %} {% if not forloop.first %}
    {% endif %} {{ ip }} ({{ ip.vrf|default:"Global" }}) {% empty %} diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index c02637e8e..42c322ce2 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -99,7 +99,7 @@ Circuits - {{ provider.circuits.count }} + {{ circuits_table.rows|length }} From 1e259f30439799b2e5aaaa5bce745b6b9742efb0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 16 Jun 2020 14:10:56 -0400 Subject: [PATCH 160/505] Allow unrestricted retrieval of MPTT ancestors --- netbox/templates/dcim/site.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index f5823f721..d6c21bf92 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -12,7 +12,7 @@