Переглянути джерело

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

Closes #323
Jeremy Stretch 6 роки тому
батько
коміт
573af6a236
42 змінених файлів з 676 додано та 404 видалено
  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
 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
 
+* [#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
 * [#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

+ 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
 
 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 circuits.models import Circuit, CircuitType, Provider
+from utilities.testing import create_test_user
 
 
 class ProviderTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['circuits.view_provider'])
         self.client = Client()
+        self.client.force_login(user)
 
         Provider.objects.bulk_create([
             Provider(name='Provider 1', slug='provider-1', asn=65001),
@@ -38,8 +40,9 @@ class ProviderTestCase(TestCase):
 class CircuitTypeTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['circuits.view_circuittype'])
         self.client = Client()
+        self.client.force_login(user)
 
         CircuitType.objects.bulk_create([
             CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
@@ -58,8 +61,9 @@ class CircuitTypeTestCase(TestCase):
 class CircuitTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['circuits.view_circuit'])
         self.client = Client()
+        self.client.force_login(user)
 
         provider = Provider(name='Provider 1', slug='provider-1', asn=65001)
         provider.save()
@@ -84,8 +88,8 @@ class CircuitTestCase(TestCase):
         response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
         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)

+ 10 - 5
netbox/circuits/views.py

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

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

@@ -1,6 +1,5 @@
 import urllib.parse
 
-from django.contrib.auth import get_user_model
 from django.test import Client, TestCase
 from django.urls import reverse
 
@@ -9,13 +8,15 @@ from dcim.models import (
     Cable, Device, DeviceRole, DeviceType, Interface, InventoryItem, Manufacturer, Platform, Rack, RackGroup,
     RackReservation, RackRole, Site, Region, VirtualChassis,
 )
+from utilities.testing import create_test_user
 
 
 class RegionTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_region'])
         self.client = Client()
+        self.client.force_login(user)
 
         # Create three Regions
         for i in range(1, 4):
@@ -32,8 +33,9 @@ class RegionTestCase(TestCase):
 class SiteTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_site'])
         self.client = Client()
+        self.client.force_login(user)
 
         region = Region(name='Region 1', slug='region-1')
         region.save()
@@ -64,8 +66,9 @@ class SiteTestCase(TestCase):
 class RackGroupTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_rackgroup'])
         self.client = Client()
+        self.client.force_login(user)
 
         site = Site(name='Site 1', slug='site-1')
         site.save()
@@ -84,11 +87,12 @@ class RackGroupTestCase(TestCase):
         self.assertEqual(response.status_code, 200)
 
 
-class RackTypeTestCase(TestCase):
+class RackRoleTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_rackrole'])
         self.client = Client()
+        self.client.force_login(user)
 
         RackRole.objects.bulk_create([
             RackRole(name='Rack Role 1', slug='rack-role-1'),
@@ -107,12 +111,9 @@ class RackTypeTestCase(TestCase):
 class RackReservationTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_rackreservation'])
         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.save()
@@ -137,8 +138,9 @@ class RackReservationTestCase(TestCase):
 class RackTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_rack'])
         self.client = Client()
+        self.client.force_login(user)
 
         site = Site(name='Site 1', slug='site-1')
         site.save()
@@ -169,8 +171,9 @@ class RackTestCase(TestCase):
 class ManufacturerTypeTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_manufacturer'])
         self.client = Client()
+        self.client.force_login(user)
 
         Manufacturer.objects.bulk_create([
             Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
@@ -189,8 +192,9 @@ class ManufacturerTypeTestCase(TestCase):
 class DeviceTypeTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_devicetype'])
         self.client = Client()
+        self.client.force_login(user)
 
         manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
         manufacturer.save()
@@ -221,8 +225,9 @@ class DeviceTypeTestCase(TestCase):
 class DeviceRoleTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_devicerole'])
         self.client = Client()
+        self.client.force_login(user)
 
         DeviceRole.objects.bulk_create([
             DeviceRole(name='Device Role 1', slug='device-role-1'),
@@ -241,8 +246,9 @@ class DeviceRoleTestCase(TestCase):
 class PlatformTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_platform'])
         self.client = Client()
+        self.client.force_login(user)
 
         Platform.objects.bulk_create([
             Platform(name='Platform 1', slug='platform-1'),
@@ -261,8 +267,9 @@ class PlatformTestCase(TestCase):
 class DeviceTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_device'])
         self.client = Client()
+        self.client.force_login(user)
 
         site = Site(name='Site 1', slug='site-1')
         site.save()
@@ -303,8 +310,9 @@ class DeviceTestCase(TestCase):
 class InventoryItemTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_inventoryitem'])
         self.client = Client()
+        self.client.force_login(user)
 
         site = Site(name='Site 1', slug='site-1')
         site.save()
@@ -337,18 +345,13 @@ class InventoryItemTestCase(TestCase):
         response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
         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):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_cable'])
         self.client = Client()
+        self.client.force_login(user)
 
         site = Site(name='Site 1', slug='site-1')
         site.save()
@@ -401,11 +404,12 @@ class CableTestCase(TestCase):
         self.assertEqual(response.status_code, 200)
 
 
-class VirtualMachineTestCase(TestCase):
+class VirtualChassisTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_virtualchassis'])
         self.client = Client()
+        self.client.force_login(user)
 
         site = Site.objects.create(name='Site 1', slug='site-1')
         manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer-1')
@@ -450,9 +454,3 @@ class VirtualMachineTestCase(TestCase):
 
         response = self.client.get(url)
         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
 #
 
-class RegionListView(ObjectListView):
+class RegionListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_region'
     queryset = Region.objects.add_related_count(
         Region.objects.all(),
         Site,
@@ -182,7 +183,8 @@ class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Sites
 #
 
-class SiteListView(ObjectListView):
+class SiteListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_site'
     queryset = Site.objects.select_related('region', 'tenant')
     filter = filters.SiteFilter
     filter_form = forms.SiteFilterForm
@@ -190,7 +192,8 @@ class SiteListView(ObjectListView):
     template_name = 'dcim/site_list.html'
 
 
-class SiteView(View):
+class SiteView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.view_site'
 
     def get(self, request, slug):
 
@@ -254,7 +257,8 @@ class SiteBulkEditView(PermissionRequiredMixin, BulkEditView):
 # 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'))
     filter = filters.RackGroupFilter
     filter_form = forms.RackGroupFilterForm
@@ -292,7 +296,8 @@ class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Rack roles
 #
 
-class RackRoleListView(ObjectListView):
+class RackRoleListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_rackrole'
     queryset = RackRole.objects.annotate(rack_count=Count('racks'))
     table = tables.RackRoleTable
     template_name = 'dcim/rackrole_list.html'
@@ -327,7 +332,8 @@ class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Racks
 #
 
-class RackListView(ObjectListView):
+class RackListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_rack'
     queryset = Rack.objects.select_related(
         'site', 'group', 'tenant', 'role'
     ).prefetch_related(
@@ -341,10 +347,11 @@ class RackListView(ObjectListView):
     template_name = 'dcim/rack_list.html'
 
 
-class RackElevationListView(View):
+class RackElevationListView(PermissionRequiredMixin, View):
     """
     Display a set of rack elevations side-by-side.
     """
+    permission_required = 'dcim.view_rack'
 
     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):
 
@@ -454,7 +462,8 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Rack reservations
 #
 
-class RackReservationListView(ObjectListView):
+class RackReservationListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_rackreservation'
     queryset = RackReservation.objects.all()
     filter = filters.RackReservationFilter
     filter_form = forms.RackReservationFilterForm
@@ -510,7 +519,8 @@ class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Manufacturers
 #
 
-class ManufacturerListView(ObjectListView):
+class ManufacturerListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_manufacturer'
     queryset = Manufacturer.objects.annotate(
         devicetype_count=Count('device_types', distinct=True),
         platform_count=Count('platforms', distinct=True),
@@ -548,7 +558,8 @@ class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # 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'))
     filter = filters.DeviceTypeFilter
     filter_form = forms.DeviceTypeFilterForm
@@ -556,7 +567,8 @@ class DeviceTypeListView(ObjectListView):
     template_name = 'dcim/devicetype_list.html'
 
 
-class DeviceTypeView(View):
+class DeviceTypeView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.view_devicetype'
 
     def get(self, request, pk):
 
@@ -812,7 +824,8 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Device roles
 #
 
-class DeviceRoleListView(ObjectListView):
+class DeviceRoleListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_devicerole'
     queryset = DeviceRole.objects.all()
     table = tables.DeviceRoleTable
     template_name = 'dcim/devicerole_list.html'
@@ -847,7 +860,8 @@ class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Platforms
 #
 
-class PlatformListView(ObjectListView):
+class PlatformListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_platform'
     queryset = Platform.objects.all()
     table = tables.PlatformTable
     template_name = 'dcim/platform_list.html'
@@ -882,7 +896,8 @@ class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Devices
 #
 
-class DeviceListView(ObjectListView):
+class DeviceListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_device'
     queryset = Device.objects.select_related(
         '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'
 
 
-class DeviceView(View):
+class DeviceView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.view_device'
 
     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):
 
@@ -993,7 +1010,7 @@ class DeviceInventoryView(View):
 
 
 class DeviceStatusView(PermissionRequiredMixin, View):
-    permission_required = 'dcim.napalm_read'
+    permission_required = ('dcim.view_device', 'dcim.napalm_read')
 
     def get(self, request, pk):
 
@@ -1006,7 +1023,7 @@ class DeviceStatusView(PermissionRequiredMixin, View):
 
 
 class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
-    permission_required = 'dcim.napalm_read'
+    permission_required = ('dcim.view_device', 'dcim.napalm_read')
 
     def get(self, request, pk):
 
@@ -1023,7 +1040,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
 
 
 class DeviceConfigView(PermissionRequiredMixin, View):
-    permission_required = 'dcim.napalm_read'
+    permission_required = ('dcim.view_device', 'dcim.napalm_read')
 
     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
     base_template = 'dcim/device.html'
 
@@ -1258,7 +1276,8 @@ class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Interfaces
 #
 
-class InterfaceView(View):
+class InterfaceView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.view_interface'
 
     def get(self, request, pk):
 
@@ -1639,7 +1658,8 @@ class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateVie
 # Cables
 #
 
-class CableListView(ObjectListView):
+class CableListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_cable'
     queryset = Cable.objects.prefetch_related(
         'termination_a', 'termination_b'
     )
@@ -1649,7 +1669,8 @@ class CableListView(ObjectListView):
     template_name = 'dcim/cable_list.html'
 
 
-class CableView(View):
+class CableView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.view_cable'
 
     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.
     """
+    permission_required = 'dcim.view_cable'
 
     def get(self, request, model, pk):
 
@@ -1792,7 +1814,8 @@ class CableBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Connections
 #
 
-class ConsoleConnectionsListView(ObjectListView):
+class ConsoleConnectionsListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = ('dcim.view_consoleport', 'dcim.view_consoleserverport')
     queryset = ConsolePort.objects.select_related(
         'device', 'connected_endpoint__device'
     ).filter(
@@ -1822,7 +1845,8 @@ class ConsoleConnectionsListView(ObjectListView):
         return csv_data
 
 
-class PowerConnectionsListView(ObjectListView):
+class PowerConnectionsListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = ('dcim.view_powerport', 'dcim.view_poweroutlet')
     queryset = PowerPort.objects.select_related(
         'device', '_connected_poweroutlet__device'
     ).filter(
@@ -1852,7 +1876,8 @@ class PowerConnectionsListView(ObjectListView):
         return csv_data
 
 
-class InterfaceConnectionsListView(ObjectListView):
+class InterfaceConnectionsListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.interface'
     queryset = Interface.objects.select_related(
         'device', 'cable', '_connected_interface__device'
     ).filter(
@@ -1894,7 +1919,8 @@ class InterfaceConnectionsListView(ObjectListView):
 # Inventory items
 #
 
-class InventoryItemListView(ObjectListView):
+class InventoryItemListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_inventoryitem'
     queryset = InventoryItem.objects.select_related('device', 'manufacturer')
     filter = filters.InventoryItemFilter
     filter_form = forms.InventoryItemFilterForm
@@ -1949,7 +1975,8 @@ class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # 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'))
     table = tables.VirtualChassisTable
     filter = filters.VirtualChassisFilter
@@ -2184,7 +2211,8 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin,
 # Power panels
 #
 
-class PowerPanelListView(ObjectListView):
+class PowerPanelListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_powerpanel'
     queryset = PowerPanel.objects.select_related(
         'site', 'rack_group'
     ).annotate(
@@ -2196,7 +2224,8 @@ class PowerPanelListView(ObjectListView):
     template_name = 'dcim/powerpanel_list.html'
 
 
-class PowerPanelView(View):
+class PowerPanelView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.view_powerpanel'
 
     def get(self, request, pk):
 
@@ -2253,7 +2282,8 @@ class PowerPanelBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Power feeds
 #
 
-class PowerFeedListView(ObjectListView):
+class PowerFeedListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_powerfeed'
     queryset = PowerFeed.objects.select_related(
         'power_panel', 'rack'
     )
@@ -2263,7 +2293,8 @@ class PowerFeedListView(ObjectListView):
     template_name = 'dcim/powerfeed_list.html'
 
 
-class PowerFeedView(View):
+class PowerFeedView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.view_powerfeed'
 
     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 extras.models import ConfigContext, ObjectChange, Tag
+from utilities.testing import create_test_user
 
 
 class TagTestCase(TestCase):
@@ -35,8 +36,9 @@ class TagTestCase(TestCase):
 class ConfigContextTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['extras.view_configcontext'])
         self.client = Client()
+        self.client.force_login(user)
 
         site = Site(name='Site 1', slug='site-1')
         site.save()
@@ -70,11 +72,9 @@ class ConfigContextTestCase(TestCase):
 class ObjectChangeTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['extras.view_objectchange'])
         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.save()

+ 12 - 6
netbox/extras/views.py

@@ -96,7 +96,8 @@ class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Config contexts
 #
 
-class ConfigContextListView(ObjectListView):
+class ConfigContextListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'extras.view_configcontext'
     queryset = ConfigContext.objects.all()
     filter = filters.ConfigContextFilter
     filter_form = ConfigContextFilterForm
@@ -104,7 +105,8 @@ class ConfigContextListView(ObjectListView):
     template_name = 'extras/configcontext_list.html'
 
 
-class ConfigContextView(View):
+class ConfigContextView(PermissionRequiredMixin, View):
+    permission_required = 'extras.view_configcontext'
 
     def get(self, request, pk):
 
@@ -173,7 +175,8 @@ class ObjectConfigContextView(View):
 # Change logging
 #
 
-class ObjectChangeListView(ObjectListView):
+class ObjectChangeListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'extras.view_objectchange'
     queryset = ObjectChange.objects.select_related('user', 'changed_object_type')
     filter = filters.ObjectChangeFilter
     filter_form = ObjectChangeFilterForm
@@ -181,7 +184,8 @@ class ObjectChangeListView(ObjectListView):
     template_name = 'extras/objectchange_list.html'
 
 
-class ObjectChangeView(View):
+class ObjectChangeView(PermissionRequiredMixin, View):
+    permission_required = 'extras.view_objectchange'
 
     def get(self, request, pk):
 
@@ -272,10 +276,11 @@ class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 # Reports
 #
 
-class ReportListView(View):
+class ReportListView(PermissionRequiredMixin, View):
     """
     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):
 
@@ -295,10 +300,11 @@ class ReportListView(View):
         })
 
 
-class ReportView(View):
+class ReportView(PermissionRequiredMixin, View):
     """
     Display a single Report and its associated ReportResult (if any).
     """
+    permission_required = 'extras.view_reportresult'
 
     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 ipam.constants import IP_PROTOCOL_TCP
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
+from utilities.testing import create_test_user
 
 
 class VRFTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['ipam.view_vrf'])
         self.client = Client()
+        self.client.force_login(user)
 
         VRF.objects.bulk_create([
             VRF(name='VRF 1', rd='65000:1'),
@@ -41,8 +43,9 @@ class VRFTestCase(TestCase):
 class RIRTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['ipam.view_rir'])
         self.client = Client()
+        self.client.force_login(user)
 
         RIR.objects.bulk_create([
             RIR(name='RIR 1', slug='rir-1'),
@@ -57,18 +60,13 @@ class RIRTestCase(TestCase):
         response = self.client.get(url)
         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):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['ipam.view_aggregate'])
         self.client = Client()
+        self.client.force_login(user)
 
         rir = RIR(name='RIR 1', slug='rir-1')
         rir.save()
@@ -99,8 +97,9 @@ class AggregateTestCase(TestCase):
 class RoleTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['ipam.view_role'])
         self.client = Client()
+        self.client.force_login(user)
 
         Role.objects.bulk_create([
             Role(name='Role 1', slug='role-1'),
@@ -119,8 +118,9 @@ class RoleTestCase(TestCase):
 class PrefixTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['ipam.view_prefix'])
         self.client = Client()
+        self.client.force_login(user)
 
         site = Site(name='Site 1', slug='site-1')
         site.save()
@@ -151,8 +151,9 @@ class PrefixTestCase(TestCase):
 class IPAddressTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['ipam.view_ipaddress'])
         self.client = Client()
+        self.client.force_login(user)
 
         vrf = VRF(name='VRF 1', rd='65000:1')
         vrf.save()
@@ -183,8 +184,9 @@ class IPAddressTestCase(TestCase):
 class VLANGroupTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['ipam.view_vlangroup'])
         self.client = Client()
+        self.client.force_login(user)
 
         site = Site(name='Site 1', slug='site-1')
         site.save()
@@ -209,8 +211,9 @@ class VLANGroupTestCase(TestCase):
 class VLANTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['ipam.view_vlan'])
         self.client = Client()
+        self.client.force_login(user)
 
         vlangroup = VLANGroup(name='VLAN Group 1', slug='vlan-group-1')
         vlangroup.save()
@@ -241,8 +244,9 @@ class VLANTestCase(TestCase):
 class ServiceTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['ipam.view_service'])
         self.client = Client()
+        self.client.force_login(user)
 
         site = Site(name='Site 1', slug='site-1')
         site.save()

+ 39 - 19
netbox/ipam/views.py

@@ -113,7 +113,8 @@ def add_available_vlans(vlan_group, vlans):
 # VRFs
 #
 
-class VRFListView(ObjectListView):
+class VRFListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'ipam.view_vrf'
     queryset = VRF.objects.select_related('tenant')
     filter = filters.VRFFilter
     filter_form = forms.VRFFilterForm
@@ -121,7 +122,8 @@ class VRFListView(ObjectListView):
     template_name = 'ipam/vrf_list.html'
 
 
-class VRFView(View):
+class VRFView(PermissionRequiredMixin, View):
+    permission_required = 'ipam.view_vrf'
 
     def get(self, request, pk):
 
@@ -180,7 +182,8 @@ class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # RIRs
 #
 
-class RIRListView(ObjectListView):
+class RIRListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'ipam.view_rir'
     queryset = RIR.objects.annotate(aggregate_count=Count('aggregates'))
     filter = filters.RIRFilter
     filter_form = forms.RIRFilterForm
@@ -286,7 +289,8 @@ class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Aggregates
 #
 
-class AggregateListView(ObjectListView):
+class AggregateListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'ipam.view_aggregate'
     queryset = Aggregate.objects.select_related('rir').extra(select={
         '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):
 
@@ -398,7 +403,8 @@ class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Prefix/VLAN roles
 #
 
-class RoleListView(ObjectListView):
+class RoleListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'ipam.view_role'
     queryset = Role.objects.all()
     table = tables.RoleTable
     template_name = 'ipam/role_list.html'
@@ -433,7 +439,8 @@ class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Prefixes
 #
 
-class PrefixListView(ObjectListView):
+class PrefixListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'ipam.view_prefix'
     queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
     filter = filters.PrefixFilter
     filter_form = forms.PrefixFilterForm
@@ -446,7 +453,8 @@ class PrefixListView(ObjectListView):
         return self.queryset.annotate_depth(limit=limit)
 
 
-class PrefixView(View):
+class PrefixView(PermissionRequiredMixin, View):
+    permission_required = 'ipam.view_prefix'
 
     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):
 
@@ -531,7 +540,8 @@ class PrefixPrefixesView(View):
         })
 
 
-class PrefixIPAddressesView(View):
+class PrefixIPAddressesView(PermissionRequiredMixin, View):
+    permission_required = 'ipam.view_prefix'
 
     def get(self, request, pk):
 
@@ -617,7 +627,8 @@ class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # IP addresses
 #
 
-class IPAddressListView(ObjectListView):
+class IPAddressListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'ipam.view_ipaddress'
     queryset = IPAddress.objects.select_related(
         'vrf__tenant', 'tenant', 'nat_inside'
     ).prefetch_related(
@@ -629,7 +640,8 @@ class IPAddressListView(ObjectListView):
     template_name = 'ipam/ipaddress_list.html'
 
 
-class IPAddressView(View):
+class IPAddressView(PermissionRequiredMixin, View):
+    permission_required = 'ipam.view_ipaddress'
 
     def get(self, request, pk):
 
@@ -788,7 +800,8 @@ class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # 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'))
     filter = filters.VLANGroupFilter
     filter_form = forms.VLANGroupFilterForm
@@ -822,7 +835,9 @@ class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     default_return_url = 'ipam:vlangroup_list'
 
 
-class VLANGroupVLANsView(View):
+class VLANGroupVLANsView(PermissionRequiredMixin, View):
+    permission_required = 'ipam.view_vlangroup'
+
     def get(self, request, pk):
 
         vlan_group = get_object_or_404(VLANGroup.objects.all(), pk=pk)
@@ -861,7 +876,8 @@ class VLANGroupVLANsView(View):
 # 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')
     filter = filters.VLANFilter
     filter_form = forms.VLANFilterForm
@@ -869,7 +885,8 @@ class VLANListView(ObjectListView):
     template_name = 'ipam/vlan_list.html'
 
 
-class VLANView(View):
+class VLANView(PermissionRequiredMixin, View):
+    permission_required = 'ipam.view_vlan'
 
     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):
 
@@ -954,7 +972,8 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Services
 #
 
-class ServiceListView(ObjectListView):
+class ServiceListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'ipam.view_service'
     queryset = Service.objects.select_related('device', 'virtual_machine')
     filter = filters.ServiceFilter
     filter_form = forms.ServiceFilterForm
@@ -962,7 +981,8 @@ class ServiceListView(ObjectListView):
     template_name = 'ipam/service_list.html'
 
 
-class ServiceView(View):
+class ServiceView(PermissionRequiredMixin, View):
+    permission_required = 'ipam.view_service'
 
     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
     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):
+
         # LOGIN_REQUIRED determines whether read-only access is provided to anonymous users.
         self.authenticated_users_only = settings.LOGIN_REQUIRED
+
         super().__init__()
 
     def has_permission(self, request, view):
+
         # If token authentication is in use, verify that the token allows write operations (for unsafe methods).
         if request.method not in SAFE_METHODS and isinstance(request.auth, Token):
             if not request.auth.write_enabled:
                 return False
+
         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.
 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:
 #   https://docs.djangoproject.com/en/1.11/topics/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')
 DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
 DEBUG = getattr(configuration, 'DEBUG', False)
-ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
 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', {})
 LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
 LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
@@ -93,7 +94,7 @@ if LDAP_CONFIGURED:
         # Prepend LDAPBackend to the default ModelBackend
         AUTHENTICATION_BACKENDS = [
             'django_auth_ldap.backend.LDAPBackend',
-            'django.contrib.auth.backends.ModelBackend',
+            'utilities.auth_backends.ViewExemptModelBackend',
         ]
         # Optionally disable strict certificate checking
         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 "
             "netbox/ldap_config.py to disable LDAP."
         )
+else:
+    AUTHENTICATION_BACKENDS = [
+        'utilities.auth_backends.ViewExemptModelBackend',
+    ]
 
 # Database
 configuration.DATABASE.update({'ENGINE': 'django.db.backends.postgresql'})

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

@@ -1,25 +1,19 @@
 import urllib.parse
 
-from django.contrib.auth import get_user_model
 from django.test import Client, TestCase
 from django.urls import reverse
 
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from secrets.models import Secret, SecretRole
+from utilities.testing import create_test_user
 
 
 class SecretRoleTestCase(TestCase):
 
     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.login(username=TEST_USERNAME, password=TEST_PASSWORD)
+        self.client.force_login(user)
 
         SecretRole.objects.bulk_create([
             SecretRole(name='Secret Role 1', slug='secret-role-1'),
@@ -29,7 +23,7 @@ class SecretRoleTestCase(TestCase):
 
     def test_secretrole_list(self):
 
-        url = reverse('secrets:secret_list')
+        url = reverse('secrets:secretrole_list')
 
         response = self.client.get(url, follow=True)
         self.assertEqual(response.status_code, 200)
@@ -38,8 +32,9 @@ class SecretRoleTestCase(TestCase):
 class SecretTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['secrets.view_secret'])
         self.client = Client()
+        self.client.force_login(user)
 
         site = Site(name='Site 1', slug='site-1')
         site.save()
@@ -75,7 +70,7 @@ class SecretTestCase(TestCase):
         response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)), follow=True)
         self.assertEqual(response.status_code, 200)
 
-    def test_configcontext(self):
+    def test_secret(self):
 
         secret = Secret.objects.first()
         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
 #
 
-class SecretRoleListView(ObjectListView):
+class SecretRoleListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'secrets.view_secretrole'
     queryset = SecretRole.objects.annotate(secret_count=Count('secrets'))
     table = tables.SecretRoleTable
     template_name = 'secrets/secretrole_list.html'
@@ -67,8 +68,8 @@ class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # 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')
     filter = filters.SecretFilter
     filter_form = forms.SecretFilterForm
@@ -76,8 +77,8 @@ class SecretListView(ObjectListView):
     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):
 
@@ -198,7 +199,7 @@ class SecretDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 
 
 class SecretBulkImportView(BulkImportView):
-    permission_required = 'ipam.add_vlan'
+    permission_required = 'secrets.add_secret'
     model_form = forms.SecretCSVForm
     table = tables.SecretTable
     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 %}>
             <a href="{{ circuit.get_absolute_url }}">Circuit</a>
         </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>
 {% endblock %}
 

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

@@ -51,9 +51,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ provider.get_absolute_url }}">Provider</a>
         </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>
 {% endblock %}
 

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

@@ -27,9 +27,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ cable.get_absolute_url }}">Cable</a>
         </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>
 {% endblock %}
 

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

@@ -86,12 +86,16 @@
                 {% include 'dcim/inc/device_napalm_tabs.html' %}
             {% 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>
 {% endblock %}
 

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

@@ -50,9 +50,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ devicetype.get_absolute_url }}">Device Type</a>
         </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>
 {% endblock %}
 

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

@@ -32,9 +32,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ interface.get_absolute_url }}">Interface</a>
         </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>
 {% endblock %}
 

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

@@ -47,9 +47,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ rack.get_absolute_url }}">Rack</a>
         </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>
 {% endblock %}
 

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

@@ -56,9 +56,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ site.get_absolute_url }}">Site</a>
         </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>
 {% endblock %}
 

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

@@ -42,9 +42,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ tag.get_absolute_url }}">Tag</a>
         </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>
 {% endblock %}
 

+ 167 - 129
netbox/templates/home.html

@@ -10,16 +10,20 @@
                 <strong>Organization</strong>
             </div>
             <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 class="panel panel-default">
@@ -27,26 +31,38 @@
                 <strong>DCIM</strong>
             </div>
             <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">
                     <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>
@@ -55,16 +71,20 @@
                 <strong>Virtualization</strong>
             </div>
             <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>
@@ -74,31 +94,41 @@
                 <strong>IPAM</strong>
             </div>
             <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 class="panel panel-default">
@@ -106,16 +136,20 @@
                 <strong>Circuits</strong>
             </div>
             <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>
         {% if perms.secrets %}
@@ -134,26 +168,28 @@
         {% endif %}
     </div>
     <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>
-            {% 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-heading">
                     <strong>Reports</strong>
@@ -168,44 +204,46 @@
                 </table>
             </div>
         {% 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>
+        {% endif %}
     </div>
 </div>
 {% 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>
                     <ul class="dropdown-menu">
                         <li class="dropdown-header">Sites</li>
-                        <li>
+                        <li{% if not perms.dcim.view_site %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_site %}
                                 <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>
@@ -29,7 +29,7 @@
                             {% endif %}
                             <a href="{% url 'dcim:site_list' %}">Sites</a>
                         </li>
-                        <li>
+                        <li{% if not perms.dcim.view_region %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_region %}
                                 <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>
@@ -40,7 +40,7 @@
                         </li>
                         <li class="divider"></li>
                         <li class="dropdown-header">Tenancy</li>
-                        <li>
+                        <li{% if not perms.tenancy.view_tenant %} class="disabled"{% endif %}>
                             {% if perms.tenancy.add_tenant %}
                                 <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>
@@ -49,7 +49,7 @@
                             {% endif %}
                             <a href="{% url 'tenancy:tenant_list' %}">Tenants</a>
                         </li>
-                        <li>
+                        <li{% if not perms.tenancy.view_tenantgroup %} class="disabled"{% endif %}>
                             {% if perms.tenancy.add_tenantgroup %}
                                 <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>
@@ -60,16 +60,16 @@
                         </li>
                         <li class="divider"></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>
                         </li>
-                        <li>
+                        <li{% if not perms.extras.view_configcontext %} class="disabled"{% endif %}>
                             <a href="{% url 'extras:configcontext_list' %}">Config Contexts</a>
                         </li>
-                        <li>
+                        <li{% if not perms.extras.view_reportresult %} class="disabled"{% endif %}>
                             <a href="{% url 'extras:report_list' %}">Reports</a>
                         </li>
-                        <li>
+                        <li{% if not perms.extras.view_objectchange %} class="disabled"{% endif %}>
                             <a href="{% url 'extras:objectchange_list' %}">Changelog</a>
                         </li>
                     </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>
                     <ul class="dropdown-menu">
                         <li class="dropdown-header">Racks</li>
-                        <li>
+                        <li{% if not perms.dcim.view_rack %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_rack %}
                                 <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>
@@ -87,7 +87,7 @@
                             {% endif %}
                             <a href="{% url 'dcim:rack_list' %}">Racks</a>
                         </li>
-                        <li>
+                        <li{% if not perms.dcim.view_rackgroup %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_rackgroup %}
                                 <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>
@@ -96,7 +96,7 @@
                             {% endif %}
                             <a href="{% url 'dcim:rackgroup_list' %}">Rack Groups</a>
                         </li>
-                        <li>
+                        <li{% if not perms.dcim.view_rackrole %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_rackrole %}
                                 <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>
@@ -105,10 +105,10 @@
                             {% endif %}
                             <a href="{% url 'dcim:rackrole_list' %}">Rack Roles</a>
                         </li>
-                        <li>
+                        <li{% if not perms.dcim.view_rack %} class="disabled"{% endif %}>
                             <a href="{% url 'dcim:rack_elevation_list' %}">Elevations</a>
                         </li>
-                        <li>
+                        <li{% if not perms.dcim.view_rackreservation %} class="disabled"{% endif %}>
                             <a href="{% url 'dcim:rackreservation_list' %}">Reservations</a>
                         </li>
                     </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>
                     <ul class="dropdown-menu">
                         <li class="dropdown-header">Devices</li>
-                        <li>
+                        <li{% if not perms.dcim.view_device %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_device %}
                                 <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>
@@ -126,7 +126,7 @@
                             {% endif %}
                             <a href="{% url 'dcim:device_list' %}">Devices</a>
                         </li>
-                        <li>
+                        <li{% if not perms.dcim.view_devicerole %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_devicerole %}
                                 <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>
@@ -135,7 +135,7 @@
                             {% endif %}
                             <a href="{% url 'dcim:devicerole_list' %}">Device Roles</a>
                         </li>
-                        <li>
+                        <li{% if not perms.dcim.view_platform %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_platform %}
                                 <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>
@@ -144,12 +144,12 @@
                             {% endif %}
                             <a href="{% url 'dcim:platform_list' %}">Platforms</a>
                         </li>
-                        <li>
+                        <li{% if not perms.dcim.view_virtualchassis %} class="disabled"{% endif %}>
                             <a href="{% url 'dcim:virtualchassis_list' %}">Virtual Chassis</a>
                         </li>
                         <li class="divider"></li>
                         <li class="dropdown-header">Device Types</li>
-                        <li>
+                        <li{% if not perms.dcim.view_devicetype %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_devicetype %}
                                 <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>
@@ -158,7 +158,7 @@
                             {% endif %}
                             <a href="{% url 'dcim:devicetype_list' %}">Device Types</a>
                         </li>
-                        <li>
+                        <li{% if not perms.dcim.view_manufacturer %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_manufacturer %}
                                 <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>
@@ -169,7 +169,7 @@
                         </li>
                         <li class="divider"></li>
                         <li class="dropdown-header">Inventory</li>
-                        <li>
+                        <li{% if not perms.dcim.view_inventoryitem %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_inventoryitem %}
                                 <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>
@@ -179,7 +179,7 @@
                         </li>
                         <li class="divider"></li>
                         <li class="dropdown-header">Connections</li>
-                        <li>
+                        <li{% if not perms.dcim.view_cable %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_cable %}
                                 <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>
@@ -187,13 +187,13 @@
                             {% endif %}
                             <a href="{% url 'dcim:cable_list' %}">Cables</a>
                         </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>
                         </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>
                         </li>
-                        <li>
+                        <li{% if not perms.dcim.view_interface %} class="disabled"{% endif %}>
                             <a href="{% url 'dcim:interface_connections_list' %}">Interface Connections</a>
                         </li>
                     </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>
                     <ul class="dropdown-menu">
                         <li class="dropdown-header">IP Addresses</li>
-                        <li>
+                        <li{% if not perms.ipam.view_ipaddress %} class="disabled"{% endif %}>
                             {% if perms.ipam.add_ipaddress %}
                                 <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>
@@ -213,7 +213,7 @@
                         </li>
                         <li class="divider"></li>
                         <li class="dropdown-header">Prefixes</li>
-                        <li>
+                        <li{% if not perms.ipam.view_prefix %} class="disabled"{% endif %}>
                             {% if perms.ipam.add_prefix %}
                                 <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>
@@ -222,7 +222,7 @@
                             {% endif %}
                             <a href="{% url 'ipam:prefix_list' %}">Prefixes</a>
                         </li>
-                        <li>
+                        <li{% if not perms.ipam.view_role %} class="disabled"{% endif %}>
                             {% if perms.ipam.add_role %}
                                 <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>
@@ -233,7 +233,7 @@
                         </li>
                         <li class="divider"></li>
                         <li class="dropdown-header">Aggregates</li>
-                        <li>
+                        <li{% if not perms.ipam.view_aggregate %} class="disabled"{% endif %}>
                             {% if perms.ipam.add_aggregate %}
                                 <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>
@@ -242,7 +242,7 @@
                             {% endif %}
                             <a href="{% url 'ipam:aggregate_list' %}">Aggregates</a>
                         </li>
-                        <li>
+                        <li{% if not perms.ipam.view_rir %} class="disabled"{% endif %}>
                             {% if perms.ipam.add_rir %}
                                 <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>
@@ -253,7 +253,7 @@
                         </li>
                         <li class="divider"></li>
                         <li class="dropdown-header">VRFs</li>
-                        <li>
+                        <li{% if not perms.ipam.view_vrf %} class="disabled"{% endif %}>
                             {% if perms.ipam.add_vrf %}
                                 <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>
@@ -264,7 +264,7 @@
                         </li>
                         <li class="divider"></li>
                         <li class="dropdown-header">VLANs</li>
-                        <li>
+                        <li{% if not perms.ipam.view_vlan %} class="disabled"{% endif %}>
                             {% if perms.ipam.add_vlan %}
                                 <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>
@@ -273,7 +273,7 @@
                             {% endif %}
                             <a href="{% url 'ipam:vlan_list' %}">VLANs</a>
                         </li>
-                        <li>
+                        <li{% if not perms.ipam.view_vlangroup %} class="disabled"{% endif %}>
                             {% if perms.ipam.add_vlangroup %}
                                 <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>
@@ -284,7 +284,7 @@
                         </li>
                         <li class="divider"></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>
                         </li>
                     </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>
                     <ul class="dropdown-menu">
                         <li class="dropdown-header">Virtual Machines</li>
-                        <li>
+                        <li{% if not perms.virtualization.view_virtualmachine %} class="disabled"{% endif %}>
                             {% if perms.virtualization.add_virtualmachine %}
                                 <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>
@@ -304,7 +304,7 @@
                         </li>
                         <li class="divider"></li>
                         <li class="dropdown-header">Clusters</li>
-                        <li>
+                        <li{% if not perms.virtualization.view_cluster %} class="disabled"{% endif %}>
                             {% if perms.virtualization.add_cluster %}
                                 <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>
@@ -313,7 +313,7 @@
                             {% endif %}
                             <a href="{% url 'virtualization:cluster_list' %}">Clusters</a>
                         </li>
-                        <li>
+                        <li{% if not perms.virtualization.view_clustertype %} class="disabled"{% endif %}>
                             {% if perms.virtualization.add_clustertype %}
                                 <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>
@@ -322,7 +322,7 @@
                             {% endif %}
                             <a href="{% url 'virtualization:clustertype_list' %}">Cluster Types</a>
                         </li>
-                        <li>
+                        <li{% if not perms.virtualization.view_clustergroup %} class="disabled"{% endif %}>
                             {% if perms.virtualization.add_clustergroup %}
                                 <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>
@@ -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>
                     <ul class="dropdown-menu">
                         <li class="dropdown-header">Circuits</li>
-                        <li>
+                        <li{% if not perms.circuits.view_circuit %} class="disabled"{% endif %}>
                             {% if perms.circuits.add_circuit %}
                                 <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>
@@ -346,7 +346,7 @@
                             {% endif %}
                             <a href="{% url 'circuits:circuit_list' %}">Circuits</a>
                         </li>
-                        <li>
+                        <li{% if not perms.circuits.view_circuittype %} class="disabled"{% endif %}>
                             {% if perms.circuits.add_circuittype %}
                                 <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>
@@ -357,7 +357,7 @@
                         </li>
                         <li class="divider"></li>
                         <li class="dropdown-header">Providers</li>
-                        <li>
+                        <li{% if not perms.circuits.view_provider %} class="disabled"{% endif %}>
                             {% if perms.circuits.add_provider %}
                                 <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>
@@ -371,7 +371,7 @@
                 <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>
                     <ul class="dropdown-menu">
-                        <li>
+                        <li{% if not perms.dcim.view_powerfeed %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_powerfeed %}
                                 <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>
@@ -380,7 +380,7 @@
                             {% endif %}
                             <a href="{% url 'dcim:powerfeed_list' %}">Power Feeds</a>
                         </li>
-                        <li>
+                        <li{% if not perms.dcim.view_powerpanel %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_powerpanel %}
                                 <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>

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

@@ -43,9 +43,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ aggregate.get_absolute_url }}">Aggregate</a>
         </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>
 {% endblock %}
 

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

@@ -45,9 +45,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ ipaddress.get_absolute_url }}">IP Address</a>
         </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>
 {% endblock %}
 

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

@@ -59,12 +59,16 @@
         <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>
         </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>
 {% endblock %}
 

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

@@ -51,9 +51,11 @@
         <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>
         </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>
 {% endblock %}
 

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

@@ -42,9 +42,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ vrf.get_absolute_url }}">VRF</a>
         </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>
 {% endblock %}
 

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

@@ -33,9 +33,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ secret.get_absolute_url }}">Secret</a>
         </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>
 {% endblock %}
 

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

@@ -45,9 +45,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ tenant.get_absolute_url }}">Tenant</a>
         </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>
 {% endblock %}
 

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

@@ -45,9 +45,11 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ cluster.get_absolute_url }}">Cluster</a>
         </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>
 {% endblock %}
 

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

@@ -44,12 +44,16 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ virtualmachine.get_absolute_url }}">Virtual Machine</a>
         </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>
 {% 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 tenancy.models import Tenant, TenantGroup
+from utilities.testing import create_test_user
 
 
 class TenantGroupTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['tenancy.view_tenantgroup'])
         self.client = Client()
+        self.client.force_login(user)
 
         TenantGroup.objects.bulk_create([
             TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
@@ -29,8 +31,9 @@ class TenantGroupTestCase(TestCase):
 class TenantTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['tenancy.view_tenant'])
         self.client = Client()
+        self.client.force_login(user)
 
         tenantgroup = TenantGroup(name='Tenant Group 1', slug='tenant-group-1')
         tenantgroup.save()

+ 6 - 3
netbox/tenancy/views.py

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

+ 4 - 10
netbox/users/views.py

@@ -1,6 +1,5 @@
 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.decorators import login_required
 from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
 from django.http import HttpResponseForbidden, HttpResponseRedirect
 from django.shortcuts import get_object_or_404, redirect, render
@@ -74,8 +73,7 @@ class LogoutView(View):
 # User profiles
 #
 
-@method_decorator(login_required, name='dispatch')
-class ProfileView(View):
+class ProfileView(LoginRequiredMixin, View):
     template_name = 'users/profile.html'
 
     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'
 
     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'
 
     def get(self, request):
@@ -127,10 +123,9 @@ class UserKeyView(View):
         })
 
 
-class UserKeyEditView(View):
+class UserKeyEditView(LoginRequiredMixin, View):
     template_name = 'users/userkey_edit.html'
 
-    @method_decorator(login_required)
     def dispatch(self, request, *args, **kwargs):
         try:
             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):
 
     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 users.models import Token
@@ -22,3 +22,16 @@ class APITestCase(_APITestCase):
         self.assertEqual(response.status_code, expected_status, err_message.format(
             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.urls import reverse
 
+from utilities.testing import create_test_user
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 
 
 class ClusterGroupTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['virtualization.view_clustergroup'])
         self.client = Client()
+        self.client.force_login(user)
 
         ClusterGroup.objects.bulk_create([
             ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'),
@@ -29,8 +31,9 @@ class ClusterGroupTestCase(TestCase):
 class ClusterTypeTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['virtualization.view_clustertype'])
         self.client = Client()
+        self.client.force_login(user)
 
         ClusterType.objects.bulk_create([
             ClusterType(name='Cluster Type 1', slug='cluster-type-1'),
@@ -49,8 +52,9 @@ class ClusterTypeTestCase(TestCase):
 class ClusterTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['virtualization.view_cluster'])
         self.client = Client()
+        self.client.force_login(user)
 
         clustergroup = ClusterGroup(name='Cluster Group 1', slug='cluster-group-1')
         clustergroup.save()
@@ -85,8 +89,9 @@ class ClusterTestCase(TestCase):
 class VirtualMachineTestCase(TestCase):
 
     def setUp(self):
-
+        user = create_test_user(permissions=['virtualization.view_virtualmachine'])
         self.client = Client()
+        self.client.force_login(user)
 
         clustertype = ClusterType(name='Cluster Type 1', slug='cluster-type-1')
         clustertype.save()

+ 14 - 7
netbox/virtualization/views.py

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