Просмотр исходного кода

Merge branch 'develop-2.8' into 3351-plugins

John Anderson 6 лет назад
Родитель
Сommit
8af4cf87b5
34 измененных файлов с 835 добавлено и 145 удалено
  1. 48 0
      docs/configuration/optional-settings.md
  2. 22 4
      docs/release-notes/version-2.8.md
  3. 2 1
      netbox/circuits/tests/test_filters.py
  4. 2 1
      netbox/dcim/api/serializers.py
  5. 32 15
      netbox/dcim/filters.py
  6. 35 3
      netbox/dcim/forms.py
  7. 43 0
      netbox/dcim/migrations/0101_nested_rackgroups.py
  8. 21 0
      netbox/dcim/migrations/0102_nested_rackgroups_rebuild.py
  9. 29 2
      netbox/dcim/models/__init__.py
  10. 7 4
      netbox/dcim/tables.py
  11. 19 10
      netbox/dcim/tests/test_api.py
  12. 39 15
      netbox/dcim/tests/test_filters.py
  13. 8 4
      netbox/dcim/tests/test_views.py
  14. 7 1
      netbox/dcim/views.py
  15. 4 2
      netbox/extras/tests/test_api.py
  16. 2 1
      netbox/extras/tests/test_filters.py
  17. 8 4
      netbox/ipam/tests/test_filters.py
  18. 8 0
      netbox/netbox/configuration.example.py
  19. 9 1
      netbox/netbox/settings.py
  20. 159 0
      netbox/netbox/tests/test_authentication.py
  21. 2 1
      netbox/tenancy/api/serializers.py
  22. 26 12
      netbox/tenancy/filters.py
  23. 17 1
      netbox/tenancy/forms.py
  24. 43 0
      netbox/tenancy/migrations/0007_nested_tenantgroups.py
  25. 21 0
      netbox/tenancy/migrations/0008_nested_tenantgroups_rebuild.py
  26. 26 3
      netbox/tenancy/models.py
  27. 21 4
      netbox/tenancy/tables.py
  28. 59 33
      netbox/tenancy/tests/test_api.py
  29. 27 10
      netbox/tenancy/tests/test_filters.py
  30. 12 9
      netbox/tenancy/tests/test_views.py
  31. 7 1
      netbox/tenancy/views.py
  32. 46 1
      netbox/utilities/auth_backends.py
  33. 20 0
      netbox/utilities/middleware.py
  34. 4 2
      netbox/virtualization/tests/test_filters.py

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

@@ -307,6 +307,54 @@ When determining the primary IP address for a device, IPv6 is preferred over IPv
 
 ---
 
+## REMOTE_AUTH_ENABLED
+
+Default: `False`
+
+NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authenitcation will still take effect as a fallback.)
+
+---
+
+## REMOTE_AUTH_BACKEND
+
+Default: `'utilities.auth_backends.RemoteUserBackend'`
+
+Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication, if not using NetBox's built-in backend. (Requires `REMOTE_AUTH_ENABLED`.)
+
+---
+
+## REMOTE_AUTH_HEADER
+
+Default: `'HTTP_REMOTE_USER'`
+
+When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. (Requires `REMOTE_AUTH_ENABLED`.)
+
+---
+
+## REMOTE_AUTH_AUTO_CREATE_USER
+
+Default: `True`
+
+If true, NetBox will automatically create local accounts for users authenticated via a remote service. (Requires `REMOTE_AUTH_ENABLED`.)
+
+---
+
+## REMOTE_AUTH_DEFAULT_GROUPS
+
+Default: `[]` (Empty list)
+
+The list of groups to assign a new user account when created using remote authentication. (Requires `REMOTE_AUTH_ENABLED`.)
+
+---
+
+## REMOTE_AUTH_DEFAULT_PERMISSIONS
+
+Default: `[]` (Empty list)
+
+The list of permissions to assign a new user account when created using remote authentication. (Requires `REMOTE_AUTH_ENABLED`.)
+
+---
+
 ## REPORTS_ROOT
 
 Default: $BASE_DIR/netbox/reports/

+ 22 - 4
docs/release-notes/version-2.8.md

@@ -1,14 +1,32 @@
-# v2.8.0 (FUTURE)
+# NetBox v2.8
 
-## Enhancements
+## v2.8.0 (FUTURE)
 
+### New Features
+
+#### Remote Authentication Support ([#2328](https://github.com/netbox-community/netbox/issues/2328))
+
+Several new configuration parameters provide support for authenticating an incoming request based on the value of a specific HTTP header. This can be leveraged to employ remote authentication via an nginx or Apache plugin, directing NetBox to create and configure a local user account as needed. The configuration parameters are:
+
+* `REMOTE_AUTH_ENABLED` - Enables remote authentication (disabled by default)
+* `REMOTE_AUTH_HEADER` - The name of the HTTP header which conveys the username
+* `REMOTE_AUTH_AUTO_CREATE_USER` - Enables the automatic creation of new users (disabled by default)
+* `REMOTE_AUTH_DEFAULT_GROUPS` - A list of groups to assign newly created users
+* `REMOTE_AUTH_DEFAULT_PERMISSIONS` - A list of permissions to assign newly created users
+
+If further customization of remote authentication is desired (for instance, if you want to pass group/permission information via HTTP headers as well), NetBox allows you to inject a custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to retain full control over the authentication and configuration of remote users.
+
+### Enhancements
+
+* [#1754](https://github.com/netbox-community/netbox/issues/1754) - Added support for nested rack groups
+* [#3939](https://github.com/netbox-community/netbox/issues/3939) - Added support for nested tenant groups
 * [#4195](https://github.com/netbox-community/netbox/issues/4195) - Enabled application logging (see [logging configuration](../configuration/optional-settings.md#logging))
 
-## API Changes
+### API Changes
 
 * dcim.Rack: The `/api/dcim/racks/<pk>/units/` endpoint has been replaced with `/api/dcim/racks/<pk>/elevation/`.
 * The `id__in` filter has been removed. Use the format `?id=1&id=2` instead. ([#4313](https://github.com/netbox-community/netbox/issues/4313))
 
-## Other Changes
+### Other Changes
 
 * [#4081](https://github.com/netbox-community/netbox/issues/4081) - The `family` field has been removed from the Aggregate, Prefix, and IPAddress models

+ 2 - 1
netbox/circuits/tests/test_filters.py

@@ -139,7 +139,8 @@ class CircuitTestCase(TestCase):
             TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
             TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
         )
-        TenantGroup.objects.bulk_create(tenant_groups)
+        for tenantgroup in tenant_groups:
+            tenantgroup.save()
 
         tenants = (
             Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),

+ 2 - 1
netbox/dcim/api/serializers.py

@@ -96,11 +96,12 @@ class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer):
 
 class RackGroupSerializer(ValidatedModelSerializer):
     site = NestedSiteSerializer()
+    parent = NestedRackGroupSerializer(required=False, allow_null=True)
     rack_count = serializers.IntegerField(read_only=True)
 
     class Meta:
         model = RackGroup
-        fields = ['id', 'name', 'slug', 'site', 'rack_count']
+        fields = ['id', 'name', 'slug', 'site', 'parent', 'rack_count']
 
 
 class RackRoleSerializer(ValidatedModelSerializer):

+ 32 - 15
netbox/dcim/filters.py

@@ -153,6 +153,16 @@ class RackGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
         to_field_name='slug',
         label='Site (slug)',
     )
+    parent_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=RackGroup.objects.all(),
+        label='Rack group (ID)',
+    )
+    parent = django_filters.ModelMultipleChoiceFilter(
+        field_name='parent__slug',
+        queryset=RackGroup.objects.all(),
+        to_field_name='slug',
+        label='Rack group (slug)',
+    )
 
     class Meta:
         model = RackGroup
@@ -194,15 +204,18 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Creat
         to_field_name='slug',
         label='Site (slug)',
     )
-    group_id = django_filters.ModelMultipleChoiceFilter(
+    group_id = TreeNodeMultipleChoiceFilter(
         queryset=RackGroup.objects.all(),
-        label='Group (ID)',
+        field_name='group',
+        lookup_expr='in',
+        label='Rack group (ID)',
     )
-    group = django_filters.ModelMultipleChoiceFilter(
-        field_name='group__slug',
+    group = TreeNodeMultipleChoiceFilter(
         queryset=RackGroup.objects.all(),
+        field_name='group',
+        lookup_expr='in',
         to_field_name='slug',
-        label='Group',
+        label='Rack group (slug)',
     )
     status = django_filters.MultipleChoiceFilter(
         choices=RackStatusChoices,
@@ -262,16 +275,18 @@ class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet):
         to_field_name='slug',
         label='Site (slug)',
     )
-    group_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='rack__group',
+    group_id = TreeNodeMultipleChoiceFilter(
         queryset=RackGroup.objects.all(),
-        label='Group (ID)',
+        field_name='rack__group',
+        lookup_expr='in',
+        label='Rack group (ID)',
     )
-    group = django_filters.ModelMultipleChoiceFilter(
-        field_name='rack__group__slug',
+    group = TreeNodeMultipleChoiceFilter(
         queryset=RackGroup.objects.all(),
+        field_name='rack__group',
+        lookup_expr='in',
         to_field_name='slug',
-        label='Group',
+        label='Rack group (slug)',
     )
     user_id = django_filters.ModelMultipleChoiceFilter(
         queryset=User.objects.all(),
@@ -551,9 +566,10 @@ class DeviceFilterSet(
         to_field_name='slug',
         label='Site name (slug)',
     )
-    rack_group_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='rack__group',
+    rack_group_id = TreeNodeMultipleChoiceFilter(
         queryset=RackGroup.objects.all(),
+        field_name='rack__group',
+        lookup_expr='in',
         label='Rack group (ID)',
     )
     rack_id = django_filters.ModelMultipleChoiceFilter(
@@ -1243,9 +1259,10 @@ class PowerPanelFilterSet(BaseFilterSet):
         to_field_name='slug',
         label='Site name (slug)',
     )
-    rack_group_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='rack_group',
+    rack_group_id = TreeNodeMultipleChoiceFilter(
         queryset=RackGroup.objects.all(),
+        field_name='rack_group',
+        lookup_expr='in',
         label='Rack group (ID)',
     )
 

+ 35 - 3
netbox/dcim/forms.py

@@ -386,7 +386,17 @@ class RackGroupForm(BootstrapMixin, forms.ModelForm):
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         widget=APISelect(
-            api_url="/api/dcim/sites/"
+            api_url="/api/dcim/sites/",
+            filter_for={
+                'parent': 'site_id',
+            }
+        )
+    )
+    parent = DynamicModelChoiceField(
+        queryset=RackGroup.objects.all(),
+        required=False,
+        widget=APISelect(
+            api_url="/api/dcim/rack-groups/"
         )
     )
     slug = SlugField()
@@ -394,7 +404,7 @@ class RackGroupForm(BootstrapMixin, forms.ModelForm):
     class Meta:
         model = RackGroup
         fields = (
-            'site', 'name', 'slug',
+            'site', 'parent', 'name', 'slug',
         )
 
 
@@ -407,6 +417,15 @@ class RackGroupCSVForm(forms.ModelForm):
             'invalid_choice': 'Site not found.',
         }
     )
+    parent = forms.ModelChoiceField(
+        queryset=RackGroup.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Name of parent rack group',
+        error_messages={
+            'invalid_choice': 'Rack group not found.',
+        }
+    )
 
     class Meta:
         model = RackGroup
@@ -426,7 +445,8 @@ class RackGroupFilterForm(BootstrapMixin, forms.Form):
             api_url="/api/dcim/regions/",
             value_field="slug",
             filter_for={
-                'site': 'region'
+                'site': 'region',
+                'parent': 'region',
             }
         )
     )
@@ -437,6 +457,18 @@ class RackGroupFilterForm(BootstrapMixin, forms.Form):
         widget=APISelectMultiple(
             api_url="/api/dcim/sites/",
             value_field="slug",
+            filter_for={
+                'parent': 'site',
+            }
+        )
+    )
+    parent = DynamicModelMultipleChoiceField(
+        queryset=RackGroup.objects.all(),
+        to_field_name='slug',
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/dcim/rack-groups/",
+            value_field="slug",
         )
     )
 

+ 43 - 0
netbox/dcim/migrations/0101_nested_rackgroups.py

@@ -0,0 +1,43 @@
+from django.db import migrations, models
+import django.db.models.deletion
+import mptt.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0100_mptt_remove_indexes'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='rackgroup',
+            name='parent',
+            field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='dcim.RackGroup'),
+        ),
+        migrations.AddField(
+            model_name='rackgroup',
+            name='level',
+            field=models.PositiveIntegerField(default=0, editable=False),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='rackgroup',
+            name='lft',
+            field=models.PositiveIntegerField(default=1, editable=False),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='rackgroup',
+            name='rght',
+            field=models.PositiveIntegerField(default=2, editable=False),
+            preserve_default=False,
+        ),
+        # tree_id will be set to a valid value during the following migration (which needs to be a separate migration)
+        migrations.AddField(
+            model_name='rackgroup',
+            name='tree_id',
+            field=models.PositiveIntegerField(db_index=True, default=0, editable=False),
+            preserve_default=False,
+        ),
+    ]

+ 21 - 0
netbox/dcim/migrations/0102_nested_rackgroups_rebuild.py

@@ -0,0 +1,21 @@
+from django.db import migrations
+
+
+def rebuild_mptt(apps, schema_editor):
+    RackGroup = apps.get_model('dcim', 'RackGroup')
+    for i, rackgroup in enumerate(RackGroup.objects.all(), start=1):
+        RackGroup.objects.filter(pk=rackgroup.pk).update(tree_id=i)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0101_nested_rackgroups'),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            code=rebuild_mptt,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 29 - 2
netbox/dcim/models/__init__.py

@@ -283,7 +283,7 @@ class Site(ChangeLoggedModel, CustomFieldModel):
 # Racks
 #
 
-class RackGroup(ChangeLoggedModel):
+class RackGroup(MPTTModel, ChangeLoggedModel):
     """
     Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For
     example, if a Site spans a corporate campus, a RackGroup might be defined to represent each building within that
@@ -298,8 +298,16 @@ class RackGroup(ChangeLoggedModel):
         on_delete=models.CASCADE,
         related_name='rack_groups'
     )
+    parent = TreeForeignKey(
+        to='self',
+        on_delete=models.CASCADE,
+        related_name='children',
+        blank=True,
+        null=True,
+        db_index=True
+    )
 
-    csv_headers = ['site', 'name', 'slug']
+    csv_headers = ['site', 'parent', 'name', 'slug']
 
     class Meta:
         ordering = ['site', 'name']
@@ -308,6 +316,9 @@ class RackGroup(ChangeLoggedModel):
             ['site', 'slug'],
         ]
 
+    class MPTTMeta:
+        order_insertion_by = ['name']
+
     def __str__(self):
         return self.name
 
@@ -317,10 +328,26 @@ class RackGroup(ChangeLoggedModel):
     def to_csv(self):
         return (
             self.site,
+            self.parent.name if self.parent else '',
             self.name,
             self.slug,
         )
 
+    def to_objectchange(self, action):
+        # Remove MPTT-internal fields
+        return ObjectChange(
+            changed_object=self,
+            object_repr=str(self),
+            action=action,
+            object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id'])
+        )
+
+    def clean(self):
+
+        # Parent RackGroup (if any) must belong to the same Site
+        if self.parent and self.parent.site != self.site:
+            raise ValidationError(f"Parent rack group ({self.parent}) must belong to the same site ({self.site})")
+
 
 class RackRole(ChangeLoggedModel):
     """

+ 7 - 4
netbox/dcim/tables.py

@@ -11,13 +11,13 @@ from .models import (
     VirtualChassis,
 )
 
-REGION_LINK = """
+MPTT_LINK = """
 {% if record.get_children %}
     <span style="padding-left: {{ record.get_ancestors|length }}0px "><i class="fa fa-caret-right"></i>
 {% else %}
     <span style="padding-left: {{ record.get_ancestors|length }}9px">
 {% endif %}
-    <a href="{% url 'dcim:site_list' %}?region={{ record.slug }}">{{ record.name }}</a>
+    <a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
 </span>
 """
 
@@ -214,7 +214,7 @@ def get_component_template_actions(model_name):
 
 class RegionTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.TemplateColumn(template_code=REGION_LINK, orderable=False)
+    name = tables.TemplateColumn(template_code=MPTT_LINK, orderable=False)
     site_count = tables.Column(verbose_name='Sites')
     slug = tables.Column(verbose_name='Slug')
     actions = tables.TemplateColumn(
@@ -250,7 +250,10 @@ class SiteTable(BaseTable):
 
 class RackGroupTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.LinkColumn()
+    name = tables.TemplateColumn(
+        template_code=MPTT_LINK,
+        orderable=False
+    )
     site = tables.LinkColumn(
         viewname='dcim:site',
         args=[Accessor('site.slug')],

+ 19 - 10
netbox/dcim/tests/test_api.py

@@ -349,9 +349,11 @@ class RackGroupTest(APITestCase):
 
         self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
         self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
-        self.rackgroup1 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 1', slug='test-rack-group-1')
-        self.rackgroup2 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 2', slug='test-rack-group-2')
-        self.rackgroup3 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 3', slug='test-rack-group-3')
+        self.parent_rackgroup1 = RackGroup.objects.create(site=self.site1, name='Parent Rack Group 1', slug='parent-rack-group-1')
+        self.parent_rackgroup2 = RackGroup.objects.create(site=self.site2, name='Parent Rack Group 2', slug='parent-rack-group-2')
+        self.rackgroup1 = RackGroup.objects.create(site=self.site1, name='Rack Group 1', slug='rack-group-1', parent=self.parent_rackgroup1)
+        self.rackgroup2 = RackGroup.objects.create(site=self.site1, name='Rack Group 2', slug='rack-group-2', parent=self.parent_rackgroup1)
+        self.rackgroup3 = RackGroup.objects.create(site=self.site1, name='Rack Group 3', slug='rack-group-3', parent=self.parent_rackgroup1)
 
     def test_get_rackgroup(self):
 
@@ -365,7 +367,7 @@ class RackGroupTest(APITestCase):
         url = reverse('dcim-api:rackgroup-list')
         response = self.client.get(url, **self.header)
 
-        self.assertEqual(response.data['count'], 3)
+        self.assertEqual(response.data['count'], 5)
 
     def test_list_rackgroups_brief(self):
 
@@ -380,20 +382,22 @@ class RackGroupTest(APITestCase):
     def test_create_rackgroup(self):
 
         data = {
-            'name': 'Test Rack Group 4',
-            'slug': 'test-rack-group-4',
+            'name': 'Rack Group 4',
+            'slug': 'rack-group-4',
             'site': self.site1.pk,
+            'parent': self.parent_rackgroup1.pk,
         }
 
         url = reverse('dcim-api:rackgroup-list')
         response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
-        self.assertEqual(RackGroup.objects.count(), 4)
+        self.assertEqual(RackGroup.objects.count(), 6)
         rackgroup4 = RackGroup.objects.get(pk=response.data['id'])
         self.assertEqual(rackgroup4.name, data['name'])
         self.assertEqual(rackgroup4.slug, data['slug'])
         self.assertEqual(rackgroup4.site_id, data['site'])
+        self.assertEqual(rackgroup4.parent_id, data['parent'])
 
     def test_create_rackgroup_bulk(self):
 
@@ -402,16 +406,19 @@ class RackGroupTest(APITestCase):
                 'name': 'Test Rack Group 4',
                 'slug': 'test-rack-group-4',
                 'site': self.site1.pk,
+                'parent': self.parent_rackgroup1.pk,
             },
             {
                 'name': 'Test Rack Group 5',
                 'slug': 'test-rack-group-5',
                 'site': self.site1.pk,
+                'parent': self.parent_rackgroup1.pk,
             },
             {
                 'name': 'Test Rack Group 6',
                 'slug': 'test-rack-group-6',
                 'site': self.site1.pk,
+                'parent': self.parent_rackgroup1.pk,
             },
         ]
 
@@ -419,7 +426,7 @@ class RackGroupTest(APITestCase):
         response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
-        self.assertEqual(RackGroup.objects.count(), 6)
+        self.assertEqual(RackGroup.objects.count(), 8)
         self.assertEqual(response.data[0]['name'], data[0]['name'])
         self.assertEqual(response.data[1]['name'], data[1]['name'])
         self.assertEqual(response.data[2]['name'], data[2]['name'])
@@ -430,17 +437,19 @@ class RackGroupTest(APITestCase):
             'name': 'Test Rack Group X',
             'slug': 'test-rack-group-x',
             'site': self.site2.pk,
+            'parent': self.parent_rackgroup2.pk,
         }
 
         url = reverse('dcim-api:rackgroup-detail', kwargs={'pk': self.rackgroup1.pk})
         response = self.client.put(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
-        self.assertEqual(RackGroup.objects.count(), 3)
+        self.assertEqual(RackGroup.objects.count(), 5)
         rackgroup1 = RackGroup.objects.get(pk=response.data['id'])
         self.assertEqual(rackgroup1.name, data['name'])
         self.assertEqual(rackgroup1.slug, data['slug'])
         self.assertEqual(rackgroup1.site_id, data['site'])
+        self.assertEqual(rackgroup1.parent_id, data['parent'])
 
     def test_delete_rackgroup(self):
 
@@ -448,7 +457,7 @@ class RackGroupTest(APITestCase):
         response = self.client.delete(url, **self.header)
 
         self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
-        self.assertEqual(RackGroup.objects.count(), 2)
+        self.assertEqual(RackGroup.objects.count(), 4)
 
 
 class RackRoleTest(APITestCase):

+ 39 - 15
netbox/dcim/tests/test_filters.py

@@ -81,7 +81,8 @@ class SiteTestCase(TestCase):
             TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
             TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
         )
-        TenantGroup.objects.bulk_create(tenant_groups)
+        for tenantgroup in tenant_groups:
+            tenantgroup.save()
 
         tenants = (
             Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
@@ -186,12 +187,21 @@ class RackGroupTestCase(TestCase):
         )
         Site.objects.bulk_create(sites)
 
+        parent_rack_groups = (
+            RackGroup(name='Parent Rack Group 1', slug='parent-rack-group-1', site=sites[0]),
+            RackGroup(name='Parent Rack Group 2', slug='parent-rack-group-2', site=sites[1]),
+            RackGroup(name='Parent Rack Group 3', slug='parent-rack-group-3', site=sites[2]),
+        )
+        for rackgroup in parent_rack_groups:
+            rackgroup.save()
+
         rack_groups = (
-            RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]),
-            RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]),
-            RackGroup(name='Rack Group 3', slug='rack-group-3', site=sites[2]),
+            RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0], parent=parent_rack_groups[0]),
+            RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1], parent=parent_rack_groups[1]),
+            RackGroup(name='Rack Group 3', slug='rack-group-3', site=sites[2], parent=parent_rack_groups[2]),
         )
-        RackGroup.objects.bulk_create(rack_groups)
+        for rackgroup in rack_groups:
+            rackgroup.save()
 
     def test_id(self):
         id_list = self.queryset.values_list('id', flat=True)[:2]
@@ -209,15 +219,22 @@ class RackGroupTestCase(TestCase):
     def test_region(self):
         regions = Region.objects.all()[:2]
         params = {'region_id': [regions[0].pk, regions[1].pk]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         params = {'region': [regions[0].slug, regions[1].slug]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
     def test_site(self):
         sites = Site.objects.all()[:2]
         params = {'site_id': [sites[0].pk, sites[1].pk]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         params = {'site': [sites[0].slug, sites[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+    def test_parent(self):
+        parent_groups = RackGroup.objects.filter(name__startswith='Parent')[:2]
+        params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
@@ -280,7 +297,8 @@ class RackTestCase(TestCase):
             RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]),
             RackGroup(name='Rack Group 3', slug='rack-group-3', site=sites[2]),
         )
-        RackGroup.objects.bulk_create(rack_groups)
+        for rackgroup in rack_groups:
+            rackgroup.save()
 
         rack_roles = (
             RackRole(name='Rack Role 1', slug='rack-role-1'),
@@ -294,7 +312,8 @@ class RackTestCase(TestCase):
             TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
             TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
         )
-        TenantGroup.objects.bulk_create(tenant_groups)
+        for tenantgroup in tenant_groups:
+            tenantgroup.save()
 
         tenants = (
             Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
@@ -432,7 +451,8 @@ class RackReservationTestCase(TestCase):
             RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]),
             RackGroup(name='Rack Group 3', slug='rack-group-3', site=sites[2]),
         )
-        RackGroup.objects.bulk_create(rack_groups)
+        for rackgroup in rack_groups:
+            rackgroup.save()
 
         racks = (
             Rack(name='Rack 1', site=sites[0], group=rack_groups[0]),
@@ -453,7 +473,8 @@ class RackReservationTestCase(TestCase):
             TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
             TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
         )
-        TenantGroup.objects.bulk_create(tenant_groups)
+        for tenantgroup in tenant_groups:
+            tenantgroup.save()
 
         tenants = (
             Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
@@ -1146,7 +1167,8 @@ class DeviceTestCase(TestCase):
             RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]),
             RackGroup(name='Rack Group 3', slug='rack-group-3', site=sites[2]),
         )
-        RackGroup.objects.bulk_create(rack_groups)
+        for rackgroup in rack_groups:
+            rackgroup.save()
 
         racks = (
             Rack(name='Rack 1', site=sites[0], group=rack_groups[0]),
@@ -1168,7 +1190,8 @@ class DeviceTestCase(TestCase):
             TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
             TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
         )
-        TenantGroup.objects.bulk_create(tenant_groups)
+        for tenantgroup in tenant_groups:
+            tenantgroup.save()
 
         tenants = (
             Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
@@ -2559,7 +2582,8 @@ class PowerPanelTestCase(TestCase):
             RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]),
             RackGroup(name='Rack Group 3', slug='rack-group-3', site=sites[2]),
         )
-        RackGroup.objects.bulk_create(rack_groups)
+        for rackgroup in rack_groups:
+            rackgroup.save()
 
         power_panels = (
             PowerPanel(name='Power Panel 1', site=sites[0], rack_group=rack_groups[0]),

+ 8 - 4
netbox/dcim/tests/test_views.py

@@ -122,11 +122,13 @@ class RackGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
         site = Site(name='Site 1', slug='site-1')
         site.save()
 
-        RackGroup.objects.bulk_create([
+        rack_groups = (
             RackGroup(name='Rack Group 1', slug='rack-group-1', site=site),
             RackGroup(name='Rack Group 2', slug='rack-group-2', site=site),
             RackGroup(name='Rack Group 3', slug='rack-group-3', site=site),
-        ])
+        )
+        for rackgroup in rack_groups:
+            rackgroup.save()
 
         cls.form_data = {
             'name': 'Rack Group X',
@@ -231,7 +233,8 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]),
             RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1])
         )
-        RackGroup.objects.bulk_create(rackgroups)
+        for rackgroup in rackgroups:
+            rackgroup.save()
 
         rackroles = (
             RackRole(name='Rack Role 1', slug='rack-role-1'),
@@ -1570,7 +1573,8 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]),
             RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]),
         )
-        RackGroup.objects.bulk_create(rackgroups)
+        for rackgroup in rackgroups:
+            rackgroup.save()
 
         PowerPanel.objects.bulk_create((
             PowerPanel(site=sites[0], rack_group=rackgroups[0], name='Power Panel 1'),

+ 7 - 1
netbox/dcim/views.py

@@ -266,7 +266,13 @@ class SiteBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 class RackGroupListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'dcim.view_rackgroup'
-    queryset = RackGroup.objects.prefetch_related('site').annotate(rack_count=Count('racks'))
+    queryset = RackGroup.objects.add_related_count(
+        RackGroup.objects.all(),
+        Rack,
+        'group',
+        'rack_count',
+        cumulative=True
+    ).prefetch_related('site')
     filterset = filters.RackGroupFilterSet
     filterset_form = forms.RackGroupFilterForm
     table = tables.RackGroupTable

+ 4 - 2
netbox/extras/tests/test_api.py

@@ -402,8 +402,10 @@ class ConfigContextTest(APITestCase):
         role2 = DeviceRole.objects.create(name='Test Role 2', slug='test-role-2')
         platform1 = Platform.objects.create(name='Test Platform 1', slug='test-platform-1')
         platform2 = Platform.objects.create(name='Test Platform 2', slug='test-platform-2')
-        tenantgroup1 = TenantGroup.objects.create(name='Test Tenant Group 1', slug='test-tenant-group-1')
-        tenantgroup2 = TenantGroup.objects.create(name='Test Tenant Group 2', slug='test-tenant-group-2')
+        tenantgroup1 = TenantGroup(name='Test Tenant Group 1', slug='test-tenant-group-1')
+        tenantgroup1.save()
+        tenantgroup2 = TenantGroup(name='Test Tenant Group 2', slug='test-tenant-group-2')
+        tenantgroup2.save()
         tenant1 = Tenant.objects.create(name='Test Tenant 1', slug='test-tenant-1')
         tenant2 = Tenant.objects.create(name='Test Tenant 2', slug='test-tenant-2')
         tag1 = Tag.objects.create(name='Test Tag 1', slug='test-tag-1')

+ 2 - 1
netbox/extras/tests/test_filters.py

@@ -128,7 +128,8 @@ class ConfigContextTestCase(TestCase):
             TenantGroup(name='Tenant Group 2', slug='tenant-group-2'),
             TenantGroup(name='Tenant Group 3', slug='tenant-group-3'),
         )
-        TenantGroup.objects.bulk_create(tenant_groups)
+        for tenantgroup in tenant_groups:
+            tenantgroup.save()
 
         tenants = (
             Tenant(name='Tenant 1', slug='tenant-1'),

+ 8 - 4
netbox/ipam/tests/test_filters.py

@@ -20,7 +20,8 @@ class VRFTestCase(TestCase):
             TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
             TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
         )
-        TenantGroup.objects.bulk_create(tenant_groups)
+        for tenantgroup in tenant_groups:
+            tenantgroup.save()
 
         tenants = (
             Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
@@ -222,7 +223,8 @@ class PrefixTestCase(TestCase):
             TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
             TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
         )
-        TenantGroup.objects.bulk_create(tenant_groups)
+        for tenantgroup in tenant_groups:
+            tenantgroup.save()
 
         tenants = (
             Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
@@ -379,7 +381,8 @@ class IPAddressTestCase(TestCase):
             TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
             TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
         )
-        TenantGroup.objects.bulk_create(tenant_groups)
+        for tenantgroup in tenant_groups:
+            tenantgroup.save()
 
         tenants = (
             Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
@@ -593,7 +596,8 @@ class VLANTestCase(TestCase):
             TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
             TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
         )
-        TenantGroup.objects.bulk_create(tenant_groups)
+        for tenantgroup in tenant_groups:
+            tenantgroup.save()
 
         tenants = (
             Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),

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

@@ -179,6 +179,14 @@ PAGINATE_COUNT = 50
 # prefer IPv4 instead.
 PREFER_IPV4 = False
 
+# Remote authentication support
+REMOTE_AUTH_ENABLED = False
+REMOTE_AUTH_BACKEND = 'utilities.auth_backends.RemoteUserBackend'
+REMOTE_AUTH_HEADER = 'HTTP_REMOTE_USER'
+REMOTE_AUTH_AUTO_CREATE_USER = True
+REMOTE_AUTH_DEFAULT_GROUPS = []
+REMOTE_AUTH_DEFAULT_PERMISSIONS = []
+
 # The file path where custom reports will be stored. A trailing slash is not needed. Note that the default value of
 # this setting is derived from the installed location.
 # REPORTS_ROOT = '/opt/netbox/netbox/reports'

+ 9 - 1
netbox/netbox/settings.py

@@ -98,6 +98,12 @@ PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
 PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
 PLUGINS_ENABLED = getattr(configuration, 'PLUGINS_ENABLED', False)
 PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
+REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False)
+REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'utilities.auth_backends.RemoteUserBackend')
+REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', [])
+REMOTE_AUTH_DEFAULT_PERMISSIONS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_PERMISSIONS', [])
+REMOTE_AUTH_ENABLED = getattr(configuration, 'REMOTE_AUTH_ENABLED', False)
+REMOTE_AUTH_HEADER = getattr(configuration, 'REMOTE_AUTH_HEADER', 'HTTP_REMOTE_USER')
 REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
 SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
 SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
@@ -275,6 +281,7 @@ MIDDLEWARE = [
     'django.middleware.clickjacking.XFrameOptionsMiddleware',
     'django.middleware.security.SecurityMiddleware',
     'utilities.middleware.ExceptionHandlingMiddleware',
+    'utilities.middleware.RemoteUserMiddleware',
     'utilities.middleware.LoginRequiredMiddleware',
     'utilities.middleware.APIVersionMiddleware',
     'extras.middleware.ObjectChangeMiddleware',
@@ -302,8 +309,9 @@ TEMPLATES = [
     },
 ]
 
-# Authentication
+# Set up authentication backends
 AUTHENTICATION_BACKENDS = [
+    REMOTE_AUTH_BACKEND,
     'utilities.auth_backends.ViewExemptModelBackend',
 ]
 

+ 159 - 0
netbox/netbox/tests/test_authentication.py

@@ -0,0 +1,159 @@
+from django.conf import settings
+from django.contrib.auth.models import Group, User
+from django.test import Client, TestCase
+from django.test.utils import override_settings
+from django.urls import reverse
+
+
+class ExternalAuthenticationTestCase(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.user = User.objects.create(username='remoteuser1')
+
+    def setUp(self):
+        self.client = Client()
+
+    @override_settings(
+        LOGIN_REQUIRED=True
+    )
+    def test_remote_auth_disabled(self):
+        """
+        Test enabling remote authentication with the default configuration.
+        """
+        headers = {
+            'HTTP_REMOTE_USER': 'remoteuser1',
+        }
+
+        self.assertFalse(settings.REMOTE_AUTH_ENABLED)
+        self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_REMOTE_USER')
+
+        # Client should not be authenticated
+        response = self.client.get(reverse('home'), follow=True, **headers)
+        self.assertNotIn('_auth_user_id', self.client.session)
+
+    @override_settings(
+        REMOTE_AUTH_ENABLED=True,
+        LOGIN_REQUIRED=True
+    )
+    def test_remote_auth_enabled(self):
+        """
+        Test enabling remote authentication with the default configuration.
+        """
+        headers = {
+            'HTTP_REMOTE_USER': 'remoteuser1',
+        }
+
+        self.assertTrue(settings.REMOTE_AUTH_ENABLED)
+        self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_REMOTE_USER')
+
+        response = self.client.get(reverse('home'), follow=True, **headers)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(int(self.client.session.get('_auth_user_id')), self.user.pk, msg='Authentication failed')
+
+    @override_settings(
+        REMOTE_AUTH_ENABLED=True,
+        REMOTE_AUTH_HEADER='HTTP_FOO',
+        LOGIN_REQUIRED=True
+    )
+    def test_remote_auth_custom_header(self):
+        """
+        Test enabling remote authentication with a custom HTTP header.
+        """
+        headers = {
+            'HTTP_FOO': 'remoteuser1',
+        }
+
+        self.assertTrue(settings.REMOTE_AUTH_ENABLED)
+        self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_FOO')
+
+        response = self.client.get(reverse('home'), follow=True, **headers)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(int(self.client.session.get('_auth_user_id')), self.user.pk, msg='Authentication failed')
+
+    @override_settings(
+        REMOTE_AUTH_ENABLED=True,
+        REMOTE_AUTH_AUTO_CREATE_USER=True,
+        LOGIN_REQUIRED=True
+    )
+    def test_remote_auth_auto_create(self):
+        """
+        Test enabling remote authentication with automatic user creation disabled.
+        """
+        headers = {
+            'HTTP_REMOTE_USER': 'remoteuser2',
+        }
+
+        self.assertTrue(settings.REMOTE_AUTH_ENABLED)
+        self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER)
+        self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_REMOTE_USER')
+
+        response = self.client.get(reverse('home'), follow=True, **headers)
+        self.assertEqual(response.status_code, 200)
+
+        # Local user should have been automatically created
+        new_user = User.objects.get(username='remoteuser2')
+        self.assertEqual(int(self.client.session.get('_auth_user_id')), new_user.pk, msg='Authentication failed')
+
+    @override_settings(
+        REMOTE_AUTH_ENABLED=True,
+        REMOTE_AUTH_AUTO_CREATE_USER=True,
+        REMOTE_AUTH_DEFAULT_GROUPS=['Group 1', 'Group 2'],
+        LOGIN_REQUIRED=True
+    )
+    def test_remote_auth_default_groups(self):
+        """
+        Test enabling remote authentication with the default configuration.
+        """
+        headers = {
+            'HTTP_REMOTE_USER': 'remoteuser2',
+        }
+
+        self.assertTrue(settings.REMOTE_AUTH_ENABLED)
+        self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER)
+        self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_REMOTE_USER')
+        self.assertEqual(settings.REMOTE_AUTH_DEFAULT_GROUPS, ['Group 1', 'Group 2'])
+
+        # Create required groups
+        groups = (
+            Group(name='Group 1'),
+            Group(name='Group 2'),
+            Group(name='Group 3'),
+        )
+        Group.objects.bulk_create(groups)
+
+        response = self.client.get(reverse('home'), follow=True, **headers)
+        self.assertEqual(response.status_code, 200)
+
+        new_user = User.objects.get(username='remoteuser2')
+        self.assertEqual(int(self.client.session.get('_auth_user_id')), new_user.pk, msg='Authentication failed')
+        self.assertListEqual(
+            [groups[0], groups[1]],
+            list(new_user.groups.all())
+        )
+
+    @override_settings(
+        REMOTE_AUTH_ENABLED=True,
+        REMOTE_AUTH_AUTO_CREATE_USER=True,
+        REMOTE_AUTH_DEFAULT_PERMISSIONS=['dcim.add_site', 'dcim.change_site'],
+        LOGIN_REQUIRED=True
+    )
+    def test_remote_auth_default_permissions(self):
+        """
+        Test enabling remote authentication with the default configuration.
+        """
+        headers = {
+            'HTTP_REMOTE_USER': 'remoteuser2',
+        }
+
+        self.assertTrue(settings.REMOTE_AUTH_ENABLED)
+        self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER)
+        self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_REMOTE_USER')
+        self.assertEqual(settings.REMOTE_AUTH_DEFAULT_PERMISSIONS, ['dcim.add_site', 'dcim.change_site'])
+
+        response = self.client.get(reverse('home'), follow=True, **headers)
+        self.assertEqual(response.status_code, 200)
+
+        new_user = User.objects.get(username='remoteuser2')
+        self.assertEqual(int(self.client.session.get('_auth_user_id')), new_user.pk, msg='Authentication failed')
+        self.assertTrue(new_user.has_perms(['dcim.add_site', 'dcim.change_site']))

+ 2 - 1
netbox/tenancy/api/serializers.py

@@ -12,11 +12,12 @@ from .nested_serializers import *
 #
 
 class TenantGroupSerializer(ValidatedModelSerializer):
+    parent = NestedTenantGroupSerializer(required=False, allow_null=True)
     tenant_count = serializers.IntegerField(read_only=True)
 
     class Meta:
         model = TenantGroup
-        fields = ['id', 'name', 'slug', 'tenant_count']
+        fields = ['id', 'name', 'slug', 'parent', 'tenant_count']
 
 
 class TenantSerializer(TaggitSerializer, CustomFieldModelSerializer):

+ 26 - 12
netbox/tenancy/filters.py

@@ -2,7 +2,7 @@ import django_filters
 from django.db.models import Q
 
 from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
-from utilities.filters import BaseFilterSet, NameSlugSearchFilterSet, TagFilter
+from utilities.filters import BaseFilterSet, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter
 from .models import Tenant, TenantGroup
 
 
@@ -14,6 +14,16 @@ __all__ = (
 
 
 class TenantGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
+    parent_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=TenantGroup.objects.all(),
+        label='Tenant group (ID)',
+    )
+    parent = django_filters.ModelMultipleChoiceFilter(
+        field_name='parent__slug',
+        queryset=TenantGroup.objects.all(),
+        to_field_name='slug',
+        label='Tenant group group (slug)',
+    )
 
     class Meta:
         model = TenantGroup
@@ -25,15 +35,18 @@ class TenantFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterS
         method='search',
         label='Search',
     )
-    group_id = django_filters.ModelMultipleChoiceFilter(
+    group_id = TreeNodeMultipleChoiceFilter(
         queryset=TenantGroup.objects.all(),
-        label='Group (ID)',
+        field_name='group',
+        lookup_expr='in',
+        label='Tenant group (ID)',
     )
-    group = django_filters.ModelMultipleChoiceFilter(
-        field_name='group__slug',
+    group = TreeNodeMultipleChoiceFilter(
         queryset=TenantGroup.objects.all(),
+        field_name='group',
+        lookup_expr='in',
         to_field_name='slug',
-        label='Group (slug)',
+        label='Tenant group (slug)',
     )
     tag = TagFilter()
 
@@ -56,16 +69,17 @@ class TenancyFilterSet(django_filters.FilterSet):
     """
     An inheritable FilterSet for models which support Tenant assignment.
     """
-    tenant_group_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='tenant__group__id',
+    tenant_group_id = TreeNodeMultipleChoiceFilter(
         queryset=TenantGroup.objects.all(),
-        to_field_name='id',
+        field_name='tenant__group',
+        lookup_expr='in',
         label='Tenant Group (ID)',
     )
-    tenant_group = django_filters.ModelMultipleChoiceFilter(
-        field_name='tenant__group__slug',
+    tenant_group = TreeNodeMultipleChoiceFilter(
         queryset=TenantGroup.objects.all(),
+        field_name='tenant__group',
         to_field_name='slug',
+        lookup_expr='in',
         label='Tenant Group (slug)',
     )
     tenant_id = django_filters.ModelMultipleChoiceFilter(
@@ -73,8 +87,8 @@ class TenancyFilterSet(django_filters.FilterSet):
         label='Tenant (ID)',
     )
     tenant = django_filters.ModelMultipleChoiceFilter(
-        field_name='tenant__slug',
         queryset=Tenant.objects.all(),
+        field_name='tenant__slug',
         to_field_name='slug',
         label='Tenant (slug)',
     )

+ 17 - 1
netbox/tenancy/forms.py

@@ -16,16 +16,32 @@ from .models import Tenant, TenantGroup
 #
 
 class TenantGroupForm(BootstrapMixin, forms.ModelForm):
+    parent = DynamicModelChoiceField(
+        queryset=TenantGroup.objects.all(),
+        required=False,
+        widget=APISelect(
+            api_url="/api/tenancy/tenant-groups/"
+        )
+    )
     slug = SlugField()
 
     class Meta:
         model = TenantGroup
         fields = [
-            'name', 'slug',
+            'parent', 'name', 'slug',
         ]
 
 
 class TenantGroupCSVForm(forms.ModelForm):
+    parent = forms.ModelChoiceField(
+        queryset=TenantGroup.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Name of parent tenant group',
+        error_messages={
+            'invalid_choice': 'Tenant group not found.',
+        }
+    )
     slug = SlugField()
 
     class Meta:

+ 43 - 0
netbox/tenancy/migrations/0007_nested_tenantgroups.py

@@ -0,0 +1,43 @@
+from django.db import migrations, models
+import django.db.models.deletion
+import mptt.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('tenancy', '0006_custom_tag_models'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='tenantgroup',
+            name='parent',
+            field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='tenancy.TenantGroup'),
+        ),
+        migrations.AddField(
+            model_name='tenantgroup',
+            name='level',
+            field=models.PositiveIntegerField(default=0, editable=False),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='tenantgroup',
+            name='lft',
+            field=models.PositiveIntegerField(default=1, editable=False),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='tenantgroup',
+            name='rght',
+            field=models.PositiveIntegerField(default=2, editable=False),
+            preserve_default=False,
+        ),
+        # tree_id will be set to a valid value during the following migration (which needs to be a separate migration)
+        migrations.AddField(
+            model_name='tenantgroup',
+            name='tree_id',
+            field=models.PositiveIntegerField(db_index=True, default=0, editable=False),
+            preserve_default=False,
+        ),
+    ]

+ 21 - 0
netbox/tenancy/migrations/0008_nested_tenantgroups_rebuild.py

@@ -0,0 +1,21 @@
+from django.db import migrations
+
+
+def rebuild_mptt(apps, schema_editor):
+    TenantGroup = apps.get_model('tenancy', 'TenantGroup')
+    for i, tenantgroup in enumerate(TenantGroup.objects.all(), start=1):
+        TenantGroup.objects.filter(pk=tenantgroup.pk).update(tree_id=i)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('tenancy', '0007_nested_tenantgroups'),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            code=rebuild_mptt,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 26 - 3
netbox/tenancy/models.py

@@ -1,10 +1,12 @@
 from django.contrib.contenttypes.fields import GenericRelation
 from django.db import models
 from django.urls import reverse
+from mptt.models import MPTTModel, TreeForeignKey
 from taggit.managers import TaggableManager
 
-from extras.models import CustomFieldModel, TaggedItem
+from extras.models import CustomFieldModel, ObjectChange, TaggedItem
 from utilities.models import ChangeLoggedModel
+from utilities.utils import serialize_object
 
 
 __all__ = (
@@ -13,7 +15,7 @@ __all__ = (
 )
 
 
-class TenantGroup(ChangeLoggedModel):
+class TenantGroup(MPTTModel, ChangeLoggedModel):
     """
     An arbitrary collection of Tenants.
     """
@@ -24,12 +26,23 @@ class TenantGroup(ChangeLoggedModel):
     slug = models.SlugField(
         unique=True
     )
+    parent = TreeForeignKey(
+        to='self',
+        on_delete=models.CASCADE,
+        related_name='children',
+        blank=True,
+        null=True,
+        db_index=True
+    )
 
-    csv_headers = ['name', 'slug']
+    csv_headers = ['name', 'slug', 'parent']
 
     class Meta:
         ordering = ['name']
 
+    class MPTTMeta:
+        order_insertion_by = ['name']
+
     def __str__(self):
         return self.name
 
@@ -40,6 +53,16 @@ class TenantGroup(ChangeLoggedModel):
         return (
             self.name,
             self.slug,
+            self.parent.name if self.parent else '',
+        )
+
+    def to_objectchange(self, action):
+        # Remove MPTT-internal fields
+        return ObjectChange(
+            changed_object=self,
+            object_repr=str(self),
+            action=action,
+            object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id'])
         )
 
 

+ 21 - 4
netbox/tenancy/tables.py

@@ -3,6 +3,16 @@ import django_tables2 as tables
 from utilities.tables import BaseTable, ToggleColumn
 from .models import Tenant, TenantGroup
 
+MPTT_LINK = """
+{% if record.get_children %}
+    <span style="padding-left: {{ record.get_ancestors|length }}0px "><i class="fa fa-caret-right"></i>
+{% else %}
+    <span style="padding-left: {{ record.get_ancestors|length }}9px">
+{% endif %}
+    <a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
+</span>
+"""
+
 TENANTGROUP_ACTIONS = """
 <a href="{% url 'tenancy:tenantgroup_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
     <i class="fa fa-history"></i>
@@ -27,11 +37,18 @@ COL_TENANT = """
 
 class TenantGroupTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.LinkColumn(verbose_name='Name')
-    tenant_count = tables.Column(verbose_name='Tenants')
-    slug = tables.Column(verbose_name='Slug')
+    name = tables.TemplateColumn(
+        template_code=MPTT_LINK,
+        orderable=False
+    )
+    tenant_count = tables.Column(
+        verbose_name='Tenants'
+    )
+    slug = tables.Column()
     actions = tables.TemplateColumn(
-        template_code=TENANTGROUP_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name=''
+        template_code=TENANTGROUP_ACTIONS,
+        attrs={'td': {'class': 'text-right noprint'}},
+        verbose_name=''
     )
 
     class Meta(BaseTable.Meta):

+ 59 - 33
netbox/tenancy/tests/test_api.py

@@ -28,23 +28,34 @@ class TenantGroupTest(APITestCase):
 
         super().setUp()
 
-        self.tenantgroup1 = TenantGroup.objects.create(name='Test Tenant Group 1', slug='test-tenant-group-1')
-        self.tenantgroup2 = TenantGroup.objects.create(name='Test Tenant Group 2', slug='test-tenant-group-2')
-        self.tenantgroup3 = TenantGroup.objects.create(name='Test Tenant Group 3', slug='test-tenant-group-3')
+        self.parent_tenant_groups = (
+            TenantGroup(name='Parent Tenant Group 1', slug='parent-tenant-group-1'),
+            TenantGroup(name='Parent Tenant Group 2', slug='parent-tenant-group-2'),
+        )
+        for tenantgroup in self.parent_tenant_groups:
+            tenantgroup.save()
+
+        self.tenant_groups = (
+            TenantGroup(name='Tenant Group 1', slug='tenant-group-1', parent=self.parent_tenant_groups[0]),
+            TenantGroup(name='Tenant Group 2', slug='tenant-group-2', parent=self.parent_tenant_groups[0]),
+            TenantGroup(name='Tenant Group 3', slug='tenant-group-3', parent=self.parent_tenant_groups[0]),
+        )
+        for tenantgroup in self.tenant_groups:
+            tenantgroup.save()
 
     def test_get_tenantgroup(self):
 
-        url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenantgroup1.pk})
+        url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenant_groups[0].pk})
         response = self.client.get(url, **self.header)
 
-        self.assertEqual(response.data['name'], self.tenantgroup1.name)
+        self.assertEqual(response.data['name'], self.tenant_groups[0].name)
 
     def test_list_tenantgroups(self):
 
         url = reverse('tenancy-api:tenantgroup-list')
         response = self.client.get(url, **self.header)
 
-        self.assertEqual(response.data['count'], 3)
+        self.assertEqual(response.data['count'], 5)
 
     def test_list_tenantgroups_brief(self):
 
@@ -59,33 +70,38 @@ class TenantGroupTest(APITestCase):
     def test_create_tenantgroup(self):
 
         data = {
-            'name': 'Test Tenant Group 4',
-            'slug': 'test-tenant-group-4',
+            'name': 'Tenant Group 4',
+            'slug': 'tenant-group-4',
+            'parent': self.parent_tenant_groups[0].pk,
         }
 
         url = reverse('tenancy-api:tenantgroup-list')
         response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
-        self.assertEqual(TenantGroup.objects.count(), 4)
+        self.assertEqual(TenantGroup.objects.count(), 6)
         tenantgroup4 = TenantGroup.objects.get(pk=response.data['id'])
         self.assertEqual(tenantgroup4.name, data['name'])
         self.assertEqual(tenantgroup4.slug, data['slug'])
+        self.assertEqual(tenantgroup4.parent_id, data['parent'])
 
     def test_create_tenantgroup_bulk(self):
 
         data = [
             {
-                'name': 'Test Tenant Group 4',
-                'slug': 'test-tenant-group-4',
+                'name': 'Tenant Group 4',
+                'slug': 'tenant-group-4',
+                'parent': self.parent_tenant_groups[0].pk,
             },
             {
-                'name': 'Test Tenant Group 5',
-                'slug': 'test-tenant-group-5',
+                'name': 'Tenant Group 5',
+                'slug': 'tenant-group-5',
+                'parent': self.parent_tenant_groups[0].pk,
             },
             {
-                'name': 'Test Tenant Group 6',
-                'slug': 'test-tenant-group-6',
+                'name': 'Tenant Group 6',
+                'slug': 'tenant-group-6',
+                'parent': self.parent_tenant_groups[0].pk,
             },
         ]
 
@@ -93,7 +109,7 @@ class TenantGroupTest(APITestCase):
         response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
-        self.assertEqual(TenantGroup.objects.count(), 6)
+        self.assertEqual(TenantGroup.objects.count(), 8)
         self.assertEqual(response.data[0]['name'], data[0]['name'])
         self.assertEqual(response.data[1]['name'], data[1]['name'])
         self.assertEqual(response.data[2]['name'], data[2]['name'])
@@ -101,26 +117,28 @@ class TenantGroupTest(APITestCase):
     def test_update_tenantgroup(self):
 
         data = {
-            'name': 'Test Tenant Group X',
-            'slug': 'test-tenant-group-x',
+            'name': 'Tenant Group X',
+            'slug': 'tenant-group-x',
+            'parent': self.parent_tenant_groups[1].pk,
         }
 
-        url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenantgroup1.pk})
+        url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenant_groups[0].pk})
         response = self.client.put(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
-        self.assertEqual(TenantGroup.objects.count(), 3)
+        self.assertEqual(TenantGroup.objects.count(), 5)
         tenantgroup1 = TenantGroup.objects.get(pk=response.data['id'])
         self.assertEqual(tenantgroup1.name, data['name'])
         self.assertEqual(tenantgroup1.slug, data['slug'])
+        self.assertEqual(tenantgroup1.parent_id, data['parent'])
 
     def test_delete_tenantgroup(self):
 
-        url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenantgroup1.pk})
+        url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenant_groups[0].pk})
         response = self.client.delete(url, **self.header)
 
         self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
-        self.assertEqual(TenantGroup.objects.count(), 2)
+        self.assertEqual(TenantGroup.objects.count(), 4)
 
 
 class TenantTest(APITestCase):
@@ -129,18 +147,26 @@ class TenantTest(APITestCase):
 
         super().setUp()
 
-        self.tenantgroup1 = TenantGroup.objects.create(name='Test Tenant Group 1', slug='test-tenant-group-1')
-        self.tenantgroup2 = TenantGroup.objects.create(name='Test Tenant Group 2', slug='test-tenant-group-2')
-        self.tenant1 = Tenant.objects.create(name='Test Tenant 1', slug='test-tenant-1', group=self.tenantgroup1)
-        self.tenant2 = Tenant.objects.create(name='Test Tenant 2', slug='test-tenant-2', group=self.tenantgroup1)
-        self.tenant3 = Tenant.objects.create(name='Test Tenant 3', slug='test-tenant-3', group=self.tenantgroup1)
+        self.tenant_groups = (
+            TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
+            TenantGroup(name='Tenant Group 2', slug='tenant-group-2'),
+        )
+        for tenantgroup in self.tenant_groups:
+            tenantgroup.save()
+
+        self.tenants = (
+            Tenant(name='Test Tenant 1', slug='test-tenant-1', group=self.tenant_groups[0]),
+            Tenant(name='Test Tenant 2', slug='test-tenant-2', group=self.tenant_groups[0]),
+            Tenant(name='Test Tenant 3', slug='test-tenant-3', group=self.tenant_groups[0]),
+        )
+        Tenant.objects.bulk_create(self.tenants)
 
     def test_get_tenant(self):
 
-        url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenant1.pk})
+        url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenants[0].pk})
         response = self.client.get(url, **self.header)
 
-        self.assertEqual(response.data['name'], self.tenant1.name)
+        self.assertEqual(response.data['name'], self.tenants[0].name)
 
     def test_list_tenants(self):
 
@@ -164,7 +190,7 @@ class TenantTest(APITestCase):
         data = {
             'name': 'Test Tenant 4',
             'slug': 'test-tenant-4',
-            'group': self.tenantgroup1.pk,
+            'group': self.tenant_groups[0].pk,
         }
 
         url = reverse('tenancy-api:tenant-list')
@@ -208,10 +234,10 @@ class TenantTest(APITestCase):
         data = {
             'name': 'Test Tenant X',
             'slug': 'test-tenant-x',
-            'group': self.tenantgroup2.pk,
+            'group': self.tenant_groups[1].pk,
         }
 
-        url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenant1.pk})
+        url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenants[0].pk})
         response = self.client.put(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
@@ -223,7 +249,7 @@ class TenantTest(APITestCase):
 
     def test_delete_tenant(self):
 
-        url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenant1.pk})
+        url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenants[0].pk})
         response = self.client.delete(url, **self.header)
 
         self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)

+ 27 - 10
netbox/tenancy/tests/test_filters.py

@@ -11,12 +11,21 @@ class TenantGroupTestCase(TestCase):
     @classmethod
     def setUpTestData(cls):
 
-        groups = (
-            TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
-            TenantGroup(name='Tenant Group 2', slug='tenant-group-2'),
-            TenantGroup(name='Tenant Group 3', slug='tenant-group-3'),
+        parent_tenant_groups = (
+            TenantGroup(name='Parent Tenant Group 1', slug='parent-tenant-group-1'),
+            TenantGroup(name='Parent Tenant Group 2', slug='parent-tenant-group-2'),
+            TenantGroup(name='Parent Tenant Group 3', slug='parent-tenant-group-3'),
+        )
+        for tenantgroup in parent_tenant_groups:
+            tenantgroup.save()
+
+        tenant_groups = (
+            TenantGroup(name='Tenant Group 1', slug='tenant-group-1', parent=parent_tenant_groups[0]),
+            TenantGroup(name='Tenant Group 2', slug='tenant-group-2', parent=parent_tenant_groups[1]),
+            TenantGroup(name='Tenant Group 3', slug='tenant-group-3', parent=parent_tenant_groups[2]),
         )
-        TenantGroup.objects.bulk_create(groups)
+        for tenantgroup in tenant_groups:
+            tenantgroup.save()
 
     def test_id(self):
         id_list = self.queryset.values_list('id', flat=True)[:2]
@@ -31,6 +40,13 @@ class TenantGroupTestCase(TestCase):
         params = {'slug': ['tenant-group-1', 'tenant-group-2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_parent(self):
+        parent_groups = TenantGroup.objects.filter(name__startswith='Parent')[:2]
+        params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 class TenantTestCase(TestCase):
     queryset = Tenant.objects.all()
@@ -39,17 +55,18 @@ class TenantTestCase(TestCase):
     @classmethod
     def setUpTestData(cls):
 
-        groups = (
+        tenant_groups = (
             TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
             TenantGroup(name='Tenant Group 2', slug='tenant-group-2'),
             TenantGroup(name='Tenant Group 3', slug='tenant-group-3'),
         )
-        TenantGroup.objects.bulk_create(groups)
+        for tenantgroup in tenant_groups:
+            tenantgroup.save()
 
         tenants = (
-            Tenant(name='Tenant 1', slug='tenant-1', group=groups[0]),
-            Tenant(name='Tenant 2', slug='tenant-2', group=groups[1]),
-            Tenant(name='Tenant 3', slug='tenant-3', group=groups[2]),
+            Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
+            Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]),
+            Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]),
         )
         Tenant.objects.bulk_create(tenants)
 

+ 12 - 9
netbox/tenancy/tests/test_views.py

@@ -8,11 +8,13 @@ class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     @classmethod
     def setUpTestData(cls):
 
-        TenantGroup.objects.bulk_create([
+        tenant_groups = (
             TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
             TenantGroup(name='Tenant Group 2', slug='tenant-group-2'),
             TenantGroup(name='Tenant Group 3', slug='tenant-group-3'),
-        ])
+        )
+        for tenanantgroup in tenant_groups:
+            tenanantgroup.save()
 
         cls.form_data = {
             'name': 'Tenant Group X',
@@ -33,22 +35,23 @@ class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     @classmethod
     def setUpTestData(cls):
 
-        tenantgroups = (
+        tenant_groups = (
             TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
             TenantGroup(name='Tenant Group 2', slug='tenant-group-2'),
         )
-        TenantGroup.objects.bulk_create(tenantgroups)
+        for tenanantgroup in tenant_groups:
+            tenanantgroup.save()
 
         Tenant.objects.bulk_create([
-            Tenant(name='Tenant 1', slug='tenant-1', group=tenantgroups[0]),
-            Tenant(name='Tenant 2', slug='tenant-2', group=tenantgroups[0]),
-            Tenant(name='Tenant 3', slug='tenant-3', group=tenantgroups[0]),
+            Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
+            Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[0]),
+            Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[0]),
         ])
 
         cls.form_data = {
             'name': 'Tenant X',
             'slug': 'tenant-x',
-            'group': tenantgroups[1].pk,
+            'group': tenant_groups[1].pk,
             'description': 'A new tenant',
             'comments': 'Some comments',
             'tags': 'Alpha,Bravo,Charlie',
@@ -62,5 +65,5 @@ class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
 
         cls.bulk_edit_data = {
-            'group': tenantgroups[1].pk,
+            'group': tenant_groups[1].pk,
         }

+ 7 - 1
netbox/tenancy/views.py

@@ -20,7 +20,13 @@ from .models import Tenant, TenantGroup
 
 class TenantGroupListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'tenancy.view_tenantgroup'
-    queryset = TenantGroup.objects.annotate(tenant_count=Count('tenants'))
+    queryset = TenantGroup.objects.add_related_count(
+        TenantGroup.objects.all(),
+        Tenant,
+        'group',
+        'tenant_count',
+        cumulative=True
+    )
     table = tables.TenantGroupTable
 
 

+ 46 - 1
netbox/utilities/auth_backends.py

@@ -1,5 +1,8 @@
+import logging
+
 from django.conf import settings
-from django.contrib.auth.backends import ModelBackend
+from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as RemoteUserBackend_
+from django.contrib.auth.models import Group, Permission
 
 
 class ViewExemptModelBackend(ModelBackend):
@@ -26,3 +29,45 @@ class ViewExemptModelBackend(ModelBackend):
             pass
 
         return super().has_perm(user_obj, perm, obj)
+
+
+class RemoteUserBackend(ViewExemptModelBackend, RemoteUserBackend_):
+    """
+    Custom implementation of Django's RemoteUserBackend which provides configuration hooks for basic customization.
+    """
+    @property
+    def create_unknown_user(self):
+        return settings.REMOTE_AUTH_AUTO_CREATE_USER
+
+    def configure_user(self, request, user):
+        logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
+
+        # Assign default groups to the user
+        group_list = []
+        for name in settings.REMOTE_AUTH_DEFAULT_GROUPS:
+            try:
+                group_list.append(Group.objects.get(name=name))
+            except Group.DoesNotExist:
+                logging.error("Could not assign group {name} to remotely-authenticated user {user}: Group not found")
+        if group_list:
+            user.groups.add(*group_list)
+            logger.debug(f"Assigned groups to remotely-authenticated user {user}: {group_list}")
+
+        # Assign default permissions to the user
+        permissions_list = []
+        for permission_name in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS:
+            try:
+                app_label, codename = permission_name.split('.')
+                permissions_list.append(
+                    Permission.objects.get(content_type__app_label=app_label, codename=codename)
+                )
+            except (ValueError, Permission.DoesNotExist):
+                logging.error(
+                    "Invalid permission name: '{permission_name}'. Permissions must be in the form "
+                    "<app>.<action>_<model>. (Example: dcim.add_site)"
+                )
+        if permissions_list:
+            user.user_permissions.add(*permissions_list)
+            logger.debug(f"Assigned permissions to remotely-authenticated user {user}: {permissions_list}")
+
+        return user

+ 20 - 0
netbox/utilities/middleware.py

@@ -1,6 +1,7 @@
 from urllib import parse
 
 from django.conf import settings
+from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
 from django.db import ProgrammingError
 from django.http import Http404, HttpResponseRedirect
 from django.urls import reverse
@@ -31,6 +32,25 @@ class LoginRequiredMiddleware(object):
         return self.get_response(request)
 
 
+class RemoteUserMiddleware(RemoteUserMiddleware_):
+    """
+    Custom implementation of Django's RemoteUserMiddleware which allows for a user-configurable HTTP header name.
+    """
+    force_logout_if_no_header = False
+
+    @property
+    def header(self):
+        return settings.REMOTE_AUTH_HEADER
+
+    def process_request(self, request):
+
+        # Bypass middleware if remote authentication is not enabled
+        if not settings.REMOTE_AUTH_ENABLED:
+            return
+
+        return super().process_request(request)
+
+
 class APIVersionMiddleware(object):
     """
     If the request is for an API endpoint, include the API version as a response header.

+ 4 - 2
netbox/virtualization/tests/test_filters.py

@@ -105,7 +105,8 @@ class ClusterTestCase(TestCase):
             TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
             TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
         )
-        TenantGroup.objects.bulk_create(tenant_groups)
+        for tenantgroup in tenant_groups:
+            tenantgroup.save()
 
         tenants = (
             Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
@@ -231,7 +232,8 @@ class VirtualMachineTestCase(TestCase):
             TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
             TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
         )
-        TenantGroup.objects.bulk_create(tenant_groups)
+        for tenantgroup in tenant_groups:
+            tenantgroup.save()
 
         tenants = (
             Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),