Procházet zdrojové kódy

Merge pull request #3066 from digitalocean/323-view-permissions

Closes #323
Jeremy Stretch před 7 roky
rodič
revize
573af6a236
42 změnil soubory, kde provedl 676 přidání a 404 odebrání
  1. 23 0
      CHANGELOG.md
  2. 24 0
      docs/configuration/optional-settings.md
  3. 10 6
      netbox/circuits/tests/test_views.py
  4. 10 5
      netbox/circuits/views.py
  5. 31 33
      netbox/dcim/tests/test_views.py
  6. 65 34
      netbox/dcim/views.py
  7. 5 5
      netbox/extras/tests/test_views.py
  8. 12 6
      netbox/extras/views.py
  9. 19 15
      netbox/ipam/tests/test_views.py
  10. 39 19
      netbox/ipam/views.py
  11. 15 0
      netbox/netbox/api.py
  12. 8 0
      netbox/netbox/configuration.example.py
  13. 7 2
      netbox/netbox/settings.py
  14. 7 12
      netbox/secrets/tests/test_views.py
  15. 7 6
      netbox/secrets/views.py
  16. 5 3
      netbox/templates/circuits/circuit.html
  17. 5 3
      netbox/templates/circuits/provider.html
  18. 5 3
      netbox/templates/dcim/cable.html
  19. 10 6
      netbox/templates/dcim/device.html
  20. 5 3
      netbox/templates/dcim/devicetype.html
  21. 5 3
      netbox/templates/dcim/interface.html
  22. 5 3
      netbox/templates/dcim/rack.html
  23. 5 3
      netbox/templates/dcim/site.html
  24. 5 3
      netbox/templates/extras/tag.html
  25. 167 129
      netbox/templates/home.html
  26. 42 42
      netbox/templates/inc/nav_menu.html
  27. 5 3
      netbox/templates/ipam/aggregate.html
  28. 5 3
      netbox/templates/ipam/ipaddress.html
  29. 10 6
      netbox/templates/ipam/prefix.html
  30. 5 3
      netbox/templates/ipam/vlan.html
  31. 5 3
      netbox/templates/ipam/vrf.html
  32. 5 3
      netbox/templates/secrets/secret.html
  33. 5 3
      netbox/templates/tenancy/tenant.html
  34. 5 3
      netbox/templates/virtualization/cluster.html
  35. 10 6
      netbox/templates/virtualization/virtualmachine.html
  36. 5 2
      netbox/tenancy/tests/test_views.py
  37. 6 3
      netbox/tenancy/views.py
  38. 4 10
      netbox/users/views.py
  39. 28 0
      netbox/utilities/auth_backends.py
  40. 14 1
      netbox/utilities/testing.py
  41. 9 4
      netbox/virtualization/tests/test_views.py
  42. 14 7
      netbox/virtualization/views.py

+ 23 - 0
CHANGELOG.md

@@ -16,8 +16,31 @@ NetBox now makes use of its own `Tag` model instead of the vanilla model which s
 lives in the `extras` app and thus any permissions that you may have configured using "Taggit | Tag" should be changed
 lives in the `extras` app and thus any permissions that you may have configured using "Taggit | Tag" should be changed
 to now use "Extras | Tag."
 to now use "Extras | Tag."
 
 
+### View Permissions
+
+Django 2.1 introduced the ability to enforce view-only permissions for different object types. NetBox now enforces
+these by default. You can grant view permission to a user or group by assigning the "can view" permission for the
+desired object(s).
+
+To exempt certain objects from the enforcement of view permissions, so that any user (including anonymous users) can
+view them, add them to the new `EXEMPT_VIEW_PERMISSIONS` setting in `configuration.py`:
+
+```
+EXEMPT_VIEW_PERMISSIONS = [
+    'dcim.site',
+    'ipam.prefix',
+]
+```
+
+To exclude _all_ objects, effectively disabling view permissions, set:
+
+```
+EXEMPT_VIEW_PERMISSIONS = ['*']
+```
+
 ## Enhancements
 ## Enhancements
 
 
+* [#323](https://github.com/digitalocean/netbox/issues/323) - Enforce per-object type view permissions
 * [#1792](https://github.com/digitalocean/netbox/issues/1792) - Add CustomFieldChoices API endpoint
 * [#1792](https://github.com/digitalocean/netbox/issues/1792) - Add CustomFieldChoices API endpoint
 * [#2324](https://github.com/digitalocean/netbox/issues/2324) - Add `color` option for tags
 * [#2324](https://github.com/digitalocean/netbox/issues/2324) - Add `color` option for tags
 * [#2643](https://github.com/digitalocean/netbox/issues/2643) - Add `description` field to console/power components and device bays
 * [#2643](https://github.com/digitalocean/netbox/issues/2643) - Add `description` field to console/power components and device bays

+ 24 - 0
docs/configuration/optional-settings.md

@@ -89,6 +89,30 @@ In order to send email, NetBox needs an email server configured. The following i
 
 
 ---
 ---
 
 
+## EXEMPT_VIEW_PERMISSIONS
+
+Default: Empty list
+
+A list of models to exempt from the enforcement of view permissions. Models listed here will be viewable by all users and by anonymous users.
+
+List models in the form `<app>.<model>`. For example:
+
+```
+EXEMPT_VIEW_PERMISSIONS = [
+    'dcim.site',
+    'dcim.region',
+    'ipam.prefix',
+]
+```
+
+To exempt _all_ models from view permission enforcement, set the following. (Note that `EXEMPT_VIEW_PERMISSIONS` must be an iterable.)
+
+```
+EXEMPT_VIEW_PERMISSIONS = ['*']
+```
+
+---
+
 # ENFORCE_GLOBAL_UNIQUE
 # ENFORCE_GLOBAL_UNIQUE
 
 
 Default: False
 Default: False

+ 10 - 6
netbox/circuits/tests/test_views.py

@@ -4,13 +4,15 @@ from django.test import Client, TestCase
 from django.urls import reverse
 from django.urls import reverse
 
 
 from circuits.models import Circuit, CircuitType, Provider
 from circuits.models import Circuit, CircuitType, Provider
+from utilities.testing import create_test_user
 
 
 
 
 class ProviderTestCase(TestCase):
 class ProviderTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['circuits.view_provider'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         Provider.objects.bulk_create([
         Provider.objects.bulk_create([
             Provider(name='Provider 1', slug='provider-1', asn=65001),
             Provider(name='Provider 1', slug='provider-1', asn=65001),
@@ -38,8 +40,9 @@ class ProviderTestCase(TestCase):
 class CircuitTypeTestCase(TestCase):
 class CircuitTypeTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['circuits.view_circuittype'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         CircuitType.objects.bulk_create([
         CircuitType.objects.bulk_create([
             CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
             CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
@@ -58,8 +61,9 @@ class CircuitTypeTestCase(TestCase):
 class CircuitTestCase(TestCase):
 class CircuitTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['circuits.view_circuit'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         provider = Provider(name='Provider 1', slug='provider-1', asn=65001)
         provider = Provider(name='Provider 1', slug='provider-1', asn=65001)
         provider.save()
         provider.save()
@@ -84,8 +88,8 @@ class CircuitTestCase(TestCase):
         response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
         response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
-    def test_provider(self):
+    def test_circuit(self):
 
 
-        provider = Provider.objects.first()
-        response = self.client.get(provider.get_absolute_url())
+        circuit = Circuit.objects.first()
+        response = self.client.get(circuit.get_absolute_url())
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)

+ 10 - 5
netbox/circuits/views.py

@@ -20,7 +20,8 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
 # Providers
 # Providers
 #
 #
 
 
-class ProviderListView(ObjectListView):
+class ProviderListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'circuits.view_provider'
     queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
     queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
     filter = filters.ProviderFilter
     filter = filters.ProviderFilter
     filter_form = forms.ProviderFilterForm
     filter_form = forms.ProviderFilterForm
@@ -28,7 +29,8 @@ class ProviderListView(ObjectListView):
     template_name = 'circuits/provider_list.html'
     template_name = 'circuits/provider_list.html'
 
 
 
 
-class ProviderView(View):
+class ProviderView(PermissionRequiredMixin, View):
+    permission_required = 'circuits.view_provider'
 
 
     def get(self, request, slug):
     def get(self, request, slug):
 
 
@@ -93,7 +95,8 @@ class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Circuit Types
 # Circuit Types
 #
 #
 
 
-class CircuitTypeListView(ObjectListView):
+class CircuitTypeListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'circuits.view_circuittype'
     queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
     queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
     table = tables.CircuitTypeTable
     table = tables.CircuitTypeTable
     template_name = 'circuits/circuittype_list.html'
     template_name = 'circuits/circuittype_list.html'
@@ -128,7 +131,8 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Circuits
 # Circuits
 #
 #
 
 
-class CircuitListView(ObjectListView):
+class CircuitListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'circuits.view_circuit'
     queryset = Circuit.objects.select_related(
     queryset = Circuit.objects.select_related(
         'provider', 'type', 'tenant'
         'provider', 'type', 'tenant'
     ).prefetch_related(
     ).prefetch_related(
@@ -140,7 +144,8 @@ class CircuitListView(ObjectListView):
     template_name = 'circuits/circuit_list.html'
     template_name = 'circuits/circuit_list.html'
 
 
 
 
-class CircuitView(View):
+class CircuitView(PermissionRequiredMixin, View):
+    permission_required = 'circuits.view_circuit'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 

+ 31 - 33
netbox/dcim/tests/test_views.py

@@ -1,6 +1,5 @@
 import urllib.parse
 import urllib.parse
 
 
-from django.contrib.auth import get_user_model
 from django.test import Client, TestCase
 from django.test import Client, TestCase
 from django.urls import reverse
 from django.urls import reverse
 
 
@@ -9,13 +8,15 @@ from dcim.models import (
     Cable, Device, DeviceRole, DeviceType, Interface, InventoryItem, Manufacturer, Platform, Rack, RackGroup,
     Cable, Device, DeviceRole, DeviceType, Interface, InventoryItem, Manufacturer, Platform, Rack, RackGroup,
     RackReservation, RackRole, Site, Region, VirtualChassis,
     RackReservation, RackRole, Site, Region, VirtualChassis,
 )
 )
+from utilities.testing import create_test_user
 
 
 
 
 class RegionTestCase(TestCase):
 class RegionTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_region'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         # Create three Regions
         # Create three Regions
         for i in range(1, 4):
         for i in range(1, 4):
@@ -32,8 +33,9 @@ class RegionTestCase(TestCase):
 class SiteTestCase(TestCase):
 class SiteTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_site'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         region = Region(name='Region 1', slug='region-1')
         region = Region(name='Region 1', slug='region-1')
         region.save()
         region.save()
@@ -64,8 +66,9 @@ class SiteTestCase(TestCase):
 class RackGroupTestCase(TestCase):
 class RackGroupTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_rackgroup'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         site = Site(name='Site 1', slug='site-1')
         site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()
@@ -84,11 +87,12 @@ class RackGroupTestCase(TestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
 
 
-class RackTypeTestCase(TestCase):
+class RackRoleTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_rackrole'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         RackRole.objects.bulk_create([
         RackRole.objects.bulk_create([
             RackRole(name='Rack Role 1', slug='rack-role-1'),
             RackRole(name='Rack Role 1', slug='rack-role-1'),
@@ -107,12 +111,9 @@ class RackTypeTestCase(TestCase):
 class RackReservationTestCase(TestCase):
 class RackReservationTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_rackreservation'])
         self.client = Client()
         self.client = Client()
-
-        User = get_user_model()
-        user = User(username='testuser', email='testuser@example.com')
-        user.save()
+        self.client.force_login(user)
 
 
         site = Site(name='Site 1', slug='site-1')
         site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()
@@ -137,8 +138,9 @@ class RackReservationTestCase(TestCase):
 class RackTestCase(TestCase):
 class RackTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_rack'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         site = Site(name='Site 1', slug='site-1')
         site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()
@@ -169,8 +171,9 @@ class RackTestCase(TestCase):
 class ManufacturerTypeTestCase(TestCase):
 class ManufacturerTypeTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_manufacturer'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         Manufacturer.objects.bulk_create([
         Manufacturer.objects.bulk_create([
             Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
             Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
@@ -189,8 +192,9 @@ class ManufacturerTypeTestCase(TestCase):
 class DeviceTypeTestCase(TestCase):
 class DeviceTypeTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_devicetype'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
         manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
         manufacturer.save()
         manufacturer.save()
@@ -221,8 +225,9 @@ class DeviceTypeTestCase(TestCase):
 class DeviceRoleTestCase(TestCase):
 class DeviceRoleTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_devicerole'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         DeviceRole.objects.bulk_create([
         DeviceRole.objects.bulk_create([
             DeviceRole(name='Device Role 1', slug='device-role-1'),
             DeviceRole(name='Device Role 1', slug='device-role-1'),
@@ -241,8 +246,9 @@ class DeviceRoleTestCase(TestCase):
 class PlatformTestCase(TestCase):
 class PlatformTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_platform'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         Platform.objects.bulk_create([
         Platform.objects.bulk_create([
             Platform(name='Platform 1', slug='platform-1'),
             Platform(name='Platform 1', slug='platform-1'),
@@ -261,8 +267,9 @@ class PlatformTestCase(TestCase):
 class DeviceTestCase(TestCase):
 class DeviceTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_device'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         site = Site(name='Site 1', slug='site-1')
         site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()
@@ -303,8 +310,9 @@ class DeviceTestCase(TestCase):
 class InventoryItemTestCase(TestCase):
 class InventoryItemTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_inventoryitem'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         site = Site(name='Site 1', slug='site-1')
         site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()
@@ -337,18 +345,13 @@ class InventoryItemTestCase(TestCase):
         response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
         response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
-    def test_inventoryitem(self):
-
-        inventoryitem = InventoryItem.objects.first()
-        response = self.client.get(inventoryitem.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
-
 
 
 class CableTestCase(TestCase):
 class CableTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_cable'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         site = Site(name='Site 1', slug='site-1')
         site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()
@@ -401,11 +404,12 @@ class CableTestCase(TestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
 
 
-class VirtualMachineTestCase(TestCase):
+class VirtualChassisTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_virtualchassis'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         site = Site.objects.create(name='Site 1', slug='site-1')
         site = Site.objects.create(name='Site 1', slug='site-1')
         manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer-1')
         manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer-1')
@@ -450,9 +454,3 @@ class VirtualMachineTestCase(TestCase):
 
 
         response = self.client.get(url)
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
-
-    def test_virtualchassis(self):
-
-        virtualchassis = VirtualChassis.objects.first()
-        response = self.client.get(virtualchassis.get_absolute_url())
-        self.assertEqual(response.status_code, 200)

+ 65 - 34
netbox/dcim/views.py

@@ -138,7 +138,8 @@ class BulkDisconnectView(GetReturnURLMixin, View):
 # Regions
 # Regions
 #
 #
 
 
-class RegionListView(ObjectListView):
+class RegionListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_region'
     queryset = Region.objects.add_related_count(
     queryset = Region.objects.add_related_count(
         Region.objects.all(),
         Region.objects.all(),
         Site,
         Site,
@@ -182,7 +183,8 @@ class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Sites
 # Sites
 #
 #
 
 
-class SiteListView(ObjectListView):
+class SiteListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_site'
     queryset = Site.objects.select_related('region', 'tenant')
     queryset = Site.objects.select_related('region', 'tenant')
     filter = filters.SiteFilter
     filter = filters.SiteFilter
     filter_form = forms.SiteFilterForm
     filter_form = forms.SiteFilterForm
@@ -190,7 +192,8 @@ class SiteListView(ObjectListView):
     template_name = 'dcim/site_list.html'
     template_name = 'dcim/site_list.html'
 
 
 
 
-class SiteView(View):
+class SiteView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.view_site'
 
 
     def get(self, request, slug):
     def get(self, request, slug):
 
 
@@ -254,7 +257,8 @@ class SiteBulkEditView(PermissionRequiredMixin, BulkEditView):
 # Rack groups
 # Rack groups
 #
 #
 
 
-class RackGroupListView(ObjectListView):
+class RackGroupListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_rackgroup'
     queryset = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks'))
     queryset = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks'))
     filter = filters.RackGroupFilter
     filter = filters.RackGroupFilter
     filter_form = forms.RackGroupFilterForm
     filter_form = forms.RackGroupFilterForm
@@ -292,7 +296,8 @@ class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Rack roles
 # Rack roles
 #
 #
 
 
-class RackRoleListView(ObjectListView):
+class RackRoleListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_rackrole'
     queryset = RackRole.objects.annotate(rack_count=Count('racks'))
     queryset = RackRole.objects.annotate(rack_count=Count('racks'))
     table = tables.RackRoleTable
     table = tables.RackRoleTable
     template_name = 'dcim/rackrole_list.html'
     template_name = 'dcim/rackrole_list.html'
@@ -327,7 +332,8 @@ class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Racks
 # Racks
 #
 #
 
 
-class RackListView(ObjectListView):
+class RackListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_rack'
     queryset = Rack.objects.select_related(
     queryset = Rack.objects.select_related(
         'site', 'group', 'tenant', 'role'
         'site', 'group', 'tenant', 'role'
     ).prefetch_related(
     ).prefetch_related(
@@ -341,10 +347,11 @@ class RackListView(ObjectListView):
     template_name = 'dcim/rack_list.html'
     template_name = 'dcim/rack_list.html'
 
 
 
 
-class RackElevationListView(View):
+class RackElevationListView(PermissionRequiredMixin, View):
     """
     """
     Display a set of rack elevations side-by-side.
     Display a set of rack elevations side-by-side.
     """
     """
+    permission_required = 'dcim.view_rack'
 
 
     def get(self, request):
     def get(self, request):
 
 
@@ -382,7 +389,8 @@ class RackElevationListView(View):
         })
         })
 
 
 
 
-class RackView(View):
+class RackView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.view_rack'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -454,7 +462,8 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Rack reservations
 # Rack reservations
 #
 #
 
 
-class RackReservationListView(ObjectListView):
+class RackReservationListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_rackreservation'
     queryset = RackReservation.objects.all()
     queryset = RackReservation.objects.all()
     filter = filters.RackReservationFilter
     filter = filters.RackReservationFilter
     filter_form = forms.RackReservationFilterForm
     filter_form = forms.RackReservationFilterForm
@@ -510,7 +519,8 @@ class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Manufacturers
 # Manufacturers
 #
 #
 
 
-class ManufacturerListView(ObjectListView):
+class ManufacturerListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_manufacturer'
     queryset = Manufacturer.objects.annotate(
     queryset = Manufacturer.objects.annotate(
         devicetype_count=Count('device_types', distinct=True),
         devicetype_count=Count('device_types', distinct=True),
         platform_count=Count('platforms', distinct=True),
         platform_count=Count('platforms', distinct=True),
@@ -548,7 +558,8 @@ class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Device types
 # Device types
 #
 #
 
 
-class DeviceTypeListView(ObjectListView):
+class DeviceTypeListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_devicetype'
     queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances'))
     queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances'))
     filter = filters.DeviceTypeFilter
     filter = filters.DeviceTypeFilter
     filter_form = forms.DeviceTypeFilterForm
     filter_form = forms.DeviceTypeFilterForm
@@ -556,7 +567,8 @@ class DeviceTypeListView(ObjectListView):
     template_name = 'dcim/devicetype_list.html'
     template_name = 'dcim/devicetype_list.html'
 
 
 
 
-class DeviceTypeView(View):
+class DeviceTypeView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.view_devicetype'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -812,7 +824,8 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Device roles
 # Device roles
 #
 #
 
 
-class DeviceRoleListView(ObjectListView):
+class DeviceRoleListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_devicerole'
     queryset = DeviceRole.objects.all()
     queryset = DeviceRole.objects.all()
     table = tables.DeviceRoleTable
     table = tables.DeviceRoleTable
     template_name = 'dcim/devicerole_list.html'
     template_name = 'dcim/devicerole_list.html'
@@ -847,7 +860,8 @@ class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Platforms
 # Platforms
 #
 #
 
 
-class PlatformListView(ObjectListView):
+class PlatformListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_platform'
     queryset = Platform.objects.all()
     queryset = Platform.objects.all()
     table = tables.PlatformTable
     table = tables.PlatformTable
     template_name = 'dcim/platform_list.html'
     template_name = 'dcim/platform_list.html'
@@ -882,7 +896,8 @@ class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Devices
 # Devices
 #
 #
 
 
-class DeviceListView(ObjectListView):
+class DeviceListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_device'
     queryset = Device.objects.select_related(
     queryset = Device.objects.select_related(
         'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6'
         'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6'
     )
     )
@@ -892,7 +907,8 @@ class DeviceListView(ObjectListView):
     template_name = 'dcim/device_list.html'
     template_name = 'dcim/device_list.html'
 
 
 
 
-class DeviceView(View):
+class DeviceView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.view_device'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -972,7 +988,8 @@ class DeviceView(View):
         })
         })
 
 
 
 
-class DeviceInventoryView(View):
+class DeviceInventoryView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.view_device'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -993,7 +1010,7 @@ class DeviceInventoryView(View):
 
 
 
 
 class DeviceStatusView(PermissionRequiredMixin, View):
 class DeviceStatusView(PermissionRequiredMixin, View):
-    permission_required = 'dcim.napalm_read'
+    permission_required = ('dcim.view_device', 'dcim.napalm_read')
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -1006,7 +1023,7 @@ class DeviceStatusView(PermissionRequiredMixin, View):
 
 
 
 
 class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
 class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
-    permission_required = 'dcim.napalm_read'
+    permission_required = ('dcim.view_device', 'dcim.napalm_read')
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -1023,7 +1040,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
 
 
 
 
 class DeviceConfigView(PermissionRequiredMixin, View):
 class DeviceConfigView(PermissionRequiredMixin, View):
-    permission_required = 'dcim.napalm_read'
+    permission_required = ('dcim.view_device', 'dcim.napalm_read')
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -1035,7 +1052,8 @@ class DeviceConfigView(PermissionRequiredMixin, View):
         })
         })
 
 
 
 
-class DeviceConfigContextView(ObjectConfigContextView):
+class DeviceConfigContextView(PermissionRequiredMixin, ObjectConfigContextView):
+    permission_required = 'dcim.view_device'
     object_class = Device
     object_class = Device
     base_template = 'dcim/device.html'
     base_template = 'dcim/device.html'
 
 
@@ -1258,7 +1276,8 @@ class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Interfaces
 # Interfaces
 #
 #
 
 
-class InterfaceView(View):
+class InterfaceView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.view_interface'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -1639,7 +1658,8 @@ class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateVie
 # Cables
 # Cables
 #
 #
 
 
-class CableListView(ObjectListView):
+class CableListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_cable'
     queryset = Cable.objects.prefetch_related(
     queryset = Cable.objects.prefetch_related(
         'termination_a', 'termination_b'
         'termination_a', 'termination_b'
     )
     )
@@ -1649,7 +1669,8 @@ class CableListView(ObjectListView):
     template_name = 'dcim/cable_list.html'
     template_name = 'dcim/cable_list.html'
 
 
 
 
-class CableView(View):
+class CableView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.view_cable'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -1660,10 +1681,11 @@ class CableView(View):
         })
         })
 
 
 
 
-class CableTraceView(View):
+class CableTraceView(PermissionRequiredMixin, View):
     """
     """
     Trace a cable path beginning from the given termination.
     Trace a cable path beginning from the given termination.
     """
     """
+    permission_required = 'dcim.view_cable'
 
 
     def get(self, request, model, pk):
     def get(self, request, model, pk):
 
 
@@ -1792,7 +1814,8 @@ class CableBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Connections
 # Connections
 #
 #
 
 
-class ConsoleConnectionsListView(ObjectListView):
+class ConsoleConnectionsListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = ('dcim.view_consoleport', 'dcim.view_consoleserverport')
     queryset = ConsolePort.objects.select_related(
     queryset = ConsolePort.objects.select_related(
         'device', 'connected_endpoint__device'
         'device', 'connected_endpoint__device'
     ).filter(
     ).filter(
@@ -1822,7 +1845,8 @@ class ConsoleConnectionsListView(ObjectListView):
         return csv_data
         return csv_data
 
 
 
 
-class PowerConnectionsListView(ObjectListView):
+class PowerConnectionsListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = ('dcim.view_powerport', 'dcim.view_poweroutlet')
     queryset = PowerPort.objects.select_related(
     queryset = PowerPort.objects.select_related(
         'device', '_connected_poweroutlet__device'
         'device', '_connected_poweroutlet__device'
     ).filter(
     ).filter(
@@ -1852,7 +1876,8 @@ class PowerConnectionsListView(ObjectListView):
         return csv_data
         return csv_data
 
 
 
 
-class InterfaceConnectionsListView(ObjectListView):
+class InterfaceConnectionsListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.interface'
     queryset = Interface.objects.select_related(
     queryset = Interface.objects.select_related(
         'device', 'cable', '_connected_interface__device'
         'device', 'cable', '_connected_interface__device'
     ).filter(
     ).filter(
@@ -1894,7 +1919,8 @@ class InterfaceConnectionsListView(ObjectListView):
 # Inventory items
 # Inventory items
 #
 #
 
 
-class InventoryItemListView(ObjectListView):
+class InventoryItemListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_inventoryitem'
     queryset = InventoryItem.objects.select_related('device', 'manufacturer')
     queryset = InventoryItem.objects.select_related('device', 'manufacturer')
     filter = filters.InventoryItemFilter
     filter = filters.InventoryItemFilter
     filter_form = forms.InventoryItemFilterForm
     filter_form = forms.InventoryItemFilterForm
@@ -1949,7 +1975,8 @@ class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Virtual chassis
 # Virtual chassis
 #
 #
 
 
-class VirtualChassisListView(ObjectListView):
+class VirtualChassisListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_virtualchassis'
     queryset = VirtualChassis.objects.select_related('master').annotate(member_count=Count('members'))
     queryset = VirtualChassis.objects.select_related('master').annotate(member_count=Count('members'))
     table = tables.VirtualChassisTable
     table = tables.VirtualChassisTable
     filter = filters.VirtualChassisFilter
     filter = filters.VirtualChassisFilter
@@ -2184,7 +2211,8 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin,
 # Power panels
 # Power panels
 #
 #
 
 
-class PowerPanelListView(ObjectListView):
+class PowerPanelListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_powerpanel'
     queryset = PowerPanel.objects.select_related(
     queryset = PowerPanel.objects.select_related(
         'site', 'rack_group'
         'site', 'rack_group'
     ).annotate(
     ).annotate(
@@ -2196,7 +2224,8 @@ class PowerPanelListView(ObjectListView):
     template_name = 'dcim/powerpanel_list.html'
     template_name = 'dcim/powerpanel_list.html'
 
 
 
 
-class PowerPanelView(View):
+class PowerPanelView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.view_powerpanel'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -2253,7 +2282,8 @@ class PowerPanelBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Power feeds
 # Power feeds
 #
 #
 
 
-class PowerFeedListView(ObjectListView):
+class PowerFeedListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_powerfeed'
     queryset = PowerFeed.objects.select_related(
     queryset = PowerFeed.objects.select_related(
         'power_panel', 'rack'
         'power_panel', 'rack'
     )
     )
@@ -2263,7 +2293,8 @@ class PowerFeedListView(ObjectListView):
     template_name = 'dcim/powerfeed_list.html'
     template_name = 'dcim/powerfeed_list.html'
 
 
 
 
-class PowerFeedView(View):
+class PowerFeedView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.view_powerfeed'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 

+ 5 - 5
netbox/extras/tests/test_views.py

@@ -7,6 +7,7 @@ from django.urls import reverse
 
 
 from dcim.models import Site
 from dcim.models import Site
 from extras.models import ConfigContext, ObjectChange, Tag
 from extras.models import ConfigContext, ObjectChange, Tag
+from utilities.testing import create_test_user
 
 
 
 
 class TagTestCase(TestCase):
 class TagTestCase(TestCase):
@@ -35,8 +36,9 @@ class TagTestCase(TestCase):
 class ConfigContextTestCase(TestCase):
 class ConfigContextTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['extras.view_configcontext'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         site = Site(name='Site 1', slug='site-1')
         site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()
@@ -70,11 +72,9 @@ class ConfigContextTestCase(TestCase):
 class ObjectChangeTestCase(TestCase):
 class ObjectChangeTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['extras.view_objectchange'])
         self.client = Client()
         self.client = Client()
-
-        user = User(username='testuser', email='testuser@example.com')
-        user.save()
+        self.client.force_login(user)
 
 
         site = Site(name='Site 1', slug='site-1')
         site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()

+ 12 - 6
netbox/extras/views.py

@@ -96,7 +96,8 @@ class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Config contexts
 # Config contexts
 #
 #
 
 
-class ConfigContextListView(ObjectListView):
+class ConfigContextListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'extras.view_configcontext'
     queryset = ConfigContext.objects.all()
     queryset = ConfigContext.objects.all()
     filter = filters.ConfigContextFilter
     filter = filters.ConfigContextFilter
     filter_form = ConfigContextFilterForm
     filter_form = ConfigContextFilterForm
@@ -104,7 +105,8 @@ class ConfigContextListView(ObjectListView):
     template_name = 'extras/configcontext_list.html'
     template_name = 'extras/configcontext_list.html'
 
 
 
 
-class ConfigContextView(View):
+class ConfigContextView(PermissionRequiredMixin, View):
+    permission_required = 'extras.view_configcontext'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -173,7 +175,8 @@ class ObjectConfigContextView(View):
 # Change logging
 # Change logging
 #
 #
 
 
-class ObjectChangeListView(ObjectListView):
+class ObjectChangeListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'extras.view_objectchange'
     queryset = ObjectChange.objects.select_related('user', 'changed_object_type')
     queryset = ObjectChange.objects.select_related('user', 'changed_object_type')
     filter = filters.ObjectChangeFilter
     filter = filters.ObjectChangeFilter
     filter_form = ObjectChangeFilterForm
     filter_form = ObjectChangeFilterForm
@@ -181,7 +184,8 @@ class ObjectChangeListView(ObjectListView):
     template_name = 'extras/objectchange_list.html'
     template_name = 'extras/objectchange_list.html'
 
 
 
 
-class ObjectChangeView(View):
+class ObjectChangeView(PermissionRequiredMixin, View):
+    permission_required = 'extras.view_objectchange'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -272,10 +276,11 @@ class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 # Reports
 # Reports
 #
 #
 
 
-class ReportListView(View):
+class ReportListView(PermissionRequiredMixin, View):
     """
     """
     Retrieve all of the available reports from disk and the recorded ReportResult (if any) for each.
     Retrieve all of the available reports from disk and the recorded ReportResult (if any) for each.
     """
     """
+    permission_required = 'extras.view_reportresult'
 
 
     def get(self, request):
     def get(self, request):
 
 
@@ -295,10 +300,11 @@ class ReportListView(View):
         })
         })
 
 
 
 
-class ReportView(View):
+class ReportView(PermissionRequiredMixin, View):
     """
     """
     Display a single Report and its associated ReportResult (if any).
     Display a single Report and its associated ReportResult (if any).
     """
     """
+    permission_required = 'extras.view_reportresult'
 
 
     def get(self, request, name):
     def get(self, request, name):
 
 

+ 19 - 15
netbox/ipam/tests/test_views.py

@@ -7,13 +7,15 @@ from django.urls import reverse
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from ipam.constants import IP_PROTOCOL_TCP
 from ipam.constants import IP_PROTOCOL_TCP
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
+from utilities.testing import create_test_user
 
 
 
 
 class VRFTestCase(TestCase):
 class VRFTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['ipam.view_vrf'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         VRF.objects.bulk_create([
         VRF.objects.bulk_create([
             VRF(name='VRF 1', rd='65000:1'),
             VRF(name='VRF 1', rd='65000:1'),
@@ -41,8 +43,9 @@ class VRFTestCase(TestCase):
 class RIRTestCase(TestCase):
 class RIRTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['ipam.view_rir'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         RIR.objects.bulk_create([
         RIR.objects.bulk_create([
             RIR(name='RIR 1', slug='rir-1'),
             RIR(name='RIR 1', slug='rir-1'),
@@ -57,18 +60,13 @@ class RIRTestCase(TestCase):
         response = self.client.get(url)
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
-    def test_rir(self):
-
-        rir = RIR.objects.first()
-        response = self.client.get(rir.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
-
 
 
 class AggregateTestCase(TestCase):
 class AggregateTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['ipam.view_aggregate'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         rir = RIR(name='RIR 1', slug='rir-1')
         rir = RIR(name='RIR 1', slug='rir-1')
         rir.save()
         rir.save()
@@ -99,8 +97,9 @@ class AggregateTestCase(TestCase):
 class RoleTestCase(TestCase):
 class RoleTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['ipam.view_role'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         Role.objects.bulk_create([
         Role.objects.bulk_create([
             Role(name='Role 1', slug='role-1'),
             Role(name='Role 1', slug='role-1'),
@@ -119,8 +118,9 @@ class RoleTestCase(TestCase):
 class PrefixTestCase(TestCase):
 class PrefixTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['ipam.view_prefix'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         site = Site(name='Site 1', slug='site-1')
         site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()
@@ -151,8 +151,9 @@ class PrefixTestCase(TestCase):
 class IPAddressTestCase(TestCase):
 class IPAddressTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['ipam.view_ipaddress'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         vrf = VRF(name='VRF 1', rd='65000:1')
         vrf = VRF(name='VRF 1', rd='65000:1')
         vrf.save()
         vrf.save()
@@ -183,8 +184,9 @@ class IPAddressTestCase(TestCase):
 class VLANGroupTestCase(TestCase):
 class VLANGroupTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['ipam.view_vlangroup'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         site = Site(name='Site 1', slug='site-1')
         site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()
@@ -209,8 +211,9 @@ class VLANGroupTestCase(TestCase):
 class VLANTestCase(TestCase):
 class VLANTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['ipam.view_vlan'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         vlangroup = VLANGroup(name='VLAN Group 1', slug='vlan-group-1')
         vlangroup = VLANGroup(name='VLAN Group 1', slug='vlan-group-1')
         vlangroup.save()
         vlangroup.save()
@@ -241,8 +244,9 @@ class VLANTestCase(TestCase):
 class ServiceTestCase(TestCase):
 class ServiceTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['ipam.view_service'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         site = Site(name='Site 1', slug='site-1')
         site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()

+ 39 - 19
netbox/ipam/views.py

@@ -113,7 +113,8 @@ def add_available_vlans(vlan_group, vlans):
 # VRFs
 # VRFs
 #
 #
 
 
-class VRFListView(ObjectListView):
+class VRFListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'ipam.view_vrf'
     queryset = VRF.objects.select_related('tenant')
     queryset = VRF.objects.select_related('tenant')
     filter = filters.VRFFilter
     filter = filters.VRFFilter
     filter_form = forms.VRFFilterForm
     filter_form = forms.VRFFilterForm
@@ -121,7 +122,8 @@ class VRFListView(ObjectListView):
     template_name = 'ipam/vrf_list.html'
     template_name = 'ipam/vrf_list.html'
 
 
 
 
-class VRFView(View):
+class VRFView(PermissionRequiredMixin, View):
+    permission_required = 'ipam.view_vrf'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -180,7 +182,8 @@ class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # RIRs
 # RIRs
 #
 #
 
 
-class RIRListView(ObjectListView):
+class RIRListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'ipam.view_rir'
     queryset = RIR.objects.annotate(aggregate_count=Count('aggregates'))
     queryset = RIR.objects.annotate(aggregate_count=Count('aggregates'))
     filter = filters.RIRFilter
     filter = filters.RIRFilter
     filter_form = forms.RIRFilterForm
     filter_form = forms.RIRFilterForm
@@ -286,7 +289,8 @@ class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Aggregates
 # Aggregates
 #
 #
 
 
-class AggregateListView(ObjectListView):
+class AggregateListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'ipam.view_aggregate'
     queryset = Aggregate.objects.select_related('rir').extra(select={
     queryset = Aggregate.objects.select_related('rir').extra(select={
         'child_count': 'SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix',
         'child_count': 'SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix',
     })
     })
@@ -312,7 +316,8 @@ class AggregateListView(ObjectListView):
         }
         }
 
 
 
 
-class AggregateView(View):
+class AggregateView(PermissionRequiredMixin, View):
+    permission_required = 'ipam.view_aggregate'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -398,7 +403,8 @@ class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Prefix/VLAN roles
 # Prefix/VLAN roles
 #
 #
 
 
-class RoleListView(ObjectListView):
+class RoleListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'ipam.view_role'
     queryset = Role.objects.all()
     queryset = Role.objects.all()
     table = tables.RoleTable
     table = tables.RoleTable
     template_name = 'ipam/role_list.html'
     template_name = 'ipam/role_list.html'
@@ -433,7 +439,8 @@ class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Prefixes
 # Prefixes
 #
 #
 
 
-class PrefixListView(ObjectListView):
+class PrefixListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'ipam.view_prefix'
     queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
     queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
     filter = filters.PrefixFilter
     filter = filters.PrefixFilter
     filter_form = forms.PrefixFilterForm
     filter_form = forms.PrefixFilterForm
@@ -446,7 +453,8 @@ class PrefixListView(ObjectListView):
         return self.queryset.annotate_depth(limit=limit)
         return self.queryset.annotate_depth(limit=limit)
 
 
 
 
-class PrefixView(View):
+class PrefixView(PermissionRequiredMixin, View):
+    permission_required = 'ipam.view_prefix'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -489,7 +497,8 @@ class PrefixView(View):
         })
         })
 
 
 
 
-class PrefixPrefixesView(View):
+class PrefixPrefixesView(PermissionRequiredMixin, View):
+    permission_required = 'ipam.view_prefix'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -531,7 +540,8 @@ class PrefixPrefixesView(View):
         })
         })
 
 
 
 
-class PrefixIPAddressesView(View):
+class PrefixIPAddressesView(PermissionRequiredMixin, View):
+    permission_required = 'ipam.view_prefix'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -617,7 +627,8 @@ class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # IP addresses
 # IP addresses
 #
 #
 
 
-class IPAddressListView(ObjectListView):
+class IPAddressListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'ipam.view_ipaddress'
     queryset = IPAddress.objects.select_related(
     queryset = IPAddress.objects.select_related(
         'vrf__tenant', 'tenant', 'nat_inside'
         'vrf__tenant', 'tenant', 'nat_inside'
     ).prefetch_related(
     ).prefetch_related(
@@ -629,7 +640,8 @@ class IPAddressListView(ObjectListView):
     template_name = 'ipam/ipaddress_list.html'
     template_name = 'ipam/ipaddress_list.html'
 
 
 
 
-class IPAddressView(View):
+class IPAddressView(PermissionRequiredMixin, View):
+    permission_required = 'ipam.view_ipaddress'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -788,7 +800,8 @@ class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # VLAN groups
 # VLAN groups
 #
 #
 
 
-class VLANGroupListView(ObjectListView):
+class VLANGroupListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'ipam.view_vlangroup'
     queryset = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans'))
     queryset = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans'))
     filter = filters.VLANGroupFilter
     filter = filters.VLANGroupFilter
     filter_form = forms.VLANGroupFilterForm
     filter_form = forms.VLANGroupFilterForm
@@ -822,7 +835,9 @@ class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     default_return_url = 'ipam:vlangroup_list'
     default_return_url = 'ipam:vlangroup_list'
 
 
 
 
-class VLANGroupVLANsView(View):
+class VLANGroupVLANsView(PermissionRequiredMixin, View):
+    permission_required = 'ipam.view_vlangroup'
+
     def get(self, request, pk):
     def get(self, request, pk):
 
 
         vlan_group = get_object_or_404(VLANGroup.objects.all(), pk=pk)
         vlan_group = get_object_or_404(VLANGroup.objects.all(), pk=pk)
@@ -861,7 +876,8 @@ class VLANGroupVLANsView(View):
 # VLANs
 # VLANs
 #
 #
 
 
-class VLANListView(ObjectListView):
+class VLANListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'ipam.view_vlan'
     queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('prefixes')
     queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('prefixes')
     filter = filters.VLANFilter
     filter = filters.VLANFilter
     filter_form = forms.VLANFilterForm
     filter_form = forms.VLANFilterForm
@@ -869,7 +885,8 @@ class VLANListView(ObjectListView):
     template_name = 'ipam/vlan_list.html'
     template_name = 'ipam/vlan_list.html'
 
 
 
 
-class VLANView(View):
+class VLANView(PermissionRequiredMixin, View):
+    permission_required = 'ipam.view_vlan'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -886,7 +903,8 @@ class VLANView(View):
         })
         })
 
 
 
 
-class VLANMembersView(View):
+class VLANMembersView(PermissionRequiredMixin, View):
+    permission_required = 'ipam.view_vlan'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -954,7 +972,8 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Services
 # Services
 #
 #
 
 
-class ServiceListView(ObjectListView):
+class ServiceListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'ipam.view_service'
     queryset = Service.objects.select_related('device', 'virtual_machine')
     queryset = Service.objects.select_related('device', 'virtual_machine')
     filter = filters.ServiceFilter
     filter = filters.ServiceFilter
     filter_form = forms.ServiceFilterForm
     filter_form = forms.ServiceFilterForm
@@ -962,7 +981,8 @@ class ServiceListView(ObjectListView):
     template_name = 'ipam/service_list.html'
     template_name = 'ipam/service_list.html'
 
 
 
 
-class ServiceView(View):
+class ServiceView(PermissionRequiredMixin, View):
+    permission_required = 'ipam.view_service'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 

+ 15 - 0
netbox/netbox/api.py

@@ -55,16 +55,31 @@ class TokenPermissions(DjangoModelPermissions):
     Custom permissions handler which extends the built-in DjangoModelPermissions to validate a Token's write ability
     Custom permissions handler which extends the built-in DjangoModelPermissions to validate a Token's write ability
     for unsafe requests (POST/PUT/PATCH/DELETE).
     for unsafe requests (POST/PUT/PATCH/DELETE).
     """
     """
+    # Override the stock perm_map to enforce view permissions
+    perms_map = {
+        'GET': ['%(app_label)s.view_%(model_name)s'],
+        'OPTIONS': [],
+        'HEAD': ['%(app_label)s.view_%(model_name)s'],
+        'POST': ['%(app_label)s.add_%(model_name)s'],
+        'PUT': ['%(app_label)s.change_%(model_name)s'],
+        'PATCH': ['%(app_label)s.change_%(model_name)s'],
+        'DELETE': ['%(app_label)s.delete_%(model_name)s'],
+    }
+
     def __init__(self):
     def __init__(self):
+
         # LOGIN_REQUIRED determines whether read-only access is provided to anonymous users.
         # LOGIN_REQUIRED determines whether read-only access is provided to anonymous users.
         self.authenticated_users_only = settings.LOGIN_REQUIRED
         self.authenticated_users_only = settings.LOGIN_REQUIRED
+
         super().__init__()
         super().__init__()
 
 
     def has_permission(self, request, view):
     def has_permission(self, request, view):
+
         # If token authentication is in use, verify that the token allows write operations (for unsafe methods).
         # 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 request.method not in SAFE_METHODS and isinstance(request.auth, Token):
             if not request.auth.write_enabled:
             if not request.auth.write_enabled:
                 return False
                 return False
+
         return super().has_permission(request, view)
         return super().has_permission(request, view)
 
 
 
 

+ 8 - 0
netbox/netbox/configuration.example.py

@@ -83,6 +83,14 @@ EMAIL = {
 # (all prefixes and IP addresses not assigned to a VRF), set ENFORCE_GLOBAL_UNIQUE to True.
 # (all prefixes and IP addresses not assigned to a VRF), set ENFORCE_GLOBAL_UNIQUE to True.
 ENFORCE_GLOBAL_UNIQUE = False
 ENFORCE_GLOBAL_UNIQUE = False
 
 
+# Exempt certain models from the enforcement of view permissions. Models listed here will be viewable by all users and
+# by anonymous users. List models in the form `<app>.<model>`. Add '*' to this list to exempt all models.
+EXEMPT_VIEW_PERMISSIONS = [
+    # 'dcim.site',
+    # 'dcim.region',
+    # 'ipam.prefix',
+]
+
 # Enable custom logging. Please see the Django documentation for detailed guidance on configuring custom logs:
 # Enable custom logging. Please see the Django documentation for detailed guidance on configuring custom logs:
 #   https://docs.djangoproject.com/en/1.11/topics/logging/
 #   https://docs.djangoproject.com/en/1.11/topics/logging/
 LOGGING = {}
 LOGGING = {}

+ 7 - 2
netbox/netbox/settings.py

@@ -51,8 +51,9 @@ CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
 DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
 DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
 DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
 DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
 DEBUG = getattr(configuration, 'DEBUG', False)
 DEBUG = getattr(configuration, 'DEBUG', False)
-ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
 EMAIL = getattr(configuration, 'EMAIL', {})
 EMAIL = getattr(configuration, 'EMAIL', {})
+ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
+EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
 LOGGING = getattr(configuration, 'LOGGING', {})
 LOGGING = getattr(configuration, 'LOGGING', {})
 LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
 LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
 LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
 LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
@@ -93,7 +94,7 @@ if LDAP_CONFIGURED:
         # Prepend LDAPBackend to the default ModelBackend
         # Prepend LDAPBackend to the default ModelBackend
         AUTHENTICATION_BACKENDS = [
         AUTHENTICATION_BACKENDS = [
             'django_auth_ldap.backend.LDAPBackend',
             'django_auth_ldap.backend.LDAPBackend',
-            'django.contrib.auth.backends.ModelBackend',
+            'utilities.auth_backends.ViewExemptModelBackend',
         ]
         ]
         # Optionally disable strict certificate checking
         # Optionally disable strict certificate checking
         if LDAP_IGNORE_CERT_ERRORS:
         if LDAP_IGNORE_CERT_ERRORS:
@@ -107,6 +108,10 @@ if LDAP_CONFIGURED:
             "LDAP authentication has been configured, but django-auth-ldap is not installed. You can remove "
             "LDAP authentication has been configured, but django-auth-ldap is not installed. You can remove "
             "netbox/ldap_config.py to disable LDAP."
             "netbox/ldap_config.py to disable LDAP."
         )
         )
+else:
+    AUTHENTICATION_BACKENDS = [
+        'utilities.auth_backends.ViewExemptModelBackend',
+    ]
 
 
 # Database
 # Database
 configuration.DATABASE.update({'ENGINE': 'django.db.backends.postgresql'})
 configuration.DATABASE.update({'ENGINE': 'django.db.backends.postgresql'})

+ 7 - 12
netbox/secrets/tests/test_views.py

@@ -1,25 +1,19 @@
 import urllib.parse
 import urllib.parse
 
 
-from django.contrib.auth import get_user_model
 from django.test import Client, TestCase
 from django.test import Client, TestCase
 from django.urls import reverse
 from django.urls import reverse
 
 
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from secrets.models import Secret, SecretRole
 from secrets.models import Secret, SecretRole
+from utilities.testing import create_test_user
 
 
 
 
 class SecretRoleTestCase(TestCase):
 class SecretRoleTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
-        TEST_USERNAME = 'testuser'
-        TEST_PASSWORD = 'testpassword'
-
-        User = get_user_model()
-        User.objects.create(username=TEST_USERNAME, email='testuser@example.com', password=TEST_PASSWORD)
-
+        user = create_test_user(permissions=['secrets.view_secretrole'])
         self.client = Client()
         self.client = Client()
-        self.client.login(username=TEST_USERNAME, password=TEST_PASSWORD)
+        self.client.force_login(user)
 
 
         SecretRole.objects.bulk_create([
         SecretRole.objects.bulk_create([
             SecretRole(name='Secret Role 1', slug='secret-role-1'),
             SecretRole(name='Secret Role 1', slug='secret-role-1'),
@@ -29,7 +23,7 @@ class SecretRoleTestCase(TestCase):
 
 
     def test_secretrole_list(self):
     def test_secretrole_list(self):
 
 
-        url = reverse('secrets:secret_list')
+        url = reverse('secrets:secretrole_list')
 
 
         response = self.client.get(url, follow=True)
         response = self.client.get(url, follow=True)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
@@ -38,8 +32,9 @@ class SecretRoleTestCase(TestCase):
 class SecretTestCase(TestCase):
 class SecretTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['secrets.view_secret'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         site = Site(name='Site 1', slug='site-1')
         site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()
@@ -75,7 +70,7 @@ class SecretTestCase(TestCase):
         response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)), follow=True)
         response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)), follow=True)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
-    def test_configcontext(self):
+    def test_secret(self):
 
 
         secret = Secret.objects.first()
         secret = Secret.objects.first()
         response = self.client.get(secret.get_absolute_url(), follow=True)
         response = self.client.get(secret.get_absolute_url(), follow=True)

+ 7 - 6
netbox/secrets/views.py

@@ -32,7 +32,8 @@ def get_session_key(request):
 # Secret roles
 # Secret roles
 #
 #
 
 
-class SecretRoleListView(ObjectListView):
+class SecretRoleListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'secrets.view_secretrole'
     queryset = SecretRole.objects.annotate(secret_count=Count('secrets'))
     queryset = SecretRole.objects.annotate(secret_count=Count('secrets'))
     table = tables.SecretRoleTable
     table = tables.SecretRoleTable
     template_name = 'secrets/secretrole_list.html'
     template_name = 'secrets/secretrole_list.html'
@@ -67,8 +68,8 @@ class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Secrets
 # Secrets
 #
 #
 
 
-@method_decorator(login_required, name='dispatch')
-class SecretListView(ObjectListView):
+class SecretListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'secrets.view_secret'
     queryset = Secret.objects.select_related('role', 'device')
     queryset = Secret.objects.select_related('role', 'device')
     filter = filters.SecretFilter
     filter = filters.SecretFilter
     filter_form = forms.SecretFilterForm
     filter_form = forms.SecretFilterForm
@@ -76,8 +77,8 @@ class SecretListView(ObjectListView):
     template_name = 'secrets/secret_list.html'
     template_name = 'secrets/secret_list.html'
 
 
 
 
-@method_decorator(login_required, name='dispatch')
-class SecretView(View):
+class SecretView(PermissionRequiredMixin, View):
+    permission_required = 'secrets.view_secret'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -198,7 +199,7 @@ class SecretDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 
 
 
 
 class SecretBulkImportView(BulkImportView):
 class SecretBulkImportView(BulkImportView):
-    permission_required = 'ipam.add_vlan'
+    permission_required = 'secrets.add_secret'
     model_form = forms.SecretCSVForm
     model_form = forms.SecretCSVForm
     table = tables.SecretTable
     table = tables.SecretTable
     template_name = 'secrets/secret_import.html'
     template_name = 'secrets/secret_import.html'

+ 5 - 3
netbox/templates/circuits/circuit.html

@@ -45,9 +45,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ circuit.get_absolute_url }}">Circuit</a>
             <a href="{{ circuit.get_absolute_url }}">Circuit</a>
         </li>
         </li>
-        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-            <a href="{% url 'circuits:circuit_changelog' pk=circuit.pk %}">Changelog</a>
-        </li>
+        {% if perms.extras.view_objectchange %}
+            <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+                <a href="{% url 'circuits:circuit_changelog' pk=circuit.pk %}">Changelog</a>
+            </li>
+        {% endif %}
     </ul>
     </ul>
 {% endblock %}
 {% endblock %}
 
 

+ 5 - 3
netbox/templates/circuits/provider.html

@@ -51,9 +51,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ provider.get_absolute_url }}">Provider</a>
             <a href="{{ provider.get_absolute_url }}">Provider</a>
         </li>
         </li>
-        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-            <a href="{% url 'circuits:provider_changelog' slug=provider.slug %}">Changelog</a>
-        </li>
+        {% if perms.extras.view_objectchange %}
+            <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+                <a href="{% url 'circuits:provider_changelog' slug=provider.slug %}">Changelog</a>
+            </li>
+        {% endif %}
     </ul>
     </ul>
 {% endblock %}
 {% endblock %}
 
 

+ 5 - 3
netbox/templates/dcim/cable.html

@@ -27,9 +27,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ cable.get_absolute_url }}">Cable</a>
             <a href="{{ cable.get_absolute_url }}">Cable</a>
         </li>
         </li>
-        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-            <a href="{% url 'dcim:cable_changelog' pk=cable.pk %}">Changelog</a>
-        </li>
+        {% if perms.extras.view_objectchange %}
+            <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+                <a href="{% url 'dcim:cable_changelog' pk=cable.pk %}">Changelog</a>
+            </li>
+        {% endif %}
     </ul>
     </ul>
 {% endblock %}
 {% endblock %}
 
 

+ 10 - 6
netbox/templates/dcim/device.html

@@ -86,12 +86,16 @@
                 {% include 'dcim/inc/device_napalm_tabs.html' %}
                 {% include 'dcim/inc/device_napalm_tabs.html' %}
             {% endif %}
             {% endif %}
         {% endif %}
         {% endif %}
-        <li role="presentation"{% if active_tab == 'config-context' %} class="active"{% endif %}>
-            <a href="{% url 'dcim:device_configcontext' pk=device.pk %}">Config Context</a>
-        </li>
-        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-            <a href="{% url 'dcim:device_changelog' pk=device.pk %}">Changelog</a>
-        </li>
+        {% if perms.extras.view_configcontext %}
+            <li role="presentation"{% if active_tab == 'config-context' %} class="active"{% endif %}>
+                <a href="{% url 'dcim:device_configcontext' pk=device.pk %}">Config Context</a>
+            </li>
+        {% endif %}
+        {% if perms.extras.view_objectchange %}
+            <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+                <a href="{% url 'dcim:device_changelog' pk=device.pk %}">Changelog</a>
+            </li>
+        {% endif %}
     </ul>
     </ul>
 {% endblock %}
 {% endblock %}
 
 

+ 5 - 3
netbox/templates/dcim/devicetype.html

@@ -50,9 +50,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ devicetype.get_absolute_url }}">Device Type</a>
             <a href="{{ devicetype.get_absolute_url }}">Device Type</a>
         </li>
         </li>
-        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-            <a href="{% url 'dcim:devicetype_changelog' pk=devicetype.pk %}">Changelog</a>
-        </li>
+        {% if perms.extras.view_objectchange %}
+            <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+                <a href="{% url 'dcim:devicetype_changelog' pk=devicetype.pk %}">Changelog</a>
+            </li>
+        {% endif %}
     </ul>
     </ul>
 {% endblock %}
 {% endblock %}
 
 

+ 5 - 3
netbox/templates/dcim/interface.html

@@ -32,9 +32,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ interface.get_absolute_url }}">Interface</a>
             <a href="{{ interface.get_absolute_url }}">Interface</a>
         </li>
         </li>
-        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-            <a href="{% url 'dcim:interface_changelog' pk=interface.pk %}">Changelog</a>
-        </li>
+        {% if perms.extras.view_objectchange %}
+            <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+                <a href="{% url 'dcim:interface_changelog' pk=interface.pk %}">Changelog</a>
+            </li>
+        {% endif %}
     </ul>
     </ul>
 {% endblock %}
 {% endblock %}
 
 

+ 5 - 3
netbox/templates/dcim/rack.html

@@ -47,9 +47,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ rack.get_absolute_url }}">Rack</a>
             <a href="{{ rack.get_absolute_url }}">Rack</a>
         </li>
         </li>
-        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-            <a href="{% url 'dcim:rack_changelog' pk=rack.pk %}">Changelog</a>
-        </li>
+        {% if perms.extras.view_objectchange %}
+            <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+                <a href="{% url 'dcim:rack_changelog' pk=rack.pk %}">Changelog</a>
+            </li>
+        {% endif %}
     </ul>
     </ul>
 {% endblock %}
 {% endblock %}
 
 

+ 5 - 3
netbox/templates/dcim/site.html

@@ -56,9 +56,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ site.get_absolute_url }}">Site</a>
             <a href="{{ site.get_absolute_url }}">Site</a>
         </li>
         </li>
-        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-            <a href="{% url 'dcim:site_changelog' slug=site.slug %}">Changelog</a>
-        </li>
+        {% if perms.extras.view_objectchange %}
+            <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+                <a href="{% url 'dcim:site_changelog' slug=site.slug %}">Changelog</a>
+            </li>
+        {% endif %}
     </ul>
     </ul>
 {% endblock %}
 {% endblock %}
 
 

+ 5 - 3
netbox/templates/extras/tag.html

@@ -42,9 +42,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ tag.get_absolute_url }}">Tag</a>
             <a href="{{ tag.get_absolute_url }}">Tag</a>
         </li>
         </li>
-        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-            <a href="{% url 'extras:tag_changelog' slug=tag.slug %}">Changelog</a>
-        </li>
+        {% if perms.extras.view_objectchange %}
+            <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+                <a href="{% url 'extras:tag_changelog' slug=tag.slug %}">Changelog</a>
+            </li>
+        {% endif %}
     </ul>
     </ul>
 {% endblock %}
 {% endblock %}
 
 

+ 167 - 129
netbox/templates/home.html

@@ -10,16 +10,20 @@
                 <strong>Organization</strong>
                 <strong>Organization</strong>
             </div>
             </div>
             <div class="list-group">
             <div class="list-group">
-                <div class="list-group-item">
-                    <span class="badge pull-right">{{ stats.site_count }}</span>
-                    <h4 class="list-group-item-heading"><a href="{% url 'dcim:site_list' %}">Sites</a></h4>
-                    <p class="list-group-item-text text-muted">Geographic locations</p>
-                </div>
-                <div class="list-group-item">
-                    <span class="badge pull-right">{{ stats.tenant_count }}</span>
-                    <h4 class="list-group-item-heading"><a href="{% url 'tenancy:tenant_list' %}">Tenants</a></h4>
-                    <p class="list-group-item-text text-muted">Customers or departments</p>
-                </div>
+                {% if perms.dcim.view_site %}
+                    <div class="list-group-item">
+                        <span class="badge pull-right">{{ stats.site_count }}</span>
+                        <h4 class="list-group-item-heading"><a href="{% url 'dcim:site_list' %}">Sites</a></h4>
+                        <p class="list-group-item-text text-muted">Geographic locations</p>
+                    </div>
+                {% endif %}
+                {% if perms.tenancy.view_tenant %}
+                    <div class="list-group-item">
+                        <span class="badge pull-right">{{ stats.tenant_count }}</span>
+                        <h4 class="list-group-item-heading"><a href="{% url 'tenancy:tenant_list' %}">Tenants</a></h4>
+                        <p class="list-group-item-text text-muted">Customers or departments</p>
+                    </div>
+                {% endif %}
             </div>
             </div>
         </div>
         </div>
         <div class="panel panel-default">
         <div class="panel panel-default">
@@ -27,26 +31,38 @@
                 <strong>DCIM</strong>
                 <strong>DCIM</strong>
             </div>
             </div>
             <div class="list-group">
             <div class="list-group">
-                <div class="list-group-item">
-                    <span class="badge pull-right">{{ stats.rack_count }}</span>
-                    <h4 class="list-group-item-heading"><a href="{% url 'dcim:rack_list' %}">Racks</a></h4>
-                    <p class="list-group-item-text text-muted">Equipment racks, optionally organized by group</p>
-                </div>
-                <div class="list-group-item">
-                    <span class="badge pull-right">{{ stats.device_count }}</span>
-                    <h4 class="list-group-item-heading"><a href="{% url 'dcim:device_list' %}">Devices</a></h4>
-                    <p class="list-group-item-text text-muted">Rack-mounted network equipment, servers, and other devices</p>
-                </div>
+                {% if perms.dcim.view_rack %}
+                    <div class="list-group-item">
+                        <span class="badge pull-right">{{ stats.rack_count }}</span>
+                        <h4 class="list-group-item-heading"><a href="{% url 'dcim:rack_list' %}">Racks</a></h4>
+                        <p class="list-group-item-text text-muted">Equipment racks, optionally organized by group</p>
+                    </div>
+                {% endif %}
+                {% if perms.dcim.view_device %}
+                    <div class="list-group-item">
+                        <span class="badge pull-right">{{ stats.device_count }}</span>
+                        <h4 class="list-group-item-heading"><a href="{% url 'dcim:device_list' %}">Devices</a></h4>
+                        <p class="list-group-item-text text-muted">Rack-mounted network equipment, servers, and other devices</p>
+                    </div>
+                {% endif %}
                 <div class="list-group-item">
                 <div class="list-group-item">
                     <h4 class="list-group-item-heading">Connections</h4>
                     <h4 class="list-group-item-heading">Connections</h4>
-                    <span class="badge pull-right">{{ stats.cable_count }}</span>
-                    <p style="padding-left: 20px;"><a href="{% url 'dcim:cable_list' %}">Cables</a></p>
-                    <span class="badge pull-right">{{ stats.interface_connections_count }}</span>
-                    <p style="padding-left: 20px;"><a href="{% url 'dcim:interface_connections_list' %}">Interfaces</a></p>
-                    <span class="badge pull-right">{{ stats.console_connections_count }}</span>
-                    <p style="padding-left: 20px;"><a href="{% url 'dcim:console_connections_list' %}">Console</a></p>
-                    <span class="badge pull-right">{{ stats.power_connections_count }}</span>
-                    <p class="list-group-item-text" style="padding-left: 20px;"><a href="{% url 'dcim:power_connections_list' %}">Power</a></p>
+                    {% if perms.dcim.view_cable %}
+                        <span class="badge pull-right">{{ stats.cable_count }}</span>
+                        <p style="padding-left: 20px;"><a href="{% url 'dcim:cable_list' %}">Cables</a></p>
+                    {% endif %}
+                    {% if perms.dcim.view_interface %}
+                        <span class="badge pull-right">{{ stats.interface_connections_count }}</span>
+                        <p style="padding-left: 20px;"><a href="{% url 'dcim:interface_connections_list' %}">Interfaces</a></p>
+                    {% endif %}
+                    {% if perms.dcim.view_consoleport and perms.dcim.view_consoleserverport %}
+                        <span class="badge pull-right">{{ stats.console_connections_count }}</span>
+                        <p style="padding-left: 20px;"><a href="{% url 'dcim:console_connections_list' %}">Console</a></p>
+                    {% endif %}
+                    {% if perms.dcim.view_powerport and perms.dcim.view_poweroutlet %}
+                        <span class="badge pull-right">{{ stats.power_connections_count }}</span>
+                        <p class="list-group-item-text" style="padding-left: 20px;"><a href="{% url 'dcim:power_connections_list' %}">Power</a></p>
+                    {% endif %}
                 </div>
                 </div>
             </div>
             </div>
         </div>
         </div>
@@ -55,16 +71,20 @@
                 <strong>Virtualization</strong>
                 <strong>Virtualization</strong>
             </div>
             </div>
             <div class="list-group">
             <div class="list-group">
-                <div class="list-group-item">
-                    <span class="badge pull-right">{{ stats.cluster_count }}</span>
-                    <h4 class="list-group-item-heading"><a href="{% url 'virtualization:cluster_list' %}">Clusters</a></h4>
-                    <p class="list-group-item-text text-muted">Clusters of physical hosts in which VMs reside</p>
-                </div>
-                <div class="list-group-item">
-                    <span class="badge pull-right">{{ stats.virtualmachine_count }}</span>
-                    <h4 class="list-group-item-heading"><a href="{% url 'virtualization:virtualmachine_list' %}">Virtual Machines</a></h4>
-                    <p class="list-group-item-text text-muted">Virtual compute instances running inside clusters</p>
-                </div>
+                {% if perms.virtualization.view_cluster %}
+                    <div class="list-group-item">
+                        <span class="badge pull-right">{{ stats.cluster_count }}</span>
+                        <h4 class="list-group-item-heading"><a href="{% url 'virtualization:cluster_list' %}">Clusters</a></h4>
+                        <p class="list-group-item-text text-muted">Clusters of physical hosts in which VMs reside</p>
+                    </div>
+                {% endif %}
+                {% if perms.virtualization.view_virtualmachine %}
+                    <div class="list-group-item">
+                        <span class="badge pull-right">{{ stats.virtualmachine_count }}</span>
+                        <h4 class="list-group-item-heading"><a href="{% url 'virtualization:virtualmachine_list' %}">Virtual Machines</a></h4>
+                        <p class="list-group-item-text text-muted">Virtual compute instances running inside clusters</p>
+                    </div>
+                {% endif %}
             </div>
             </div>
         </div>
         </div>
     </div>
     </div>
@@ -74,31 +94,41 @@
                 <strong>IPAM</strong>
                 <strong>IPAM</strong>
             </div>
             </div>
             <div class="list-group">
             <div class="list-group">
-                <div class="list-group-item">
-                    <span class="badge pull-right">{{ stats.vrf_count }}</span>
-                    <h4 class="list-group-item-heading"><a href="{% url 'ipam:vrf_list' %}">VRFs</a></h4>
-                    <p class="list-group-item-text text-muted">Virtual routing and forwarding tables</p>
-                </div>
-                <div class="list-group-item">
-                    <span class="badge pull-right">{{ stats.aggregate_count }}</span>
-                    <h4 class="list-group-item-heading"><a href="{% url 'ipam:aggregate_list' %}">Aggregates</a></h4>
-                    <p class="list-group-item-text text-muted">Top-level IP allocations</p>
-                </div>
-                <div class="list-group-item">
-                    <span class="badge pull-right">{{ stats.prefix_count }}</span>
-                    <h4 class="list-group-item-heading"><a href="{% url 'ipam:prefix_list' %}">Prefixes</a></h4>
-                    <p class="list-group-item-text text-muted">IPv4 and IPv6 network assignments</p>
-                </div>
-                <div class="list-group-item">
-                    <span class="badge pull-right">{{ stats.ipaddress_count }}</span>
-                    <h4 class="list-group-item-heading"><a href="{% url 'ipam:ipaddress_list' %}">IP Addresses</a></h4>
-                    <p class="list-group-item-text text-muted">Individual IPv4 and IPv6 addresses</p>
-                </div>
-                <div class="list-group-item">
-                    <span class="badge pull-right">{{ stats.vlan_count }}</span>
-                    <h4 class="list-group-item-heading"><a href="{% url 'ipam:vlan_list' %}">VLANs</a></h4>
-                    <p class="list-group-item-text text-muted">Layer two domains, identified by VLAN ID</p>
-                </div>
+                {% if perms.ipam.view_vrf %}
+                    <div class="list-group-item">
+                        <span class="badge pull-right">{{ stats.vrf_count }}</span>
+                        <h4 class="list-group-item-heading"><a href="{% url 'ipam:vrf_list' %}">VRFs</a></h4>
+                        <p class="list-group-item-text text-muted">Virtual routing and forwarding tables</p>
+                    </div>
+                {% endif %}
+                {% if perms.ipam.view_aggregate %}
+                    <div class="list-group-item">
+                        <span class="badge pull-right">{{ stats.aggregate_count }}</span>
+                        <h4 class="list-group-item-heading"><a href="{% url 'ipam:aggregate_list' %}">Aggregates</a></h4>
+                        <p class="list-group-item-text text-muted">Top-level IP allocations</p>
+                    </div>
+                {% endif %}
+                {% if perms.ipam.view_prefix %}
+                    <div class="list-group-item">
+                        <span class="badge pull-right">{{ stats.prefix_count }}</span>
+                        <h4 class="list-group-item-heading"><a href="{% url 'ipam:prefix_list' %}">Prefixes</a></h4>
+                        <p class="list-group-item-text text-muted">IPv4 and IPv6 network assignments</p>
+                    </div>
+                {% endif %}
+                {% if perms.ipam.view_ipaddress %}
+                    <div class="list-group-item">
+                        <span class="badge pull-right">{{ stats.ipaddress_count }}</span>
+                        <h4 class="list-group-item-heading"><a href="{% url 'ipam:ipaddress_list' %}">IP Addresses</a></h4>
+                        <p class="list-group-item-text text-muted">Individual IPv4 and IPv6 addresses</p>
+                    </div>
+                {% endif %}
+                {% if perms.ipam.view_vlan %}
+                    <div class="list-group-item">
+                        <span class="badge pull-right">{{ stats.vlan_count }}</span>
+                        <h4 class="list-group-item-heading"><a href="{% url 'ipam:vlan_list' %}">VLANs</a></h4>
+                        <p class="list-group-item-text text-muted">Layer two domains, identified by VLAN ID</p>
+                    </div>
+                {% endif %}
             </div>
             </div>
         </div>
         </div>
         <div class="panel panel-default">
         <div class="panel panel-default">
@@ -106,16 +136,20 @@
                 <strong>Circuits</strong>
                 <strong>Circuits</strong>
             </div>
             </div>
             <div class="list-group">
             <div class="list-group">
-                <div class="list-group-item">
-                    <span class="badge pull-right">{{ stats.provider_count }}</span>
-                    <h4 class="list-group-item-heading"><a href="{% url 'circuits:provider_list' %}">Providers</a></h4>
-                    <p class="list-group-item-text text-muted">Organizations which provide circuit connectivity</p>
-                </div>
-                <div class="list-group-item">
-                    <span class="badge pull-right">{{ stats.circuit_count }}</span>
-                    <h4 class="list-group-item-heading"><a href="{% url 'circuits:circuit_list' %}">Circuits</a></h4>
-                    <p class="list-group-item-text text-muted">Communication links for Internet transit, peering, and other services</p>
-                </div>
+                {% if perms.circuits.view_provider %}
+                    <div class="list-group-item">
+                        <span class="badge pull-right">{{ stats.provider_count }}</span>
+                        <h4 class="list-group-item-heading"><a href="{% url 'circuits:provider_list' %}">Providers</a></h4>
+                        <p class="list-group-item-text text-muted">Organizations which provide circuit connectivity</p>
+                    </div>
+                {% endif %}
+                {% if perms.circuits.view_circuit %}
+                    <div class="list-group-item">
+                        <span class="badge pull-right">{{ stats.circuit_count }}</span>
+                        <h4 class="list-group-item-heading"><a href="{% url 'circuits:circuit_list' %}">Circuits</a></h4>
+                        <p class="list-group-item-text text-muted">Communication links for Internet transit, peering, and other services</p>
+                    </div>
+                {% endif %}
             </div>
             </div>
         </div>
         </div>
         {% if perms.secrets %}
         {% if perms.secrets %}
@@ -134,26 +168,28 @@
         {% endif %}
         {% endif %}
     </div>
     </div>
     <div class="col-sm-6 col-md-4">
     <div class="col-sm-6 col-md-4">
-        <div class="panel panel-default">
-            <div class="panel-heading">
-                <strong>Global Topology Maps</strong>
+        {% if perms.extras.view_topologymap %}
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Global Topology Maps</strong>
+                </div>
+                {% if topology_maps %}
+                    <table class="table table-hover panel-body">
+                        {% for tm in topology_maps %}
+                            <tr>
+                                <td><i class="fa fa-fw fa-map-o"></i> <a href="{% url 'extras-api:topologymap-render' pk=tm.pk %}" target="_blank">{{ tm }}</a></td>
+                                <td>{{ tm.description }}</td>
+                            </tr>
+                        {% endfor %}
+                    </table>
+                {% else %}
+                    <div class="panel-body text-muted">
+                        None
+                    </div>
+                {% endif %}
             </div>
             </div>
-            {% if topology_maps %}
-                <table class="table table-hover panel-body">
-                    {% for tm in topology_maps %}
-                        <tr>
-                            <td><i class="fa fa-fw fa-map-o"></i> <a href="{% url 'extras-api:topologymap-render' pk=tm.pk %}" target="_blank">{{ tm }}</a></td>
-                            <td>{{ tm.description }}</td>
-                        </tr>
-                    {% endfor %}
-                </table>
-            {% else %}
-                <div class="panel-body text-muted">
-                    None
-                </div>
-            {% endif %}
-        </div>
-        {% if report_results %}
+        {% endif %}
+        {% if report_results and perms.extras.view_reportresult %}
             <div class="panel panel-default">
             <div class="panel panel-default">
                 <div class="panel-heading">
                 <div class="panel-heading">
                     <strong>Reports</strong>
                     <strong>Reports</strong>
@@ -168,44 +204,46 @@
                 </table>
                 </table>
             </div>
             </div>
         {% endif %}
         {% endif %}
-        <div class="panel panel-default">
-            <div class="panel-heading">
-                <strong>Changelog</strong>
-            </div>
-            <div class="list-group">
-                {% for change in changelog %}
-                    {% with action=change.get_action_display|lower %}
-                        <div class="list-group-item">
-                            {% if action == 'created' %}
-                                <span class="label label-success"><i class="fa fa-plus"></i></span>
-                            {% elif action == 'updated' %}
-                                <span class="label label-warning"><i class="fa fa-pencil"></i></span>
-                            {% elif action == 'deleted' %}
-                                <span class="label label-danger"><i class="fa fa-trash"></i></span>
-                            {% endif %}
-                            {{ change.changed_object_type.name|bettertitle }}
-                            {% if change.changed_object.get_absolute_url %}
-                                <a href="{{ change.changed_object.get_absolute_url }}">{{ change.changed_object }}</a>
-                            {% else %}
-                                {{ change.changed_object|default:change.object_repr }}
-                            {% endif %}
-                            <br />
-                            <small>
-                                <span class="text-muted">{{ change.user|default:change.user_name }} -</span>
-                                <a href="{{ change.get_absolute_url }}" class="text-muted">{{ change.time|date:'SHORT_DATETIME_FORMAT' }}</a>
-                            </small>
-                        </div>
-                    {% endwith %}
-                    {% if forloop.last %}
-                        <div class="list-group-item text-right">
-                            <a href="{% url 'extras:objectchange_list' %}">View All Changes</a>
-                        </div>
-                    {% endif %}
-                {% empty %}
-                    <div class="list-group-item text-muted">No change history found</div>
-                {% endfor %}
+        {% if perms.extras.view_objectchange %}
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Changelog</strong>
+                </div>
+                <div class="list-group">
+                    {% for change in changelog %}
+                        {% with action=change.get_action_display|lower %}
+                            <div class="list-group-item">
+                                {% if action == 'created' %}
+                                    <span class="label label-success"><i class="fa fa-plus"></i></span>
+                                {% elif action == 'updated' %}
+                                    <span class="label label-warning"><i class="fa fa-pencil"></i></span>
+                                {% elif action == 'deleted' %}
+                                    <span class="label label-danger"><i class="fa fa-trash"></i></span>
+                                {% endif %}
+                                {{ change.changed_object_type.name|bettertitle }}
+                                {% if change.changed_object.get_absolute_url %}
+                                    <a href="{{ change.changed_object.get_absolute_url }}">{{ change.changed_object }}</a>
+                                {% else %}
+                                    {{ change.changed_object|default:change.object_repr }}
+                                {% endif %}
+                                <br />
+                                <small>
+                                    <span class="text-muted">{{ change.user|default:change.user_name }} -</span>
+                                    <a href="{{ change.get_absolute_url }}" class="text-muted">{{ change.time|date:'SHORT_DATETIME_FORMAT' }}</a>
+                                </small>
+                            </div>
+                        {% endwith %}
+                        {% if forloop.last %}
+                            <div class="list-group-item text-right">
+                                <a href="{% url 'extras:objectchange_list' %}">View All Changes</a>
+                            </div>
+                        {% endif %}
+                    {% empty %}
+                        <div class="list-group-item text-muted">No change history found</div>
+                    {% endfor %}
+                </div>
             </div>
             </div>
-        </div>
+        {% endif %}
     </div>
     </div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 42 - 42
netbox/templates/inc/nav_menu.html

@@ -20,7 +20,7 @@
                     <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Organization <span class="caret"></span></a>
                     <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Organization <span class="caret"></span></a>
                     <ul class="dropdown-menu">
                     <ul class="dropdown-menu">
                         <li class="dropdown-header">Sites</li>
                         <li class="dropdown-header">Sites</li>
-                        <li>
+                        <li{% if not perms.dcim.view_site %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_site %}
                             {% if perms.dcim.add_site %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'dcim:site_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'dcim:site_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
@@ -29,7 +29,7 @@
                             {% endif %}
                             {% endif %}
                             <a href="{% url 'dcim:site_list' %}">Sites</a>
                             <a href="{% url 'dcim:site_list' %}">Sites</a>
                         </li>
                         </li>
-                        <li>
+                        <li{% if not perms.dcim.view_region %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_region %}
                             {% if perms.dcim.add_region %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'dcim:region_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'dcim:region_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
@@ -40,7 +40,7 @@
                         </li>
                         </li>
                         <li class="divider"></li>
                         <li class="divider"></li>
                         <li class="dropdown-header">Tenancy</li>
                         <li class="dropdown-header">Tenancy</li>
-                        <li>
+                        <li{% if not perms.tenancy.view_tenant %} class="disabled"{% endif %}>
                             {% if perms.tenancy.add_tenant %}
                             {% if perms.tenancy.add_tenant %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'tenancy:tenant_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'tenancy:tenant_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
@@ -49,7 +49,7 @@
                             {% endif %}
                             {% endif %}
                             <a href="{% url 'tenancy:tenant_list' %}">Tenants</a>
                             <a href="{% url 'tenancy:tenant_list' %}">Tenants</a>
                         </li>
                         </li>
-                        <li>
+                        <li{% if not perms.tenancy.view_tenantgroup %} class="disabled"{% endif %}>
                             {% if perms.tenancy.add_tenantgroup %}
                             {% if perms.tenancy.add_tenantgroup %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'tenancy:tenantgroup_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'tenancy:tenantgroup_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
@@ -60,16 +60,16 @@
                         </li>
                         </li>
                         <li class="divider"></li>
                         <li class="divider"></li>
                         <li class="dropdown-header">Miscellaneous</li>
                         <li class="dropdown-header">Miscellaneous</li>
-                        <li>
+                        <li{% if not perms.extras.view_tag %} class="disabled"{% endif %}>
                             <a href="{% url 'extras:tag_list' %}">Tags</a>
                             <a href="{% url 'extras:tag_list' %}">Tags</a>
                         </li>
                         </li>
-                        <li>
+                        <li{% if not perms.extras.view_configcontext %} class="disabled"{% endif %}>
                             <a href="{% url 'extras:configcontext_list' %}">Config Contexts</a>
                             <a href="{% url 'extras:configcontext_list' %}">Config Contexts</a>
                         </li>
                         </li>
-                        <li>
+                        <li{% if not perms.extras.view_reportresult %} class="disabled"{% endif %}>
                             <a href="{% url 'extras:report_list' %}">Reports</a>
                             <a href="{% url 'extras:report_list' %}">Reports</a>
                         </li>
                         </li>
-                        <li>
+                        <li{% if not perms.extras.view_objectchange %} class="disabled"{% endif %}>
                             <a href="{% url 'extras:objectchange_list' %}">Changelog</a>
                             <a href="{% url 'extras:objectchange_list' %}">Changelog</a>
                         </li>
                         </li>
                     </ul>
                     </ul>
@@ -78,7 +78,7 @@
                     <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Racks <span class="caret"></span></a>
                     <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Racks <span class="caret"></span></a>
                     <ul class="dropdown-menu">
                     <ul class="dropdown-menu">
                         <li class="dropdown-header">Racks</li>
                         <li class="dropdown-header">Racks</li>
-                        <li>
+                        <li{% if not perms.dcim.view_rack %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_rack %}
                             {% if perms.dcim.add_rack %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'dcim:rack_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'dcim:rack_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
@@ -87,7 +87,7 @@
                             {% endif %}
                             {% endif %}
                             <a href="{% url 'dcim:rack_list' %}">Racks</a>
                             <a href="{% url 'dcim:rack_list' %}">Racks</a>
                         </li>
                         </li>
-                        <li>
+                        <li{% if not perms.dcim.view_rackgroup %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_rackgroup %}
                             {% if perms.dcim.add_rackgroup %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'dcim:rackgroup_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'dcim:rackgroup_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
@@ -96,7 +96,7 @@
                             {% endif %}
                             {% endif %}
                             <a href="{% url 'dcim:rackgroup_list' %}">Rack Groups</a>
                             <a href="{% url 'dcim:rackgroup_list' %}">Rack Groups</a>
                         </li>
                         </li>
-                        <li>
+                        <li{% if not perms.dcim.view_rackrole %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_rackrole %}
                             {% if perms.dcim.add_rackrole %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'dcim:rackrole_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'dcim:rackrole_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
@@ -105,10 +105,10 @@
                             {% endif %}
                             {% endif %}
                             <a href="{% url 'dcim:rackrole_list' %}">Rack Roles</a>
                             <a href="{% url 'dcim:rackrole_list' %}">Rack Roles</a>
                         </li>
                         </li>
-                        <li>
+                        <li{% if not perms.dcim.view_rack %} class="disabled"{% endif %}>
                             <a href="{% url 'dcim:rack_elevation_list' %}">Elevations</a>
                             <a href="{% url 'dcim:rack_elevation_list' %}">Elevations</a>
                         </li>
                         </li>
-                        <li>
+                        <li{% if not perms.dcim.view_rackreservation %} class="disabled"{% endif %}>
                             <a href="{% url 'dcim:rackreservation_list' %}">Reservations</a>
                             <a href="{% url 'dcim:rackreservation_list' %}">Reservations</a>
                         </li>
                         </li>
                     </ul>
                     </ul>
@@ -117,7 +117,7 @@
                     <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Devices <span class="caret"></span></a>
                     <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Devices <span class="caret"></span></a>
                     <ul class="dropdown-menu">
                     <ul class="dropdown-menu">
                         <li class="dropdown-header">Devices</li>
                         <li class="dropdown-header">Devices</li>
-                        <li>
+                        <li{% if not perms.dcim.view_device %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_device %}
                             {% if perms.dcim.add_device %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'dcim:device_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'dcim:device_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
@@ -126,7 +126,7 @@
                             {% endif %}
                             {% endif %}
                             <a href="{% url 'dcim:device_list' %}">Devices</a>
                             <a href="{% url 'dcim:device_list' %}">Devices</a>
                         </li>
                         </li>
-                        <li>
+                        <li{% if not perms.dcim.view_devicerole %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_devicerole %}
                             {% if perms.dcim.add_devicerole %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'dcim:devicerole_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'dcim:devicerole_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
@@ -135,7 +135,7 @@
                             {% endif %}
                             {% endif %}
                             <a href="{% url 'dcim:devicerole_list' %}">Device Roles</a>
                             <a href="{% url 'dcim:devicerole_list' %}">Device Roles</a>
                         </li>
                         </li>
-                        <li>
+                        <li{% if not perms.dcim.view_platform %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_platform %}
                             {% if perms.dcim.add_platform %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'dcim:platform_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'dcim:platform_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
@@ -144,12 +144,12 @@
                             {% endif %}
                             {% endif %}
                             <a href="{% url 'dcim:platform_list' %}">Platforms</a>
                             <a href="{% url 'dcim:platform_list' %}">Platforms</a>
                         </li>
                         </li>
-                        <li>
+                        <li{% if not perms.dcim.view_virtualchassis %} class="disabled"{% endif %}>
                             <a href="{% url 'dcim:virtualchassis_list' %}">Virtual Chassis</a>
                             <a href="{% url 'dcim:virtualchassis_list' %}">Virtual Chassis</a>
                         </li>
                         </li>
                         <li class="divider"></li>
                         <li class="divider"></li>
                         <li class="dropdown-header">Device Types</li>
                         <li class="dropdown-header">Device Types</li>
-                        <li>
+                        <li{% if not perms.dcim.view_devicetype %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_devicetype %}
                             {% if perms.dcim.add_devicetype %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'dcim:devicetype_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'dcim:devicetype_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
@@ -158,7 +158,7 @@
                             {% endif %}
                             {% endif %}
                             <a href="{% url 'dcim:devicetype_list' %}">Device Types</a>
                             <a href="{% url 'dcim:devicetype_list' %}">Device Types</a>
                         </li>
                         </li>
-                        <li>
+                        <li{% if not perms.dcim.view_manufacturer %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_manufacturer %}
                             {% if perms.dcim.add_manufacturer %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'dcim:manufacturer_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'dcim:manufacturer_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
@@ -169,7 +169,7 @@
                         </li>
                         </li>
                         <li class="divider"></li>
                         <li class="divider"></li>
                         <li class="dropdown-header">Inventory</li>
                         <li class="dropdown-header">Inventory</li>
-                        <li>
+                        <li{% if not perms.dcim.view_inventoryitem %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_inventoryitem %}
                             {% if perms.dcim.add_inventoryitem %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'dcim:inventoryitem_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
                                     <a href="{% url 'dcim:inventoryitem_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
@@ -179,7 +179,7 @@
                         </li>
                         </li>
                         <li class="divider"></li>
                         <li class="divider"></li>
                         <li class="dropdown-header">Connections</li>
                         <li class="dropdown-header">Connections</li>
-                        <li>
+                        <li{% if not perms.dcim.view_cable %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_cable %}
                             {% if perms.dcim.add_cable %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'dcim:cable_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
                                     <a href="{% url 'dcim:cable_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
@@ -187,13 +187,13 @@
                             {% endif %}
                             {% endif %}
                             <a href="{% url 'dcim:cable_list' %}">Cables</a>
                             <a href="{% url 'dcim:cable_list' %}">Cables</a>
                         </li>
                         </li>
-                        <li>
+                        <li{% if not perms.dcim.view_consoleport or not perms.dcim.view_consoleserverport %} class="disabled"{% endif %}>
                             <a href="{% url 'dcim:console_connections_list' %}">Console Connections</a>
                             <a href="{% url 'dcim:console_connections_list' %}">Console Connections</a>
                         </li>
                         </li>
-                        <li>
+                        <li{% if not perms.dcim.view_powerport or not perms.dcim.view_poweroutlet %} class="disabled"{% endif %}>
                             <a href="{% url 'dcim:power_connections_list' %}">Power Connections</a>
                             <a href="{% url 'dcim:power_connections_list' %}">Power Connections</a>
                         </li>
                         </li>
-                        <li>
+                        <li{% if not perms.dcim.view_interface %} class="disabled"{% endif %}>
                             <a href="{% url 'dcim:interface_connections_list' %}">Interface Connections</a>
                             <a href="{% url 'dcim:interface_connections_list' %}">Interface Connections</a>
                         </li>
                         </li>
                     </ul>
                     </ul>
@@ -202,7 +202,7 @@
                     <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">IPAM <span class="caret"></span></a>
                     <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">IPAM <span class="caret"></span></a>
                     <ul class="dropdown-menu">
                     <ul class="dropdown-menu">
                         <li class="dropdown-header">IP Addresses</li>
                         <li class="dropdown-header">IP Addresses</li>
-                        <li>
+                        <li{% if not perms.ipam.view_ipaddress %} class="disabled"{% endif %}>
                             {% if perms.ipam.add_ipaddress %}
                             {% if perms.ipam.add_ipaddress %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'ipam:ipaddress_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'ipam:ipaddress_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
@@ -213,7 +213,7 @@
                         </li>
                         </li>
                         <li class="divider"></li>
                         <li class="divider"></li>
                         <li class="dropdown-header">Prefixes</li>
                         <li class="dropdown-header">Prefixes</li>
-                        <li>
+                        <li{% if not perms.ipam.view_prefix %} class="disabled"{% endif %}>
                             {% if perms.ipam.add_prefix %}
                             {% if perms.ipam.add_prefix %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'ipam:prefix_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'ipam:prefix_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
@@ -222,7 +222,7 @@
                             {% endif %}
                             {% endif %}
                             <a href="{% url 'ipam:prefix_list' %}">Prefixes</a>
                             <a href="{% url 'ipam:prefix_list' %}">Prefixes</a>
                         </li>
                         </li>
-                        <li>
+                        <li{% if not perms.ipam.view_role %} class="disabled"{% endif %}>
                             {% if perms.ipam.add_role %}
                             {% if perms.ipam.add_role %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'ipam:role_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'ipam:role_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
@@ -233,7 +233,7 @@
                         </li>
                         </li>
                         <li class="divider"></li>
                         <li class="divider"></li>
                         <li class="dropdown-header">Aggregates</li>
                         <li class="dropdown-header">Aggregates</li>
-                        <li>
+                        <li{% if not perms.ipam.view_aggregate %} class="disabled"{% endif %}>
                             {% if perms.ipam.add_aggregate %}
                             {% if perms.ipam.add_aggregate %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'ipam:aggregate_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'ipam:aggregate_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
@@ -242,7 +242,7 @@
                             {% endif %}
                             {% endif %}
                             <a href="{% url 'ipam:aggregate_list' %}">Aggregates</a>
                             <a href="{% url 'ipam:aggregate_list' %}">Aggregates</a>
                         </li>
                         </li>
-                        <li>
+                        <li{% if not perms.ipam.view_rir %} class="disabled"{% endif %}>
                             {% if perms.ipam.add_rir %}
                             {% if perms.ipam.add_rir %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'ipam:rir_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'ipam:rir_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
@@ -253,7 +253,7 @@
                         </li>
                         </li>
                         <li class="divider"></li>
                         <li class="divider"></li>
                         <li class="dropdown-header">VRFs</li>
                         <li class="dropdown-header">VRFs</li>
-                        <li>
+                        <li{% if not perms.ipam.view_vrf %} class="disabled"{% endif %}>
                             {% if perms.ipam.add_vrf %}
                             {% if perms.ipam.add_vrf %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'ipam:vrf_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'ipam:vrf_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
@@ -264,7 +264,7 @@
                         </li>
                         </li>
                         <li class="divider"></li>
                         <li class="divider"></li>
                         <li class="dropdown-header">VLANs</li>
                         <li class="dropdown-header">VLANs</li>
-                        <li>
+                        <li{% if not perms.ipam.view_vlan %} class="disabled"{% endif %}>
                             {% if perms.ipam.add_vlan %}
                             {% if perms.ipam.add_vlan %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'ipam:vlan_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'ipam:vlan_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
@@ -273,7 +273,7 @@
                             {% endif %}
                             {% endif %}
                             <a href="{% url 'ipam:vlan_list' %}">VLANs</a>
                             <a href="{% url 'ipam:vlan_list' %}">VLANs</a>
                         </li>
                         </li>
-                        <li>
+                        <li{% if not perms.ipam.view_vlangroup %} class="disabled"{% endif %}>
                             {% if perms.ipam.add_vlangroup %}
                             {% if perms.ipam.add_vlangroup %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'ipam:vlangroup_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'ipam:vlangroup_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
@@ -284,7 +284,7 @@
                         </li>
                         </li>
                         <li class="divider"></li>
                         <li class="divider"></li>
                         <li class="dropdown-header">Services</li>
                         <li class="dropdown-header">Services</li>
-                        <li>
+                        <li{% if not perms.ipam.view_service %} class="disabled"{% endif %}>
                             <a href="{% url 'ipam:service_list' %}">Services</a>
                             <a href="{% url 'ipam:service_list' %}">Services</a>
                         </li>
                         </li>
                     </ul>
                     </ul>
@@ -293,7 +293,7 @@
                     <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Virtualization <span class="caret"></span></a>
                     <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Virtualization <span class="caret"></span></a>
                     <ul class="dropdown-menu">
                     <ul class="dropdown-menu">
                         <li class="dropdown-header">Virtual Machines</li>
                         <li class="dropdown-header">Virtual Machines</li>
-                        <li>
+                        <li{% if not perms.virtualization.view_virtualmachine %} class="disabled"{% endif %}>
                             {% if perms.virtualization.add_virtualmachine %}
                             {% if perms.virtualization.add_virtualmachine %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'virtualization:virtualmachine_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'virtualization:virtualmachine_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
@@ -304,7 +304,7 @@
                         </li>
                         </li>
                         <li class="divider"></li>
                         <li class="divider"></li>
                         <li class="dropdown-header">Clusters</li>
                         <li class="dropdown-header">Clusters</li>
-                        <li>
+                        <li{% if not perms.virtualization.view_cluster %} class="disabled"{% endif %}>
                             {% if perms.virtualization.add_cluster %}
                             {% if perms.virtualization.add_cluster %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'virtualization:cluster_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'virtualization:cluster_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
@@ -313,7 +313,7 @@
                             {% endif %}
                             {% endif %}
                             <a href="{% url 'virtualization:cluster_list' %}">Clusters</a>
                             <a href="{% url 'virtualization:cluster_list' %}">Clusters</a>
                         </li>
                         </li>
-                        <li>
+                        <li{% if not perms.virtualization.view_clustertype %} class="disabled"{% endif %}>
                             {% if perms.virtualization.add_clustertype %}
                             {% if perms.virtualization.add_clustertype %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'virtualization:clustertype_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'virtualization:clustertype_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
@@ -322,7 +322,7 @@
                             {% endif %}
                             {% endif %}
                             <a href="{% url 'virtualization:clustertype_list' %}">Cluster Types</a>
                             <a href="{% url 'virtualization:clustertype_list' %}">Cluster Types</a>
                         </li>
                         </li>
-                        <li>
+                        <li{% if not perms.virtualization.view_clustergroup %} class="disabled"{% endif %}>
                             {% if perms.virtualization.add_clustergroup %}
                             {% if perms.virtualization.add_clustergroup %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'virtualization:clustergroup_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'virtualization:clustergroup_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
@@ -337,7 +337,7 @@
                     <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Circuits <span class="caret"></span></a>
                     <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Circuits <span class="caret"></span></a>
                     <ul class="dropdown-menu">
                     <ul class="dropdown-menu">
                         <li class="dropdown-header">Circuits</li>
                         <li class="dropdown-header">Circuits</li>
-                        <li>
+                        <li{% if not perms.circuits.view_circuit %} class="disabled"{% endif %}>
                             {% if perms.circuits.add_circuit %}
                             {% if perms.circuits.add_circuit %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'circuits:circuit_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'circuits:circuit_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
@@ -346,7 +346,7 @@
                             {% endif %}
                             {% endif %}
                             <a href="{% url 'circuits:circuit_list' %}">Circuits</a>
                             <a href="{% url 'circuits:circuit_list' %}">Circuits</a>
                         </li>
                         </li>
-                        <li>
+                        <li{% if not perms.circuits.view_circuittype %} class="disabled"{% endif %}>
                             {% if perms.circuits.add_circuittype %}
                             {% if perms.circuits.add_circuittype %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'circuits:circuittype_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'circuits:circuittype_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
@@ -357,7 +357,7 @@
                         </li>
                         </li>
                         <li class="divider"></li>
                         <li class="divider"></li>
                         <li class="dropdown-header">Providers</li>
                         <li class="dropdown-header">Providers</li>
-                        <li>
+                        <li{% if not perms.circuits.view_provider %} class="disabled"{% endif %}>
                             {% if perms.circuits.add_provider %}
                             {% if perms.circuits.add_provider %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'circuits:provider_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'circuits:provider_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
@@ -371,7 +371,7 @@
                 <li class="dropdown{% if request.path|contains:'/dcim/power' %} active{% endif %}">
                 <li class="dropdown{% if request.path|contains:'/dcim/power' %} active{% endif %}">
                     <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Power <span class="caret"></span></a>
                     <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Power <span class="caret"></span></a>
                     <ul class="dropdown-menu">
                     <ul class="dropdown-menu">
-                        <li>
+                        <li{% if not perms.dcim.view_powerfeed %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_powerfeed %}
                             {% if perms.dcim.add_powerfeed %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'dcim:powerfeed_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'dcim:powerfeed_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
@@ -380,7 +380,7 @@
                             {% endif %}
                             {% endif %}
                             <a href="{% url 'dcim:powerfeed_list' %}">Power Feeds</a>
                             <a href="{% url 'dcim:powerfeed_list' %}">Power Feeds</a>
                         </li>
                         </li>
-                        <li>
+                        <li{% if not perms.dcim.view_powerpanel %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_powerpanel %}
                             {% if perms.dcim.add_powerpanel %}
                                 <div class="buttons pull-right">
                                 <div class="buttons pull-right">
                                     <a href="{% url 'dcim:powerpanel_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'dcim:powerpanel_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>

+ 5 - 3
netbox/templates/ipam/aggregate.html

@@ -43,9 +43,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ aggregate.get_absolute_url }}">Aggregate</a>
             <a href="{{ aggregate.get_absolute_url }}">Aggregate</a>
         </li>
         </li>
-        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-            <a href="{% url 'ipam:aggregate_changelog' pk=aggregate.pk %}">Changelog</a>
-        </li>
+        {% if perms.extras.view_objectchange %}
+            <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+                <a href="{% url 'ipam:aggregate_changelog' pk=aggregate.pk %}">Changelog</a>
+            </li>
+        {% endif %}
     </ul>
     </ul>
 {% endblock %}
 {% endblock %}
 
 

+ 5 - 3
netbox/templates/ipam/ipaddress.html

@@ -45,9 +45,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ ipaddress.get_absolute_url }}">IP Address</a>
             <a href="{{ ipaddress.get_absolute_url }}">IP Address</a>
         </li>
         </li>
-        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-            <a href="{% url 'ipam:ipaddress_changelog' pk=ipaddress.pk %}">Changelog</a>
-        </li>
+        {% if perms.extras.view_objectchange %}
+            <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+                <a href="{% url 'ipam:ipaddress_changelog' pk=ipaddress.pk %}">Changelog</a>
+            </li>
+        {% endif %}
     </ul>
     </ul>
 {% endblock %}
 {% endblock %}
 
 

+ 10 - 6
netbox/templates/ipam/prefix.html

@@ -59,12 +59,16 @@
         <li role="presentation"{% if active_tab == 'prefixes' %} class="active"{% endif %}>
         <li role="presentation"{% if active_tab == 'prefixes' %} class="active"{% endif %}>
             <a href="{% url 'ipam:prefix_prefixes' pk=prefix.pk %}">Child Prefixes <span class="badge">{{ prefix.get_child_prefixes.count }}</span></a>
             <a href="{% url 'ipam:prefix_prefixes' pk=prefix.pk %}">Child Prefixes <span class="badge">{{ prefix.get_child_prefixes.count }}</span></a>
         </li>
         </li>
-        <li role="presentation"{% if active_tab == 'ip-addresses' %} class="active"{% endif %}>
-            <a href="{% url 'ipam:prefix_ipaddresses' pk=prefix.pk %}">IP Addresses <span class="badge">{{ prefix.get_child_ips.count }}</span></a>
-        </li>
-        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-            <a href="{% url 'ipam:prefix_changelog' pk=prefix.pk %}">Changelog</a>
-        </li>
+        {% if perms.ipam.view_ipaddress %}
+            <li role="presentation"{% if active_tab == 'ip-addresses' %} class="active"{% endif %}>
+                <a href="{% url 'ipam:prefix_ipaddresses' pk=prefix.pk %}">IP Addresses <span class="badge">{{ prefix.get_child_ips.count }}</span></a>
+            </li>
+        {% endif %}
+        {% if perms.extras.view_objectchange %}
+            <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+                <a href="{% url 'ipam:prefix_changelog' pk=prefix.pk %}">Changelog</a>
+            </li>
+        {% endif %}
     </ul>
     </ul>
 {% endblock %}
 {% endblock %}
 
 

+ 5 - 3
netbox/templates/ipam/vlan.html

@@ -51,9 +51,11 @@
         <li role="presentation"{% if active_tab == 'members' %} class="active"{% endif %}>
         <li role="presentation"{% if active_tab == 'members' %} class="active"{% endif %}>
             <a href="{% url 'ipam:vlan_members' pk=vlan.pk %}">Members <span class="badge">{{ vlan.get_members.count }}</span></a>
             <a href="{% url 'ipam:vlan_members' pk=vlan.pk %}">Members <span class="badge">{{ vlan.get_members.count }}</span></a>
         </li>
         </li>
-        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-            <a href="{% url 'ipam:vlan_changelog' pk=vlan.pk %}">Changelog</a>
-        </li>
+        {% if perms.extras.view_objectchange %}
+            <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+                <a href="{% url 'ipam:vlan_changelog' pk=vlan.pk %}">Changelog</a>
+            </li>
+        {% endif %}
     </ul>
     </ul>
 {% endblock %}
 {% endblock %}
 
 

+ 5 - 3
netbox/templates/ipam/vrf.html

@@ -42,9 +42,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ vrf.get_absolute_url }}">VRF</a>
             <a href="{{ vrf.get_absolute_url }}">VRF</a>
         </li>
         </li>
-        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-            <a href="{% url 'ipam:vrf_changelog' pk=vrf.pk %}">Changelog</a>
-        </li>
+        {% if perms.extras.view_objectchange %}
+            <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+                <a href="{% url 'ipam:vrf_changelog' pk=vrf.pk %}">Changelog</a>
+            </li>
+        {% endif %}
     </ul>
     </ul>
 {% endblock %}
 {% endblock %}
 
 

+ 5 - 3
netbox/templates/secrets/secret.html

@@ -33,9 +33,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ secret.get_absolute_url }}">Secret</a>
             <a href="{{ secret.get_absolute_url }}">Secret</a>
         </li>
         </li>
-        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-            <a href="{% url 'secrets:secret_changelog' pk=secret.pk %}">Changelog</a>
-        </li>
+        {% if perms.extras.view_objectchange %}
+            <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+                <a href="{% url 'secrets:secret_changelog' pk=secret.pk %}">Changelog</a>
+            </li>
+        {% endif %}
     </ul>
     </ul>
 {% endblock %}
 {% endblock %}
 
 

+ 5 - 3
netbox/templates/tenancy/tenant.html

@@ -45,9 +45,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ tenant.get_absolute_url }}">Tenant</a>
             <a href="{{ tenant.get_absolute_url }}">Tenant</a>
         </li>
         </li>
-        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-            <a href="{% url 'tenancy:tenant_changelog' slug=tenant.slug %}">Changelog</a>
-        </li>
+        {% if perms.extras.view_objectchange %}
+            <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+                <a href="{% url 'tenancy:tenant_changelog' slug=tenant.slug %}">Changelog</a>
+            </li>
+        {% endif %}
     </ul>
     </ul>
 {% endblock %}
 {% endblock %}
 
 

+ 5 - 3
netbox/templates/virtualization/cluster.html

@@ -45,9 +45,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ cluster.get_absolute_url }}">Cluster</a>
             <a href="{{ cluster.get_absolute_url }}">Cluster</a>
         </li>
         </li>
-        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-            <a href="{% url 'virtualization:cluster_changelog' pk=cluster.pk %}">Changelog</a>
-        </li>
+        {% if perms.extras.view_objectchange %}
+            <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+                <a href="{% url 'virtualization:cluster_changelog' pk=cluster.pk %}">Changelog</a>
+            </li>
+        {% endif %}
     </ul>
     </ul>
 {% endblock %}
 {% endblock %}
 
 

+ 10 - 6
netbox/templates/virtualization/virtualmachine.html

@@ -44,12 +44,16 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ virtualmachine.get_absolute_url }}">Virtual Machine</a>
             <a href="{{ virtualmachine.get_absolute_url }}">Virtual Machine</a>
         </li>
         </li>
-        <li role="presentation"{% if active_tab == 'config-context' %} class="active"{% endif %}>
-            <a href="{% url 'virtualization:virtualmachine_configcontext' pk=virtualmachine.pk %}">Config Context</a>
-        </li>
-        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-            <a href="{% url 'virtualization:virtualmachine_changelog' pk=virtualmachine.pk %}">Changelog</a>
-        </li>
+        {% if perms.extras.view_configcontext %}
+            <li role="presentation"{% if active_tab == 'config-context' %} class="active"{% endif %}>
+                <a href="{% url 'virtualization:virtualmachine_configcontext' pk=virtualmachine.pk %}">Config Context</a>
+            </li>
+        {% endif %}
+        {% if perms.extras.view_objectchange %}
+            <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+                <a href="{% url 'virtualization:virtualmachine_changelog' pk=virtualmachine.pk %}">Changelog</a>
+            </li>
+        {% endif %}
     </ul>
     </ul>
 {% endblock %}
 {% endblock %}
 
 

+ 5 - 2
netbox/tenancy/tests/test_views.py

@@ -4,13 +4,15 @@ from django.test import Client, TestCase
 from django.urls import reverse
 from django.urls import reverse
 
 
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
+from utilities.testing import create_test_user
 
 
 
 
 class TenantGroupTestCase(TestCase):
 class TenantGroupTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['tenancy.view_tenantgroup'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         TenantGroup.objects.bulk_create([
         TenantGroup.objects.bulk_create([
             TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
             TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
@@ -29,8 +31,9 @@ class TenantGroupTestCase(TestCase):
 class TenantTestCase(TestCase):
 class TenantTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['tenancy.view_tenant'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         tenantgroup = TenantGroup(name='Tenant Group 1', slug='tenant-group-1')
         tenantgroup = TenantGroup(name='Tenant Group 1', slug='tenant-group-1')
         tenantgroup.save()
         tenantgroup.save()

+ 6 - 3
netbox/tenancy/views.py

@@ -18,7 +18,8 @@ from .models import Tenant, TenantGroup
 # Tenant groups
 # Tenant groups
 #
 #
 
 
-class TenantGroupListView(ObjectListView):
+class TenantGroupListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'tenancy.view_tenantgroup'
     queryset = TenantGroup.objects.annotate(tenant_count=Count('tenants'))
     queryset = TenantGroup.objects.annotate(tenant_count=Count('tenants'))
     table = tables.TenantGroupTable
     table = tables.TenantGroupTable
     template_name = 'tenancy/tenantgroup_list.html'
     template_name = 'tenancy/tenantgroup_list.html'
@@ -53,7 +54,8 @@ class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #  Tenants
 #  Tenants
 #
 #
 
 
-class TenantListView(ObjectListView):
+class TenantListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'tenancy.view_tenant'
     queryset = Tenant.objects.select_related('group')
     queryset = Tenant.objects.select_related('group')
     filter = filters.TenantFilter
     filter = filters.TenantFilter
     filter_form = forms.TenantFilterForm
     filter_form = forms.TenantFilterForm
@@ -61,7 +63,8 @@ class TenantListView(ObjectListView):
     template_name = 'tenancy/tenant_list.html'
     template_name = 'tenancy/tenant_list.html'
 
 
 
 
-class TenantView(View):
+class TenantView(PermissionRequiredMixin, View):
+    permission_required = 'tenancy.view_tenant'
 
 
     def get(self, request, slug):
     def get(self, request, slug):
 
 

+ 4 - 10
netbox/users/views.py

@@ -1,6 +1,5 @@
 from django.contrib import messages
 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 import login as auth_login, logout as auth_logout, update_session_auth_hash
-from django.contrib.auth.decorators import login_required
 from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
 from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
 from django.http import HttpResponseForbidden, HttpResponseRedirect
 from django.http import HttpResponseForbidden, HttpResponseRedirect
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
@@ -74,8 +73,7 @@ class LogoutView(View):
 # User profiles
 # User profiles
 #
 #
 
 
-@method_decorator(login_required, name='dispatch')
-class ProfileView(View):
+class ProfileView(LoginRequiredMixin, View):
     template_name = 'users/profile.html'
     template_name = 'users/profile.html'
 
 
     def get(self, request):
     def get(self, request):
@@ -85,8 +83,7 @@ class ProfileView(View):
         })
         })
 
 
 
 
-@method_decorator(login_required, name='dispatch')
-class ChangePasswordView(View):
+class ChangePasswordView(LoginRequiredMixin, View):
     template_name = 'users/change_password.html'
     template_name = 'users/change_password.html'
 
 
     def get(self, request):
     def get(self, request):
@@ -111,8 +108,7 @@ class ChangePasswordView(View):
         })
         })
 
 
 
 
-@method_decorator(login_required, name='dispatch')
-class UserKeyView(View):
+class UserKeyView(LoginRequiredMixin, View):
     template_name = 'users/userkey.html'
     template_name = 'users/userkey.html'
 
 
     def get(self, request):
     def get(self, request):
@@ -127,10 +123,9 @@ class UserKeyView(View):
         })
         })
 
 
 
 
-class UserKeyEditView(View):
+class UserKeyEditView(LoginRequiredMixin, View):
     template_name = 'users/userkey_edit.html'
     template_name = 'users/userkey_edit.html'
 
 
-    @method_decorator(login_required)
     def dispatch(self, request, *args, **kwargs):
     def dispatch(self, request, *args, **kwargs):
         try:
         try:
             self.userkey = UserKey.objects.get(user=request.user)
             self.userkey = UserKey.objects.get(user=request.user)
@@ -164,7 +159,6 @@ class UserKeyEditView(View):
         })
         })
 
 
 
 
-@method_decorator(login_required, name='dispatch')
 class SessionKeyDeleteView(LoginRequiredMixin, View):
 class SessionKeyDeleteView(LoginRequiredMixin, View):
 
 
     def get(self, request):
     def get(self, request):

+ 28 - 0
netbox/utilities/auth_backends.py

@@ -0,0 +1,28 @@
+from django.conf import settings
+from django.contrib.auth.backends import ModelBackend
+
+
+class ViewExemptModelBackend(ModelBackend):
+    """
+    Custom implementation of Django's stock ModelBackend which allows for the exemption of arbitrary models from view
+    permission enforcement.
+    """
+    def has_perm(self, user_obj, perm, obj=None):
+
+        # If this is a view permission, check whether the model has been exempted from enforcement
+        try:
+            app, codename = perm.split('.')
+            action, model = codename.split('_')
+            if action == 'view':
+                if (
+                    # All models are exempt from view permission enforcement
+                    '*' in settings.EXEMPT_VIEW_PERMISSIONS
+                ) or (
+                    # This specific model is exempt from view permission enforcement
+                    '{}.{}'.format(app, model) in settings.EXEMPT_VIEW_PERMISSIONS
+                ):
+                    return True
+        except ValueError:
+            pass
+
+        return super().has_perm(user_obj, perm, obj)

+ 14 - 1
netbox/utilities/testing.py

@@ -1,4 +1,4 @@
-from django.contrib.auth.models import User
+from django.contrib.auth.models import Permission, User
 from rest_framework.test import APITestCase as _APITestCase
 from rest_framework.test import APITestCase as _APITestCase
 
 
 from users.models import Token
 from users.models import Token
@@ -22,3 +22,16 @@ class APITestCase(_APITestCase):
         self.assertEqual(response.status_code, expected_status, err_message.format(
         self.assertEqual(response.status_code, expected_status, err_message.format(
             expected_status, response.status_code, response.data
             expected_status, response.status_code, response.data
         ))
         ))
+
+
+def create_test_user(username='testuser', permissions=list()):
+    """
+    Create a User with the given permissions.
+    """
+    user = User.objects.create_user(username=username)
+    for perm_name in permissions:
+        app, codename = perm_name.split('.')
+        perm = Permission.objects.get(content_type__app_label=app, codename=codename)
+        user.user_permissions.add(perm)
+
+    return user

+ 9 - 4
netbox/virtualization/tests/test_views.py

@@ -3,14 +3,16 @@ import urllib.parse
 from django.test import Client, TestCase
 from django.test import Client, TestCase
 from django.urls import reverse
 from django.urls import reverse
 
 
+from utilities.testing import create_test_user
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 
 
 
 
 class ClusterGroupTestCase(TestCase):
 class ClusterGroupTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['virtualization.view_clustergroup'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         ClusterGroup.objects.bulk_create([
         ClusterGroup.objects.bulk_create([
             ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'),
             ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'),
@@ -29,8 +31,9 @@ class ClusterGroupTestCase(TestCase):
 class ClusterTypeTestCase(TestCase):
 class ClusterTypeTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['virtualization.view_clustertype'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         ClusterType.objects.bulk_create([
         ClusterType.objects.bulk_create([
             ClusterType(name='Cluster Type 1', slug='cluster-type-1'),
             ClusterType(name='Cluster Type 1', slug='cluster-type-1'),
@@ -49,8 +52,9 @@ class ClusterTypeTestCase(TestCase):
 class ClusterTestCase(TestCase):
 class ClusterTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['virtualization.view_cluster'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         clustergroup = ClusterGroup(name='Cluster Group 1', slug='cluster-group-1')
         clustergroup = ClusterGroup(name='Cluster Group 1', slug='cluster-group-1')
         clustergroup.save()
         clustergroup.save()
@@ -85,8 +89,9 @@ class ClusterTestCase(TestCase):
 class VirtualMachineTestCase(TestCase):
 class VirtualMachineTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['virtualization.view_virtualmachine'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         clustertype = ClusterType(name='Cluster Type 1', slug='cluster-type-1')
         clustertype = ClusterType(name='Cluster Type 1', slug='cluster-type-1')
         clustertype.save()
         clustertype.save()

+ 14 - 7
netbox/virtualization/views.py

@@ -22,7 +22,8 @@ from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 # Cluster types
 # Cluster types
 #
 #
 
 
-class ClusterTypeListView(ObjectListView):
+class ClusterTypeListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'virtualization.view_clustertype'
     queryset = ClusterType.objects.annotate(cluster_count=Count('clusters'))
     queryset = ClusterType.objects.annotate(cluster_count=Count('clusters'))
     table = tables.ClusterTypeTable
     table = tables.ClusterTypeTable
     template_name = 'virtualization/clustertype_list.html'
     template_name = 'virtualization/clustertype_list.html'
@@ -57,7 +58,8 @@ class ClusterTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Cluster groups
 # Cluster groups
 #
 #
 
 
-class ClusterGroupListView(ObjectListView):
+class ClusterGroupListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'virtualization.view_clustergroup'
     queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters'))
     queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters'))
     table = tables.ClusterGroupTable
     table = tables.ClusterGroupTable
     template_name = 'virtualization/clustergroup_list.html'
     template_name = 'virtualization/clustergroup_list.html'
@@ -92,7 +94,8 @@ class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Clusters
 # Clusters
 #
 #
 
 
-class ClusterListView(ObjectListView):
+class ClusterListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'virtualization.view_cluster'
     queryset = Cluster.objects.select_related('type', 'group', 'site')
     queryset = Cluster.objects.select_related('type', 'group', 'site')
     table = tables.ClusterTable
     table = tables.ClusterTable
     filter = filters.ClusterFilter
     filter = filters.ClusterFilter
@@ -100,7 +103,8 @@ class ClusterListView(ObjectListView):
     template_name = 'virtualization/cluster_list.html'
     template_name = 'virtualization/cluster_list.html'
 
 
 
 
-class ClusterView(View):
+class ClusterView(PermissionRequiredMixin, View):
+    permission_required = 'virtualization.view_cluster'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -247,7 +251,8 @@ class ClusterRemoveDevicesView(PermissionRequiredMixin, View):
 # Virtual machines
 # Virtual machines
 #
 #
 
 
-class VirtualMachineListView(ObjectListView):
+class VirtualMachineListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'virtualization.view_virtualmachine'
     queryset = VirtualMachine.objects.select_related('cluster', 'tenant', 'role', 'primary_ip4', 'primary_ip6')
     queryset = VirtualMachine.objects.select_related('cluster', 'tenant', 'role', 'primary_ip4', 'primary_ip6')
     filter = filters.VirtualMachineFilter
     filter = filters.VirtualMachineFilter
     filter_form = forms.VirtualMachineFilterForm
     filter_form = forms.VirtualMachineFilterForm
@@ -255,7 +260,8 @@ class VirtualMachineListView(ObjectListView):
     template_name = 'virtualization/virtualmachine_list.html'
     template_name = 'virtualization/virtualmachine_list.html'
 
 
 
 
-class VirtualMachineView(View):
+class VirtualMachineView(PermissionRequiredMixin, View):
+    permission_required = 'virtualization.view_virtualmachine'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -270,7 +276,8 @@ class VirtualMachineView(View):
         })
         })
 
 
 
 
-class VirtualMachineConfigContextView(ObjectConfigContextView):
+class VirtualMachineConfigContextView(PermissionRequiredMixin, ObjectConfigContextView):
+    permission_required = 'virtualization.view_virtualmachine'
     object_class = VirtualMachine
     object_class = VirtualMachine
     base_template = 'virtualization/virtualmachine.html'
     base_template = 'virtualization/virtualmachine.html'