diff --git a/docs/administration/permissions.md b/docs/administration/permissions.md new file mode 100644 index 000000000..7e47db0d9 --- /dev/null +++ b/docs/administration/permissions.md @@ -0,0 +1,43 @@ +# Permissions + +NetBox v2.9 introduced a new object-based permissions framework, which replace's Django's built-in permission model. Object-based permissions allow for the assignment of permissions to an arbitrary subset of objects of a certain type, rather than only by type of object. For example, it is possible to grant a user permission to view only sites within a particular region, or to modify only VLANs with a numeric ID within a certain range. + +{!docs/models/users/objectpermission.md!} + +### Example Constraint Definitions + +| Query Filter | Permission Constraints | +| ------------ | --------------------- | +| `filter(status='active')` | `{"status": "active"}` | +| `filter(status='active', role='testing')` | `{"status": "active", "role": "testing"}` | +| `filter(status__in=['planned', 'reserved'])` | `{"status__in": ["planned", "reserved"]}` | +| `filter(name__startswith('Foo')` | `{"name__startswith": "Foo"}` | +| `filter(vid__gte=100, vid__lt=200)` | `{"vid__gte": 100, "vid__lt": 200}` | + +## Permissions Enforcement + +### Viewing Objects + +Object-based permissions work by filtering the database query generated by a user's request to restrict the set of objects returned. When a request is received, NetBox first determines whether the user is authenticated and has been granted to perform the requested action. For example, if the requested URL is `/dcim/devices/`, NetBox will check for the `dcim.view_device` permission. If the user has not been assigned this permission (either directly or via a group assignment), NetBox will return a 403 (forbidden) HTTP response. + +If the permission has been granted, NetBox will compile any specified constraints for the model and action. For example, suppose two permissions have been assigned to the user granting view access to the device model, with the following constraints: + +```json +[ + {"site__name__in": ["NYC1", "NYC2"]}, + {"status": "offline", "tenant__isnull": true} +] +``` + +This grants the user access to view any device that is in NYC1 or NYC2, **or** which has a status of "offline" and has no tenant assigned. These constraints will result in the following ORM query: + +```no-highlight +Site.objects.filter( + Q(site__name__in=['NYC1', 'NYC2']), + Q(status='active', tenant__isnull=True) +) +``` + +### Creating and Modifying Objects + +The same sort of logic is in play when a user attempts to create or modify an object in NetBox, with a twist. Once validation has completed, NetBox starts an atomic database transaction to facilitate the change, and the object is created or saved normally. Next, still within the transaction, NetBox issues a second query to retrieve the newly created/updated object, filtering the restricted queryset with the object's primary key. If this query fails to return the object, NetBox knows that the new revision does not match the constraints imposed by the permission. The transaction is then aborted, and the database is left in its original state. diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 3c4392915..31ee39a5f 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -384,7 +384,7 @@ NetBox can be configured to support remote user authentication by inferring user ## REMOTE_AUTH_BACKEND -Default: `'utilities.auth_backends.RemoteUserBackend'` +Default: `'netbox.authentication.RemoteUserBackend'` Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication, if not using NetBox's built-in backend. (Requires `REMOTE_AUTH_ENABLED`.) @@ -416,9 +416,9 @@ The list of groups to assign a new user account when created using remote authen ## REMOTE_AUTH_DEFAULT_PERMISSIONS -Default: `[]` (Empty list) +Default: `{}` (Empty dictionary) -The list of permissions to assign a new user account when created using remote authentication. (Requires `REMOTE_AUTH_ENABLED`.) +A mapping of permissions to assign a new user account when created using remote authentication. Each key in the dictionary should be set to a dictionary of the attributes to be applied to the permission, or `None` to allow all objects. (Requires `REMOTE_AUTH_ENABLED`.) --- diff --git a/docs/development/utility-views.md b/docs/development/utility-views.md index a6e50f71e..3b9c1053d 100644 --- a/docs/development/utility-views.md +++ b/docs/development/utility-views.md @@ -4,6 +4,10 @@ Utility views are reusable views that handle common CRUD tasks, such as listing ## Individual Views +### ObjectView + +Retrieve and display a single object. + ### ObjectListView Generates a paginated table of objects from a given queryset, which may optionally be filtered. diff --git a/docs/models/users/objectpermission.md b/docs/models/users/objectpermission.md new file mode 100644 index 000000000..80313fc0b --- /dev/null +++ b/docs/models/users/objectpermission.md @@ -0,0 +1,36 @@ +# Object Permissions + +Assigning a permission in NetBox entails defining a relationship among several components: + +* Object type(s) - One or more types of object in NetBox +* User(s) - One or more users or groups of users +* Actions - The actions that can be performed (view, add, change, and/or delete) +* Constraints - An arbitrary filter used to limit the granted action(s) to a specific subset of objects + +At a minimum, a permission assignment must specify one object type, one user or group, and one action. The specification of constraints is optional: A permission without any constraints specified will apply to all instances of the selected model(s). + +## Actions + +There are four core actions that can be permitted for each type of object within NetBox, roughly analogous to the CRUD convention (create, read, update, and delete): + +* View - Retrieve an object from the database +* Add - Create a new object +* Change - Modify an existing object +* Delete - Delete an existing object + +Some models introduce additional permissions that can be granted to allow other actions. For example, the `napalm_read` permission on the device model allows a user to execute NAPALM queries on a device via NetBox's REST API. These can be specified when granting a permission in the "additional actions" field. + +## Constraints + +Constraints are defined as a JSON object representing a [Django query filter](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups). This is the same syntax that you would pass to the QuerySet `filter()` method when performing a query using the Django ORM. As with query filters, double underscores can be used to traverse related objects or invoke lookup expressions. Some example queries and their corresponding definitions are shown below. + +All constraints defined on a permission are applied with a logic AND. For example, suppose you assign a permission for the site model with the following constraints. + +```json +{ + "status": "active", + "region__name": "Americas" +} +``` + +The permission will grant access only to sites which have a status of "active" **and** which are assigned to the "Americas" region. To achieve a logical OR with a different set of constraints, simply create another permission assignment for the same model and user/group. diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md new file mode 100644 index 000000000..be6004feb --- /dev/null +++ b/docs/release-notes/version-2.9.md @@ -0,0 +1,18 @@ +# NetBox v2.8 + +## v2.9.0 (FUTURE) + +### New Features + +#### Object-Based Permissions ([#554](https://github.com/netbox-community/netbox/issues/554)) + +NetBox v2.9 replaces Django's built-in permissions framework with one that supports object-based assignment of permissions using arbitrary constraints. When granting a user or group to perform a certain action on one or more types of objects, an administrator can optionally specify a set of constraints. The permission will apply only to objects which match the specified constraints. For example, assigning permission to modify devices with the constraint `{"tenant__group__name": "Customers"}` would grant the permission only for devices assigned to a tenant belonging to the "Customers" group. + +### Configuration Changes + +* `REMOTE_AUTH_DEFAULT_PERMISSIONS` now takes a dictionary rather than a list. This is a mapping of permission names to a dictionary of constraining attributes, or `None`. For example, `['dcim.add_site', 'dcim.change_site']` would become `{'dcim.add_site': None, 'dcim.change_site': None}`. + +### Other Changes + +* The `secrets.activate_userkey` permission no longer exists. Instead, `secrets.change_userkey` is checked to determine whether a user has the ability to activate a UserKey. +* The `users.delete_token` permission is no longer enforced. All users are permitted to delete their own API tokens. diff --git a/mkdocs.yml b/mkdocs.yml index b8633ea8f..2c58acbd8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -58,6 +58,7 @@ nav: - Using Plugins: 'plugins/index.md' - Developing Plugins: 'plugins/development.md' - Administration: + - Permissions: 'administration/permissions.md' - Replicating NetBox: 'administration/replicating-netbox.md' - NetBox Shell: 'administration/netbox-shell.md' - API: diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 57d41a994..dcf1c5118 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -8,6 +8,7 @@ from dcim.fields import ASNField from dcim.models import CableTermination from extras.models import CustomFieldModel, ObjectChange, TaggedItem from extras.utils import extras_features +from utilities.querysets import RestrictedQuerySet from utilities.models import ChangeLoggedModel from utilities.utils import serialize_object from .choices import * @@ -66,9 +67,10 @@ class Provider(ChangeLoggedModel, CustomFieldModel): content_type_field='obj_type', object_id_field='obj_id' ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = [ 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', ] @@ -115,6 +117,8 @@ class CircuitType(ChangeLoggedModel): blank=True, ) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'description'] class Meta: @@ -300,6 +304,8 @@ class CircuitTermination(CableTermination): blank=True ) + objects = RestrictedQuerySet.as_manager() + class Meta: ordering = ['circuit', 'term_side'] unique_together = ['circuit', 'term_side'] diff --git a/netbox/circuits/querysets.py b/netbox/circuits/querysets.py index 60956f32a..8a9bd50a4 100644 --- a/netbox/circuits/querysets.py +++ b/netbox/circuits/querysets.py @@ -1,7 +1,9 @@ -from django.db.models import OuterRef, QuerySet, Subquery +from django.db.models import OuterRef, Subquery + +from utilities.querysets import RestrictedQuerySet -class CircuitQuerySet(QuerySet): +class CircuitQuerySet(RestrictedQuerySet): def annotate_sites(self): """ diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 72d9720df..1c0f0715b 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -10,7 +10,7 @@ urlpatterns = [ # Providers path('providers/', views.ProviderListView.as_view(), name='provider_list'), - path('providers/add/', views.ProviderCreateView.as_view(), name='provider_add'), + path('providers/add/', views.ProviderEditView.as_view(), name='provider_add'), path('providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'), path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'), path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'), @@ -21,7 +21,7 @@ urlpatterns = [ # Circuit types path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'), - path('circuit-types/add/', views.CircuitTypeCreateView.as_view(), name='circuittype_add'), + path('circuit-types/add/', views.CircuitTypeEditView.as_view(), name='circuittype_add'), path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'), path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), path('circuit-types//edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), @@ -29,7 +29,7 @@ urlpatterns = [ # Circuits path('circuits/', views.CircuitListView.as_view(), name='circuit_list'), - path('circuits/add/', views.CircuitCreateView.as_view(), name='circuit_add'), + path('circuits/add/', views.CircuitEditView.as_view(), name='circuit_add'), path('circuits/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'), path('circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'), path('circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'), @@ -37,11 +37,10 @@ urlpatterns = [ path('circuits//edit/', views.CircuitEditView.as_view(), name='circuit_edit'), path('circuits//delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'), path('circuits//changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}), - path('circuits//terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'), + path('circuits//terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'), # Circuit terminations - - path('circuits//terminations/add/', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'), + path('circuits//terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'), path('circuit-terminations//edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), path('circuit-terminations//delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), path('circuit-terminations//connect//', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}), diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 0546b3832..5da912f0a 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -1,18 +1,15 @@ from django.conf import settings from django.contrib import messages -from django.contrib.auth.decorators import permission_required -from django.contrib.auth.mixins import PermissionRequiredMixin from django.db import transaction -from django.db.models import Count, OuterRef, Subquery +from django.db.models import Count, OuterRef from django.shortcuts import get_object_or_404, redirect, render -from django.views.generic import View from django_tables2 import RequestConfig from extras.models import Graph from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator from utilities.views import ( - BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, + BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView, ) from . import filters, forms, tables from .choices import CircuitTerminationSideChoices @@ -23,21 +20,20 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider # Providers # -class ProviderListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'circuits.view_provider' +class ProviderListView(ObjectListView): queryset = Provider.objects.annotate(count_circuits=Count('circuits')) filterset = filters.ProviderFilterSet filterset_form = forms.ProviderFilterForm table = tables.ProviderTable -class ProviderView(PermissionRequiredMixin, View): - permission_required = 'circuits.view_provider' +class ProviderView(ObjectView): + queryset = Provider.objects.all() def get(self, request, slug): - provider = get_object_or_404(Provider, slug=slug) - circuits = Circuit.objects.filter( + provider = get_object_or_404(self.queryset, slug=slug) + circuits = Circuit.objects.restrict(request.user, 'view').filter( provider=provider ).prefetch_related( 'type', 'tenant', 'terminations__site' @@ -60,33 +56,26 @@ class ProviderView(PermissionRequiredMixin, View): }) -class ProviderCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'circuits.add_provider' +class ProviderEditView(ObjectEditView): queryset = Provider.objects.all() model_form = forms.ProviderForm template_name = 'circuits/provider_edit.html' default_return_url = 'circuits:provider_list' -class ProviderEditView(ProviderCreateView): - permission_required = 'circuits.change_provider' - - -class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'circuits.delete_provider' +class ProviderDeleteView(ObjectDeleteView): queryset = Provider.objects.all() default_return_url = 'circuits:provider_list' -class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'circuits.add_provider' +class ProviderBulkImportView(BulkImportView): + queryset = Provider.objects.all() model_form = forms.ProviderCSVForm table = tables.ProviderTable default_return_url = 'circuits:provider_list' -class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'circuits.change_provider' +class ProviderBulkEditView(BulkEditView): queryset = Provider.objects.annotate(count_circuits=Count('circuits')) filterset = filters.ProviderFilterSet table = tables.ProviderTable @@ -94,8 +83,7 @@ class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView): default_return_url = 'circuits:provider_list' -class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'circuits.delete_provider' +class ProviderBulkDeleteView(BulkDeleteView): queryset = Provider.objects.annotate(count_circuits=Count('circuits')) filterset = filters.ProviderFilterSet table = tables.ProviderTable @@ -106,32 +94,25 @@ class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Circuit Types # -class CircuitTypeListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'circuits.view_circuittype' +class CircuitTypeListView(ObjectListView): queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')) table = tables.CircuitTypeTable -class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'circuits.add_circuittype' +class CircuitTypeEditView(ObjectEditView): queryset = CircuitType.objects.all() model_form = forms.CircuitTypeForm default_return_url = 'circuits:circuittype_list' -class CircuitTypeEditView(CircuitTypeCreateView): - permission_required = 'circuits.change_circuittype' - - -class CircuitTypeBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'circuits.add_circuittype' +class CircuitTypeBulkImportView(BulkImportView): + queryset = CircuitType.objects.all() model_form = forms.CircuitTypeCSVForm table = tables.CircuitTypeTable default_return_url = 'circuits:circuittype_list' -class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'circuits.delete_circuittype' +class CircuitTypeBulkDeleteView(BulkDeleteView): queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')) table = tables.CircuitTypeTable default_return_url = 'circuits:circuittype_list' @@ -141,8 +122,7 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Circuits # -class CircuitListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'circuits.view_circuit' +class CircuitListView(ObjectListView): _terminations = CircuitTermination.objects.filter(circuit=OuterRef('pk')) queryset = Circuit.objects.prefetch_related( 'provider', 'type', 'tenant', 'terminations__site' @@ -152,18 +132,18 @@ class CircuitListView(PermissionRequiredMixin, ObjectListView): table = tables.CircuitTable -class CircuitView(PermissionRequiredMixin, View): - permission_required = 'circuits.view_circuit' +class CircuitView(ObjectView): + queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant__group') def get(self, request, pk): - circuit = get_object_or_404(Circuit.objects.prefetch_related('provider', 'type', 'tenant__group'), pk=pk) - termination_a = CircuitTermination.objects.prefetch_related( + circuit = get_object_or_404(self.queryset, pk=pk) + termination_a = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related( 'site__region', 'connected_endpoint__device' ).filter( circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A ).first() - termination_z = CircuitTermination.objects.prefetch_related( + termination_z = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related( 'site__region', 'connected_endpoint__device' ).filter( circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z @@ -176,33 +156,26 @@ class CircuitView(PermissionRequiredMixin, View): }) -class CircuitCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'circuits.add_circuit' +class CircuitEditView(ObjectEditView): queryset = Circuit.objects.all() model_form = forms.CircuitForm template_name = 'circuits/circuit_edit.html' default_return_url = 'circuits:circuit_list' -class CircuitEditView(CircuitCreateView): - permission_required = 'circuits.change_circuit' - - -class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'circuits.delete_circuit' +class CircuitDeleteView(ObjectDeleteView): queryset = Circuit.objects.all() default_return_url = 'circuits:circuit_list' -class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'circuits.add_circuit' +class CircuitBulkImportView(BulkImportView): + queryset = Circuit.objects.all() model_form = forms.CircuitCSVForm table = tables.CircuitTable default_return_url = 'circuits:circuit_list' -class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'circuits.change_circuit' +class CircuitBulkEditView(BulkEditView): queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site') filterset = filters.CircuitFilterSet table = tables.CircuitTable @@ -210,33 +183,54 @@ class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView): default_return_url = 'circuits:circuit_list' -class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'circuits.delete_circuit' +class CircuitBulkDeleteView(BulkDeleteView): queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site') filterset = filters.CircuitFilterSet table = tables.CircuitTable default_return_url = 'circuits:circuit_list' -@permission_required('circuits.change_circuittermination') -def circuit_terminations_swap(request, pk): +class CircuitSwapTerminations(ObjectEditView): + """ + Swap the A and Z terminations of a circuit. + """ + queryset = Circuit.objects.all() - circuit = get_object_or_404(Circuit, pk=pk) - termination_a = CircuitTermination.objects.filter( - circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A - ).first() - termination_z = CircuitTermination.objects.filter( - circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z - ).first() - if not termination_a and not termination_z: - messages.error(request, "No terminations have been defined for circuit {}.".format(circuit)) - return redirect('circuits:circuit', pk=circuit.pk) + def get(self, request, pk): + circuit = get_object_or_404(self.queryset, pk=pk) + form = ConfirmationForm() - if request.method == 'POST': + # Circuit must have at least one termination to swap + if not circuit.termination_a and not circuit.termination_z: + messages.error(request, "No terminations have been defined for circuit {}.".format(circuit)) + return redirect('circuits:circuit', pk=circuit.pk) + + return render(request, 'circuits/circuit_terminations_swap.html', { + 'circuit': circuit, + 'termination_a': circuit.termination_a, + 'termination_z': circuit.termination_z, + 'form': form, + 'panel_class': 'default', + 'button_class': 'primary', + 'return_url': circuit.get_absolute_url(), + }) + + def post(self, request, pk): + circuit = get_object_or_404(self.queryset, pk=pk) form = ConfirmationForm(request.POST) + if form.is_valid(): + + termination_a = CircuitTermination.objects.filter( + circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A + ).first() + termination_z = CircuitTermination.objects.filter( + circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z + ).first() + if termination_a and termination_z: # Use a placeholder to avoid an IntegrityError on the (circuit, term_side) unique constraint + print('swapping') with transaction.atomic(): termination_a.term_side = '_' termination_a.save() @@ -250,29 +244,26 @@ def circuit_terminations_swap(request, pk): else: termination_z.term_side = 'A' termination_z.save() + messages.success(request, "Swapped terminations for circuit {}.".format(circuit)) return redirect('circuits:circuit', pk=circuit.pk) - else: - form = ConfirmationForm() - - return render(request, 'circuits/circuit_terminations_swap.html', { - 'circuit': circuit, - 'termination_a': termination_a, - 'termination_z': termination_z, - 'form': form, - 'panel_class': 'default', - 'button_class': 'primary', - 'return_url': circuit.get_absolute_url(), - }) + return render(request, 'circuits/circuit_terminations_swap.html', { + 'circuit': circuit, + 'termination_a': circuit.termination_a, + 'termination_z': circuit.termination_z, + 'form': form, + 'panel_class': 'default', + 'button_class': 'primary', + 'return_url': circuit.get_absolute_url(), + }) # # Circuit terminations # -class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'circuits.add_circuittermination' +class CircuitTerminationEditView(ObjectEditView): queryset = CircuitTermination.objects.all() model_form = forms.CircuitTerminationForm template_name = 'circuits/circuittermination_edit.html' @@ -286,10 +277,5 @@ class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView): return obj.circuit.get_absolute_url() -class CircuitTerminationEditView(CircuitTerminationCreateView): - permission_required = 'circuits.change_circuittermination' - - -class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'circuits.delete_circuittermination' +class CircuitTerminationDeleteView(ObjectDeleteView): queryset = CircuitTermination.objects.all() diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 9c8fe12de..3abfddbc2 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -395,7 +395,7 @@ class DeviceViewSet(CustomFieldModelViewSet): )) # Verify user permission - if not request.user.has_perm('dcim.napalm_read'): + if not request.user.has_perm('dcim.napalm_read_device'): return HttpResponseForbidden() # Connect to the device diff --git a/netbox/dcim/migrations/0041_napalm_integration.py b/netbox/dcim/migrations/0041_napalm_integration.py index 50c2fbd99..3acad9f0b 100644 --- a/netbox/dcim/migrations/0041_napalm_integration.py +++ b/netbox/dcim/migrations/0041_napalm_integration.py @@ -22,7 +22,7 @@ class Migration(migrations.Migration): operations = [ migrations.AlterModelOptions( name='device', - options={'ordering': ['name'], 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))}, + options={'ordering': ['name']}, ), migrations.AddField( model_name='platform', diff --git a/netbox/dcim/migrations/0089_deterministic_ordering.py b/netbox/dcim/migrations/0089_deterministic_ordering.py index 6944cff00..77d18739e 100644 --- a/netbox/dcim/migrations/0089_deterministic_ordering.py +++ b/netbox/dcim/migrations/0089_deterministic_ordering.py @@ -12,7 +12,7 @@ class Migration(migrations.Migration): operations = [ migrations.AlterModelOptions( name='device', - options={'ordering': ('name', 'pk'), 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))}, + options={'ordering': ('name', 'pk')}, ), migrations.AlterModelOptions( name='rack', diff --git a/netbox/dcim/migrations/0095_primary_model_ordering.py b/netbox/dcim/migrations/0095_primary_model_ordering.py index 3bc780161..6225a9b73 100644 --- a/netbox/dcim/migrations/0095_primary_model_ordering.py +++ b/netbox/dcim/migrations/0095_primary_model_ordering.py @@ -30,7 +30,7 @@ class Migration(migrations.Migration): operations = [ migrations.AlterModelOptions( name='device', - options={'ordering': ('_name', 'pk'), 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))}, + options={'ordering': ('_name', 'pk')}, ), migrations.AlterModelOptions( name='rack', diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 1f6478119..4d18509a9 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -25,7 +25,9 @@ from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, Ta from extras.utils import extras_features from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField +from utilities.querysets import RestrictedQuerySet from utilities.models import ChangeLoggedModel +from utilities.mptt import TreeManager from utilities.utils import serialize_object, to_meters from utilities.validators import ExclusionValidator from .device_component_templates import ( @@ -103,6 +105,8 @@ class Region(MPTTModel, ChangeLoggedModel): blank=True ) + objects = TreeManager() + csv_headers = ['name', 'slug', 'parent', 'description'] class MPTTMeta: @@ -244,6 +248,8 @@ class Site(ChangeLoggedModel, CustomFieldModel): ) tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = [ 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments', @@ -326,6 +332,8 @@ class RackGroup(MPTTModel, ChangeLoggedModel): blank=True ) + objects = TreeManager() + csv_headers = ['site', 'parent', 'name', 'slug', 'description'] class Meta: @@ -388,6 +396,8 @@ class RackRole(ChangeLoggedModel): blank=True, ) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'color', 'description'] class Meta: @@ -526,6 +536,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel): ) tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = [ 'site', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments', @@ -821,6 +833,8 @@ class RackReservation(ChangeLoggedModel): max_length=200 ) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['site', 'rack_group', 'rack', 'units', 'tenant', 'user', 'description'] class Meta: @@ -900,6 +914,8 @@ class Manufacturer(ChangeLoggedModel): blank=True ) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'description'] class Meta: @@ -982,9 +998,10 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): content_type_field='obj_type', object_id_field='obj_id' ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + clone_fields = [ 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', ] @@ -1206,6 +1223,8 @@ class DeviceRole(ChangeLoggedModel): blank=True, ) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'color', 'vm_role', 'description'] class Meta: @@ -1263,6 +1282,8 @@ class Platform(ChangeLoggedModel): blank=True ) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description'] class Meta: @@ -1429,6 +1450,8 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): ) tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = [ 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', 'site', 'rack_group', 'rack_name', 'position', 'face', 'comments', @@ -1454,10 +1477,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): ('rack', 'position', 'face'), ('virtual_chassis', 'vc_position'), ) - permissions = ( - ('napalm_read', 'Read-only access to devices via NAPALM'), - ('napalm_write', 'Read/write access to devices via NAPALM'), - ) def __str__(self): return self.display_name or super().__str__() @@ -1741,9 +1760,10 @@ class VirtualChassis(ChangeLoggedModel): max_length=30, blank=True ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['master', 'domain'] class Meta: @@ -1813,6 +1833,8 @@ class PowerPanel(ChangeLoggedModel): max_length=50 ) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['site', 'rack_group', 'name'] class Meta: @@ -1916,9 +1938,10 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel): content_type_field='obj_type', object_id_field='obj_id' ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = [ 'site', 'power_panel', 'rack_group', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'comments', @@ -2084,6 +2107,8 @@ class Cable(ChangeLoggedModel): null=True ) + objects = RestrictedQuerySet.as_manager() + csv_headers = [ 'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 164d37d77..e412a602e 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -6,6 +6,7 @@ from dcim.choices import * from dcim.constants import * from extras.models import ObjectChange from utilities.fields import NaturalOrderingField +from utilities.querysets import RestrictedQuerySet from utilities.ordering import naturalize_interface from utilities.utils import serialize_object from .device_components import ( @@ -26,6 +27,7 @@ __all__ = ( class ComponentTemplateModel(models.Model): + objects = RestrictedQuerySet.as_manager() class Meta: abstract = True diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 4005d41a4..702455c7e 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -16,6 +16,7 @@ from extras.models import ObjectChange, TaggedItem from extras.utils import extras_features from utilities.fields import NaturalOrderingField from utilities.ordering import naturalize_interface +from utilities.querysets import RestrictedQuerySet from utilities.query_functions import CollateAsChar from utilities.utils import serialize_object from virtualization.choices import VMInterfaceTypeChoices @@ -41,6 +42,8 @@ class ComponentModel(models.Model): blank=True ) + objects = RestrictedQuerySet.as_manager() + class Meta: abstract = True diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 7ee5d7845..cfbb2b95f 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -321,7 +321,16 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase): ) -class DeviceTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase): +# TODO: Change base class to PrimaryObjectViewTestCase +class DeviceTypeTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.CreateObjectViewTestCase, + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase +): model = DeviceType @classmethod @@ -792,12 +801,15 @@ class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase } -class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): +# TODO: Change base class to DeviceComponentTemplateViewTestCase +class DeviceBayTemplateTestCase( + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.BulkCreateObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase +): model = DeviceBayTemplate - # Disable inapplicable views - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -1437,12 +1449,18 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): ) -class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase): +# TODO: Change base class to PrimaryObjectViewTestCase +class CableTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkImportObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase +): model = Cable - # TODO: Creation URL needs termination context - test_create_object = None - @classmethod def setUpTestData(cls): @@ -1511,16 +1529,16 @@ class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase): } -class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase): +# TODO: Change base class to PrimaryObjectViewTestCase +class VirtualChassisTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase +): model = VirtualChassis - # Disable inapplicable tests - test_import_objects = None - - # TODO: Requires special form handling - test_create_object = None - test_edit_object = None - @classmethod def setUpTestData(cls): diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 0b1f6250e..a0d6bdc92 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -1,7 +1,7 @@ from django.urls import path from extras.views import ObjectChangeLogView, ImageAttachmentEditView -from ipam.views import ServiceCreateView +from ipam.views import ServiceEditView from . import views from .models import ( Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform, @@ -14,7 +14,7 @@ urlpatterns = [ # Regions path('regions/', views.RegionListView.as_view(), name='region_list'), - path('regions/add/', views.RegionCreateView.as_view(), name='region_add'), + path('regions/add/', views.RegionEditView.as_view(), name='region_add'), path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'), path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), path('regions//edit/', views.RegionEditView.as_view(), name='region_edit'), @@ -22,7 +22,7 @@ urlpatterns = [ # Sites path('sites/', views.SiteListView.as_view(), name='site_list'), - path('sites/add/', views.SiteCreateView.as_view(), name='site_add'), + path('sites/add/', views.SiteEditView.as_view(), name='site_add'), path('sites/import/', views.SiteBulkImportView.as_view(), name='site_import'), path('sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'), path('sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'), @@ -34,7 +34,7 @@ urlpatterns = [ # Rack groups path('rack-groups/', views.RackGroupListView.as_view(), name='rackgroup_list'), - path('rack-groups/add/', views.RackGroupCreateView.as_view(), name='rackgroup_add'), + path('rack-groups/add/', views.RackGroupEditView.as_view(), name='rackgroup_add'), path('rack-groups/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'), path('rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'), path('rack-groups//edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'), @@ -42,7 +42,7 @@ urlpatterns = [ # Rack roles path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'), - path('rack-roles/add/', views.RackRoleCreateView.as_view(), name='rackrole_add'), + path('rack-roles/add/', views.RackRoleEditView.as_view(), name='rackrole_add'), path('rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'), path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), path('rack-roles//edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'), @@ -50,7 +50,7 @@ urlpatterns = [ # Rack reservations path('rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'), - path('rack-reservations/add/', views.RackReservationCreateView.as_view(), name='rackreservation_add'), + path('rack-reservations/add/', views.RackReservationEditView.as_view(), name='rackreservation_add'), path('rack-reservations/import/', views.RackReservationImportView.as_view(), name='rackreservation_import'), path('rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'), path('rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'), @@ -62,7 +62,7 @@ urlpatterns = [ # Racks path('racks/', views.RackListView.as_view(), name='rack_list'), path('rack-elevations/', views.RackElevationListView.as_view(), name='rack_elevation_list'), - path('racks/add/', views.RackCreateView.as_view(), name='rack_add'), + path('racks/add/', views.RackEditView.as_view(), name='rack_add'), path('racks/import/', views.RackBulkImportView.as_view(), name='rack_import'), path('racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'), path('racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'), @@ -74,7 +74,7 @@ urlpatterns = [ # Manufacturers path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'), - path('manufacturers/add/', views.ManufacturerCreateView.as_view(), name='manufacturer_add'), + path('manufacturers/add/', views.ManufacturerEditView.as_view(), name='manufacturer_add'), path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'), path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'), path('manufacturers//edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), @@ -82,7 +82,7 @@ urlpatterns = [ # Device types path('device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'), - path('device-types/add/', views.DeviceTypeCreateView.as_view(), name='devicetype_add'), + path('device-types/add/', views.DeviceTypeEditView.as_view(), name='devicetype_add'), path('device-types/import/', views.DeviceTypeImportView.as_view(), name='devicetype_import'), path('device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'), path('device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'), @@ -149,7 +149,7 @@ urlpatterns = [ # Device roles path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'), - path('device-roles/add/', views.DeviceRoleCreateView.as_view(), name='devicerole_add'), + path('device-roles/add/', views.DeviceRoleEditView.as_view(), name='devicerole_add'), path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'), path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'), path('device-roles//edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), @@ -157,7 +157,7 @@ urlpatterns = [ # Platforms path('platforms/', views.PlatformListView.as_view(), name='platform_list'), - path('platforms/add/', views.PlatformCreateView.as_view(), name='platform_add'), + path('platforms/add/', views.PlatformEditView.as_view(), name='platform_add'), path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'), path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'), path('platforms//edit/', views.PlatformEditView.as_view(), name='platform_edit'), @@ -165,7 +165,7 @@ urlpatterns = [ # Devices path('devices/', views.DeviceListView.as_view(), name='device_list'), - path('devices/add/', views.DeviceCreateView.as_view(), name='device_add'), + path('devices/add/', views.DeviceEditView.as_view(), name='device_add'), path('devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'), path('devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'), path('devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'), @@ -179,7 +179,7 @@ urlpatterns = [ path('devices//status/', views.DeviceStatusView.as_view(), name='device_status'), path('devices//lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), path('devices//config/', views.DeviceConfigView.as_view(), name='device_config'), - path('devices//services/assign/', ServiceCreateView.as_view(), name='device_service_assign'), + path('devices//services/assign/', ServiceEditView.as_view(), name='device_service_assign'), path('devices//images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}), # Console ports @@ -332,7 +332,7 @@ urlpatterns = [ # Power panels path('power-panels/', views.PowerPanelListView.as_view(), name='powerpanel_list'), - path('power-panels/add/', views.PowerPanelCreateView.as_view(), name='powerpanel_add'), + path('power-panels/add/', views.PowerPanelEditView.as_view(), name='powerpanel_add'), path('power-panels/import/', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'), path('power-panels/edit/', views.PowerPanelBulkEditView.as_view(), name='powerpanel_bulk_edit'), path('power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'), @@ -343,7 +343,7 @@ urlpatterns = [ # Power feeds path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'), - path('power-feeds/add/', views.PowerFeedCreateView.as_view(), name='powerfeed_add'), + path('power-feeds/add/', views.PowerFeedEditView.as_view(), name='powerfeed_add'), path('power-feeds/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'), path('power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'), path('power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 2df57f440..de2bf80e5 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -3,7 +3,6 @@ import re from django.conf import settings from django.contrib import messages -from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.contenttypes.models import ContentType from django.core.paginator import EmptyPage, PageNotAnInteger from django.db import transaction @@ -12,21 +11,22 @@ from django.forms import modelformset_factory from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.html import escape -from django.utils.http import is_safe_url from django.utils.safestring import mark_safe from django.views.generic import View from circuits.models import Circuit from extras.models import Graph from extras.views import ObjectConfigContextView -from ipam.models import Prefix, VLAN +from ipam.models import Prefix, Service, VLAN from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable +from secrets.models import Secret from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator +from utilities.permissions import get_permission_for_model from utilities.utils import csv_format from utilities.views import ( BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin, - ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView, + ObjectView, ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView, ObjectPermissionRequiredMixin, ) from virtualization.models import VirtualMachine from . import filters, forms, tables @@ -41,7 +41,7 @@ from .models import ( ) -class BulkRenameView(GetReturnURLMixin, View): +class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ An extendable view for renaming device components in bulk. """ @@ -49,9 +49,10 @@ class BulkRenameView(GetReturnURLMixin, View): form = None template_name = 'dcim/bulk_rename.html' - def post(self, request): + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'change') - model = self.queryset.model + def post(self, request): if '_preview' in request.POST or '_apply' in request.POST: form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')}) @@ -76,7 +77,7 @@ class BulkRenameView(GetReturnURLMixin, View): obj.save() messages.success(request, "Renamed {} {}".format( len(selected_objects), - model._meta.verbose_name_plural + self.queryset.model._meta.verbose_name_plural )) return redirect(self.get_return_url(request)) @@ -86,20 +87,23 @@ class BulkRenameView(GetReturnURLMixin, View): return render(request, self.template_name, { 'form': form, - 'obj_type_plural': model._meta.verbose_name_plural, + 'obj_type_plural': self.queryset.model._meta.verbose_name_plural, 'selected_objects': selected_objects, 'return_url': self.get_return_url(request), }) -class BulkDisconnectView(GetReturnURLMixin, View): +class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ An extendable view for disconnection console/power/interface components in bulk. """ - model = None + queryset = None form = None template_name = 'dcim/bulk_disconnect.html' + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'change') + def post(self, request): selected_objects = [] @@ -113,25 +117,25 @@ class BulkDisconnectView(GetReturnURLMixin, View): with transaction.atomic(): count = 0 - for obj in self.model.objects.filter(pk__in=form.cleaned_data['pk']): + for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']): if obj.cable is None: continue obj.cable.delete() count += 1 messages.success(request, "Disconnected {} {}".format( - count, self.model._meta.verbose_name_plural + count, self.queryset.model._meta.verbose_name_plural )) return redirect(return_url) else: form = self.form(initial={'pk': request.POST.getlist('pk')}) - selected_objects = self.model.objects.filter(pk__in=form.initial['pk']) + selected_objects = self.queryset.filter(pk__in=form.initial['pk']) return render(request, self.template_name, { 'form': form, - 'obj_type_plural': self.model._meta.verbose_name_plural, + 'obj_type_plural': self.queryset.model._meta.verbose_name_plural, 'selected_objects': selected_objects, 'return_url': return_url, }) @@ -141,8 +145,7 @@ class BulkDisconnectView(GetReturnURLMixin, View): # Regions # -class RegionListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_region' +class RegionListView(ObjectListView): queryset = Region.objects.add_related_count( Region.objects.all(), Site, @@ -155,26 +158,20 @@ class RegionListView(PermissionRequiredMixin, ObjectListView): table = tables.RegionTable -class RegionCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.add_region' +class RegionEditView(ObjectEditView): queryset = Region.objects.all() model_form = forms.RegionForm default_return_url = 'dcim:region_list' -class RegionEditView(RegionCreateView): - permission_required = 'dcim.change_region' - - -class RegionBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_region' +class RegionBulkImportView(BulkImportView): + queryset = Region.objects.all() model_form = forms.RegionCSVForm table = tables.RegionTable default_return_url = 'dcim:region_list' -class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_region' +class RegionBulkDeleteView(BulkDeleteView): queryset = Region.objects.all() filterset = filters.RegionFilterSet table = tables.RegionTable @@ -185,29 +182,30 @@ class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Sites # -class SiteListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_site' +class SiteListView(ObjectListView): queryset = Site.objects.prefetch_related('region', 'tenant') filterset = filters.SiteFilterSet filterset_form = forms.SiteFilterForm table = tables.SiteTable -class SiteView(PermissionRequiredMixin, View): - permission_required = 'dcim.view_site' +class SiteView(ObjectView): + queryset = Site.objects.prefetch_related('region', 'tenant__group') def get(self, request, slug): - site = get_object_or_404(Site.objects.prefetch_related('region', 'tenant__group'), slug=slug) + site = get_object_or_404(self.queryset, slug=slug) stats = { - 'rack_count': Rack.objects.filter(site=site).count(), - 'device_count': Device.objects.filter(site=site).count(), - 'prefix_count': Prefix.objects.filter(site=site).count(), - 'vlan_count': VLAN.objects.filter(site=site).count(), - 'circuit_count': Circuit.objects.filter(terminations__site=site).count(), - 'vm_count': VirtualMachine.objects.filter(cluster__site=site).count(), + 'rack_count': Rack.objects.restrict(request.user, 'view').filter(site=site).count(), + 'device_count': Device.objects.restrict(request.user, 'view').filter(site=site).count(), + 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(site=site).count(), + 'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(site=site).count(), + 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=site).count(), + 'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=site).count(), } - rack_groups = RackGroup.objects.filter(site=site).annotate(rack_count=Count('racks')) + rack_groups = RackGroup.objects.restrict(request.user, 'view').filter(site=site).annotate( + rack_count=Count('racks') + ) show_graphs = Graph.objects.filter(type__model='site').exists() return render(request, 'dcim/site.html', { @@ -218,33 +216,26 @@ class SiteView(PermissionRequiredMixin, View): }) -class SiteCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.add_site' +class SiteEditView(ObjectEditView): queryset = Site.objects.all() model_form = forms.SiteForm template_name = 'dcim/site_edit.html' default_return_url = 'dcim:site_list' -class SiteEditView(SiteCreateView): - permission_required = 'dcim.change_site' - - -class SiteDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_site' +class SiteDeleteView(ObjectDeleteView): queryset = Site.objects.all() default_return_url = 'dcim:site_list' -class SiteBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_site' +class SiteBulkImportView(BulkImportView): + queryset = Site.objects.all() model_form = forms.SiteCSVForm table = tables.SiteTable default_return_url = 'dcim:site_list' -class SiteBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_site' +class SiteBulkEditView(BulkEditView): queryset = Site.objects.prefetch_related('region', 'tenant') filterset = filters.SiteFilterSet table = tables.SiteTable @@ -252,8 +243,7 @@ class SiteBulkEditView(PermissionRequiredMixin, BulkEditView): default_return_url = 'dcim:site_list' -class SiteBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_site' +class SiteBulkDeleteView(BulkDeleteView): queryset = Site.objects.prefetch_related('region', 'tenant') filterset = filters.SiteFilterSet table = tables.SiteTable @@ -264,8 +254,7 @@ class SiteBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Rack groups # -class RackGroupListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_rackgroup' +class RackGroupListView(ObjectListView): queryset = RackGroup.objects.add_related_count( RackGroup.objects.all(), Rack, @@ -278,26 +267,20 @@ class RackGroupListView(PermissionRequiredMixin, ObjectListView): table = tables.RackGroupTable -class RackGroupCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.add_rackgroup' +class RackGroupEditView(ObjectEditView): queryset = RackGroup.objects.all() model_form = forms.RackGroupForm default_return_url = 'dcim:rackgroup_list' -class RackGroupEditView(RackGroupCreateView): - permission_required = 'dcim.change_rackgroup' - - -class RackGroupBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_rackgroup' +class RackGroupBulkImportView(BulkImportView): + queryset = RackGroup.objects.all() model_form = forms.RackGroupCSVForm table = tables.RackGroupTable default_return_url = 'dcim:rackgroup_list' -class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_rackgroup' +class RackGroupBulkDeleteView(BulkDeleteView): queryset = RackGroup.objects.prefetch_related('site').annotate(rack_count=Count('racks')) filterset = filters.RackGroupFilterSet table = tables.RackGroupTable @@ -308,32 +291,25 @@ class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Rack roles # -class RackRoleListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_rackrole' +class RackRoleListView(ObjectListView): queryset = RackRole.objects.annotate(rack_count=Count('racks')) table = tables.RackRoleTable -class RackRoleCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.add_rackrole' +class RackRoleEditView(ObjectEditView): queryset = RackRole.objects.all() model_form = forms.RackRoleForm default_return_url = 'dcim:rackrole_list' -class RackRoleEditView(RackRoleCreateView): - permission_required = 'dcim.change_rackrole' - - -class RackRoleBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_rackrole' +class RackRoleBulkImportView(BulkImportView): + queryset = RackRole.objects.all() model_form = forms.RackRoleCSVForm table = tables.RackRoleTable default_return_url = 'dcim:rackrole_list' -class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_rackrole' +class RackRoleBulkDeleteView(BulkDeleteView): queryset = RackRole.objects.annotate(rack_count=Count('racks')) table = tables.RackRoleTable default_return_url = 'dcim:rackrole_list' @@ -343,8 +319,7 @@ class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Racks # -class RackListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_rack' +class RackListView(ObjectListView): queryset = Rack.objects.prefetch_related( 'site', 'group', 'tenant', 'role', 'devices__device_type' ).annotate( @@ -355,16 +330,15 @@ class RackListView(PermissionRequiredMixin, ObjectListView): table = tables.RackDetailTable -class RackElevationListView(PermissionRequiredMixin, View): +class RackElevationListView(ObjectListView): """ Display a set of rack elevations side-by-side. """ - permission_required = 'dcim.view_rack' + queryset = Rack.objects.prefetch_related('role') def get(self, request): - racks = Rack.objects.prefetch_related('role') - racks = filters.RackFilterSet(request.GET, racks).qs + racks = filters.RackFilterSet(request.GET, self.queryset).qs total_count = racks.count() # Pagination @@ -392,14 +366,14 @@ class RackElevationListView(PermissionRequiredMixin, View): }) -class RackView(PermissionRequiredMixin, View): - permission_required = 'dcim.view_rack' +class RackView(ObjectView): + queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role') def get(self, request, pk): - rack = get_object_or_404(Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role'), pk=pk) + rack = get_object_or_404(self.queryset, pk=pk) - nonracked_devices = Device.objects.filter( + nonracked_devices = Device.objects.restrict(request.user, 'view').filter( rack=rack, position__isnull=True, parent_bay__isnull=True @@ -411,8 +385,8 @@ class RackView(PermissionRequiredMixin, View): next_rack = peer_racks.filter(name__gt=rack.name).order_by('name').first() prev_rack = peer_racks.filter(name__lt=rack.name).order_by('-name').first() - reservations = RackReservation.objects.filter(rack=rack) - power_feeds = PowerFeed.objects.filter(rack=rack).prefetch_related('power_panel') + reservations = RackReservation.objects.restrict(request.user, 'view').filter(rack=rack) + power_feeds = PowerFeed.objects.restrict(request.user, 'view').filter(rack=rack).prefetch_related('power_panel') return render(request, 'dcim/rack.html', { 'rack': rack, @@ -424,33 +398,26 @@ class RackView(PermissionRequiredMixin, View): }) -class RackCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.add_rack' +class RackEditView(ObjectEditView): queryset = Rack.objects.all() model_form = forms.RackForm template_name = 'dcim/rack_edit.html' default_return_url = 'dcim:rack_list' -class RackEditView(RackCreateView): - permission_required = 'dcim.change_rack' - - -class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_rack' +class RackDeleteView(ObjectDeleteView): queryset = Rack.objects.all() default_return_url = 'dcim:rack_list' -class RackBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_rack' +class RackBulkImportView(BulkImportView): + queryset = Rack.objects.all() model_form = forms.RackCSVForm table = tables.RackTable default_return_url = 'dcim:rack_list' -class RackBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_rack' +class RackBulkEditView(BulkEditView): queryset = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role') filterset = filters.RackFilterSet table = tables.RackTable @@ -458,8 +425,7 @@ class RackBulkEditView(PermissionRequiredMixin, BulkEditView): default_return_url = 'dcim:rack_list' -class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_rack' +class RackBulkDeleteView(BulkDeleteView): queryset = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role') filterset = filters.RackFilterSet table = tables.RackTable @@ -470,8 +436,7 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Rack reservations # -class RackReservationListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_rackreservation' +class RackReservationListView(ObjectListView): queryset = RackReservation.objects.prefetch_related('rack__site') filterset = filters.RackReservationFilterSet filterset_form = forms.RackReservationFilterForm @@ -479,20 +444,19 @@ class RackReservationListView(PermissionRequiredMixin, ObjectListView): action_buttons = ('export',) -class RackReservationView(PermissionRequiredMixin, View): - permission_required = 'dcim.view_rackreservation' +class RackReservationView(ObjectView): + queryset = RackReservation.objects.prefetch_related('rack') def get(self, request, pk): - rackreservation = get_object_or_404(RackReservation.objects.prefetch_related('rack'), pk=pk) + rackreservation = get_object_or_404(self.queryset, pk=pk) return render(request, 'dcim/rackreservation.html', { 'rackreservation': rackreservation, }) -class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.add_rackreservation' +class RackReservationEditView(ObjectEditView): queryset = RackReservation.objects.all() model_form = forms.RackReservationForm template_name = 'dcim/rackreservation_edit.html' @@ -506,18 +470,13 @@ class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView): return obj -class RackReservationEditView(RackReservationCreateView): - permission_required = 'dcim.change_rackreservation' - - -class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_rackreservation' +class RackReservationDeleteView(ObjectDeleteView): queryset = RackReservation.objects.all() default_return_url = 'dcim:rackreservation_list' -class RackReservationImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_rackreservation' +class RackReservationImportView(BulkImportView): + queryset = RackReservation.objects.all() model_form = forms.RackReservationCSVForm table = tables.RackReservationTable default_return_url = 'dcim:rackreservation_list' @@ -533,8 +492,7 @@ class RackReservationImportView(PermissionRequiredMixin, BulkImportView): return instance -class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_rackreservation' +class RackReservationBulkEditView(BulkEditView): queryset = RackReservation.objects.prefetch_related('rack', 'user') filterset = filters.RackReservationFilterSet table = tables.RackReservationTable @@ -542,8 +500,7 @@ class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView): default_return_url = 'dcim:rackreservation_list' -class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_rackreservation' +class RackReservationBulkDeleteView(BulkDeleteView): queryset = RackReservation.objects.prefetch_related('rack', 'user') filterset = filters.RackReservationFilterSet table = tables.RackReservationTable @@ -554,8 +511,7 @@ class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Manufacturers # -class ManufacturerListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_manufacturer' +class ManufacturerListView(ObjectListView): queryset = Manufacturer.objects.annotate( devicetype_count=Count('device_types', distinct=True), inventoryitem_count=Count('inventory_items', distinct=True), @@ -564,26 +520,20 @@ class ManufacturerListView(PermissionRequiredMixin, ObjectListView): table = tables.ManufacturerTable -class ManufacturerCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.add_manufacturer' +class ManufacturerEditView(ObjectEditView): queryset = Manufacturer.objects.all() model_form = forms.ManufacturerForm default_return_url = 'dcim:manufacturer_list' -class ManufacturerEditView(ManufacturerCreateView): - permission_required = 'dcim.change_manufacturer' - - -class ManufacturerBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_manufacturer' +class ManufacturerBulkImportView(BulkImportView): + queryset = Manufacturer.objects.all() model_form = forms.ManufacturerCSVForm table = tables.ManufacturerTable default_return_url = 'dcim:manufacturer_list' -class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_manufacturer' +class ManufacturerBulkDeleteView(BulkDeleteView): queryset = Manufacturer.objects.annotate(devicetype_count=Count('device_types')) table = tables.ManufacturerTable default_return_url = 'dcim:manufacturer_list' @@ -593,52 +543,51 @@ class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Device types # -class DeviceTypeListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_devicetype' +class DeviceTypeListView(ObjectListView): queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances')) filterset = filters.DeviceTypeFilterSet filterset_form = forms.DeviceTypeFilterForm table = tables.DeviceTypeTable -class DeviceTypeView(PermissionRequiredMixin, View): - permission_required = 'dcim.view_devicetype' +class DeviceTypeView(ObjectView): + queryset = DeviceType.objects.prefetch_related('manufacturer') def get(self, request, pk): - devicetype = get_object_or_404(DeviceType, pk=pk) + devicetype = get_object_or_404(self.queryset, pk=pk) # Component tables consoleport_table = tables.ConsolePortTemplateTable( - ConsolePortTemplate.objects.filter(device_type=devicetype), + ConsolePortTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype), orderable=False ) consoleserverport_table = tables.ConsoleServerPortTemplateTable( - ConsoleServerPortTemplate.objects.filter(device_type=devicetype), + ConsoleServerPortTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype), orderable=False ) powerport_table = tables.PowerPortTemplateTable( - PowerPortTemplate.objects.filter(device_type=devicetype), + PowerPortTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype), orderable=False ) poweroutlet_table = tables.PowerOutletTemplateTable( - PowerOutletTemplate.objects.filter(device_type=devicetype), + PowerOutletTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype), orderable=False ) interface_table = tables.InterfaceTemplateTable( - list(InterfaceTemplate.objects.filter(device_type=devicetype)), + list(InterfaceTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype)), orderable=False ) front_port_table = tables.FrontPortTemplateTable( - FrontPortTemplate.objects.filter(device_type=devicetype), + FrontPortTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype), orderable=False ) rear_port_table = tables.RearPortTemplateTable( - RearPortTemplate.objects.filter(device_type=devicetype), + RearPortTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype), orderable=False ) devicebay_table = tables.DeviceBayTemplateTable( - DeviceBayTemplate.objects.filter(device_type=devicetype), + DeviceBayTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype), orderable=False ) if request.user.has_perm('dcim.change_devicetype'): @@ -664,26 +613,20 @@ class DeviceTypeView(PermissionRequiredMixin, View): }) -class DeviceTypeCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.add_devicetype' +class DeviceTypeEditView(ObjectEditView): queryset = DeviceType.objects.all() model_form = forms.DeviceTypeForm template_name = 'dcim/devicetype_edit.html' default_return_url = 'dcim:devicetype_list' -class DeviceTypeEditView(DeviceTypeCreateView): - permission_required = 'dcim.change_devicetype' - - -class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_devicetype' +class DeviceTypeDeleteView(ObjectDeleteView): queryset = DeviceType.objects.all() default_return_url = 'dcim:devicetype_list' -class DeviceTypeImportView(PermissionRequiredMixin, ObjectImportView): - permission_required = [ +class DeviceTypeImportView(ObjectImportView): + additional_permissions = [ 'dcim.add_devicetype', 'dcim.add_consoleporttemplate', 'dcim.add_consoleserverporttemplate', @@ -694,7 +637,7 @@ class DeviceTypeImportView(PermissionRequiredMixin, ObjectImportView): 'dcim.add_rearporttemplate', 'dcim.add_devicebaytemplate', ] - model = DeviceType + queryset = DeviceType.objects.all() model_form = forms.DeviceTypeImportForm related_object_forms = OrderedDict(( ('console-ports', forms.ConsolePortTemplateImportForm), @@ -709,8 +652,7 @@ class DeviceTypeImportView(PermissionRequiredMixin, ObjectImportView): default_return_url = 'dcim:devicetype_import' -class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_devicetype' +class DeviceTypeBulkEditView(BulkEditView): queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances')) filterset = filters.DeviceTypeFilterSet table = tables.DeviceTypeTable @@ -718,8 +660,7 @@ class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView): default_return_url = 'dcim:devicetype_list' -class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_devicetype' +class DeviceTypeBulkDeleteView(BulkDeleteView): queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances')) filterset = filters.DeviceTypeFilterSet table = tables.DeviceTypeTable @@ -730,34 +671,29 @@ class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Console port templates # -class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_consoleporttemplate' - model = ConsolePortTemplate +class ConsolePortTemplateCreateView(ComponentCreateView): + queryset = ConsolePortTemplate.objects.all() form = forms.ConsolePortTemplateCreateForm model_form = forms.ConsolePortTemplateForm template_name = 'dcim/device_component_add.html' -class ConsolePortTemplateEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_consoleporttemplate' +class ConsolePortTemplateEditView(ObjectEditView): queryset = ConsolePortTemplate.objects.all() model_form = forms.ConsolePortTemplateForm -class ConsolePortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_consoleporttemplate' +class ConsolePortTemplateDeleteView(ObjectDeleteView): queryset = ConsolePortTemplate.objects.all() -class ConsolePortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_consoleporttemplate' +class ConsolePortTemplateBulkEditView(BulkEditView): queryset = ConsolePortTemplate.objects.all() table = tables.ConsolePortTemplateTable form = forms.ConsolePortTemplateBulkEditForm -class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_consoleporttemplate' +class ConsolePortTemplateBulkDeleteView(BulkDeleteView): queryset = ConsolePortTemplate.objects.all() table = tables.ConsolePortTemplateTable @@ -766,34 +702,29 @@ class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView) # Console server port templates # -class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_consoleserverporttemplate' - model = ConsoleServerPortTemplate +class ConsoleServerPortTemplateCreateView(ComponentCreateView): + queryset = ConsoleServerPortTemplate.objects.all() form = forms.ConsoleServerPortTemplateCreateForm model_form = forms.ConsoleServerPortTemplateForm template_name = 'dcim/device_component_add.html' -class ConsoleServerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_consoleserverporttemplate' +class ConsoleServerPortTemplateEditView(ObjectEditView): queryset = ConsoleServerPortTemplate.objects.all() model_form = forms.ConsoleServerPortTemplateForm -class ConsoleServerPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_consoleserverporttemplate' +class ConsoleServerPortTemplateDeleteView(ObjectDeleteView): queryset = ConsoleServerPortTemplate.objects.all() -class ConsoleServerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_consoleserverporttemplate' +class ConsoleServerPortTemplateBulkEditView(BulkEditView): queryset = ConsoleServerPortTemplate.objects.all() table = tables.ConsoleServerPortTemplateTable form = forms.ConsoleServerPortTemplateBulkEditForm -class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_consoleserverporttemplate' +class ConsoleServerPortTemplateBulkDeleteView(BulkDeleteView): queryset = ConsoleServerPortTemplate.objects.all() table = tables.ConsoleServerPortTemplateTable @@ -802,34 +733,29 @@ class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDelet # Power port templates # -class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_powerporttemplate' - model = PowerPortTemplate +class PowerPortTemplateCreateView(ComponentCreateView): + queryset = PowerPortTemplate.objects.all() form = forms.PowerPortTemplateCreateForm model_form = forms.PowerPortTemplateForm template_name = 'dcim/device_component_add.html' -class PowerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_powerporttemplate' +class PowerPortTemplateEditView(ObjectEditView): queryset = PowerPortTemplate.objects.all() model_form = forms.PowerPortTemplateForm -class PowerPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_powerporttemplate' +class PowerPortTemplateDeleteView(ObjectDeleteView): queryset = PowerPortTemplate.objects.all() -class PowerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_powerporttemplate' +class PowerPortTemplateBulkEditView(BulkEditView): queryset = PowerPortTemplate.objects.all() table = tables.PowerPortTemplateTable form = forms.PowerPortTemplateBulkEditForm -class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_powerporttemplate' +class PowerPortTemplateBulkDeleteView(BulkDeleteView): queryset = PowerPortTemplate.objects.all() table = tables.PowerPortTemplateTable @@ -838,34 +764,29 @@ class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Power outlet templates # -class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_poweroutlettemplate' - model = PowerOutletTemplate +class PowerOutletTemplateCreateView(ComponentCreateView): + queryset = PowerOutletTemplate.objects.all() form = forms.PowerOutletTemplateCreateForm model_form = forms.PowerOutletTemplateForm template_name = 'dcim/device_component_add.html' -class PowerOutletTemplateEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_poweroutlettemplate' +class PowerOutletTemplateEditView(ObjectEditView): queryset = PowerOutletTemplate.objects.all() model_form = forms.PowerOutletTemplateForm -class PowerOutletTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_poweroutlettemplate' +class PowerOutletTemplateDeleteView(ObjectDeleteView): queryset = PowerOutletTemplate.objects.all() -class PowerOutletTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_poweroutlettemplate' +class PowerOutletTemplateBulkEditView(BulkEditView): queryset = PowerOutletTemplate.objects.all() table = tables.PowerOutletTemplateTable form = forms.PowerOutletTemplateBulkEditForm -class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_poweroutlettemplate' +class PowerOutletTemplateBulkDeleteView(BulkDeleteView): queryset = PowerOutletTemplate.objects.all() table = tables.PowerOutletTemplateTable @@ -874,34 +795,29 @@ class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView) # Interface templates # -class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_interfacetemplate' - model = InterfaceTemplate +class InterfaceTemplateCreateView(ComponentCreateView): + queryset = InterfaceTemplate.objects.all() form = forms.InterfaceTemplateCreateForm model_form = forms.InterfaceTemplateForm template_name = 'dcim/device_component_add.html' -class InterfaceTemplateEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_interfacetemplate' +class InterfaceTemplateEditView(ObjectEditView): queryset = InterfaceTemplate.objects.all() model_form = forms.InterfaceTemplateForm -class InterfaceTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_interfacetemplate' +class InterfaceTemplateDeleteView(ObjectDeleteView): queryset = InterfaceTemplate.objects.all() -class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_interfacetemplate' +class InterfaceTemplateBulkEditView(BulkEditView): queryset = InterfaceTemplate.objects.all() table = tables.InterfaceTemplateTable form = forms.InterfaceTemplateBulkEditForm -class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_interfacetemplate' +class InterfaceTemplateBulkDeleteView(BulkDeleteView): queryset = InterfaceTemplate.objects.all() table = tables.InterfaceTemplateTable @@ -910,34 +826,29 @@ class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Front port templates # -class FrontPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_frontporttemplate' - model = FrontPortTemplate +class FrontPortTemplateCreateView(ComponentCreateView): + queryset = FrontPortTemplate.objects.all() form = forms.FrontPortTemplateCreateForm model_form = forms.FrontPortTemplateForm template_name = 'dcim/device_component_add.html' -class FrontPortTemplateEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_frontporttemplate' +class FrontPortTemplateEditView(ObjectEditView): queryset = FrontPortTemplate.objects.all() model_form = forms.FrontPortTemplateForm -class FrontPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_frontporttemplate' +class FrontPortTemplateDeleteView(ObjectDeleteView): queryset = FrontPortTemplate.objects.all() -class FrontPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_frontporttemplate' +class FrontPortTemplateBulkEditView(BulkEditView): queryset = FrontPortTemplate.objects.all() table = tables.FrontPortTemplateTable form = forms.FrontPortTemplateBulkEditForm -class FrontPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_frontporttemplate' +class FrontPortTemplateBulkDeleteView(BulkDeleteView): queryset = FrontPortTemplate.objects.all() table = tables.FrontPortTemplateTable @@ -946,34 +857,29 @@ class FrontPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Rear port templates # -class RearPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_rearporttemplate' - model = RearPortTemplate +class RearPortTemplateCreateView(ComponentCreateView): + queryset = RearPortTemplate.objects.all() form = forms.RearPortTemplateCreateForm model_form = forms.RearPortTemplateForm template_name = 'dcim/device_component_add.html' -class RearPortTemplateEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_rearporttemplate' +class RearPortTemplateEditView(ObjectEditView): queryset = RearPortTemplate.objects.all() model_form = forms.RearPortTemplateForm -class RearPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_rearporttemplate' +class RearPortTemplateDeleteView(ObjectDeleteView): queryset = RearPortTemplate.objects.all() -class RearPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_rearporttemplate' +class RearPortTemplateBulkEditView(BulkEditView): queryset = RearPortTemplate.objects.all() table = tables.RearPortTemplateTable form = forms.RearPortTemplateBulkEditForm -class RearPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_rearporttemplate' +class RearPortTemplateBulkDeleteView(BulkDeleteView): queryset = RearPortTemplate.objects.all() table = tables.RearPortTemplateTable @@ -982,34 +888,29 @@ class RearPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Device bay templates # -class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_devicebaytemplate' - model = DeviceBayTemplate +class DeviceBayTemplateCreateView(ComponentCreateView): + queryset = DeviceBayTemplate.objects.all() form = forms.DeviceBayTemplateCreateForm model_form = forms.DeviceBayTemplateForm template_name = 'dcim/device_component_add.html' -class DeviceBayTemplateEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_devicebaytemplate' +class DeviceBayTemplateEditView(ObjectEditView): queryset = DeviceBayTemplate.objects.all() model_form = forms.DeviceBayTemplateForm -class DeviceBayTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_devicebaytemplate' +class DeviceBayTemplateDeleteView(ObjectDeleteView): queryset = DeviceBayTemplate.objects.all() -# class DeviceBayTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): -# permission_required = 'dcim.change_devicebaytemplate' +# class DeviceBayTemplateBulkEditView(BulkEditView): # queryset = DeviceBayTemplate.objects.all() # table = tables.DeviceBayTemplateTable # form = forms.DeviceBayTemplateBulkEditForm -class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_devicebaytemplate' +class DeviceBayTemplateBulkDeleteView(BulkDeleteView): queryset = DeviceBayTemplate.objects.all() table = tables.DeviceBayTemplateTable @@ -1018,32 +919,25 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Device roles # -class DeviceRoleListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_devicerole' +class DeviceRoleListView(ObjectListView): queryset = DeviceRole.objects.all() table = tables.DeviceRoleTable -class DeviceRoleCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.add_devicerole' +class DeviceRoleEditView(ObjectEditView): queryset = DeviceRole.objects.all() model_form = forms.DeviceRoleForm default_return_url = 'dcim:devicerole_list' -class DeviceRoleEditView(DeviceRoleCreateView): - permission_required = 'dcim.change_devicerole' - - -class DeviceRoleBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_devicerole' +class DeviceRoleBulkImportView(BulkImportView): + queryset = DeviceRole.objects.all() model_form = forms.DeviceRoleCSVForm table = tables.DeviceRoleTable default_return_url = 'dcim:devicerole_list' -class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_devicerole' +class DeviceRoleBulkDeleteView(BulkDeleteView): queryset = DeviceRole.objects.all() table = tables.DeviceRoleTable default_return_url = 'dcim:devicerole_list' @@ -1053,32 +947,25 @@ class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Platforms # -class PlatformListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_platform' +class PlatformListView(ObjectListView): queryset = Platform.objects.all() table = tables.PlatformTable -class PlatformCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.add_platform' +class PlatformEditView(ObjectEditView): queryset = Platform.objects.all() model_form = forms.PlatformForm default_return_url = 'dcim:platform_list' -class PlatformEditView(PlatformCreateView): - permission_required = 'dcim.change_platform' - - -class PlatformBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_platform' +class PlatformBulkImportView(BulkImportView): + queryset = Platform.objects.all() model_form = forms.PlatformCSVForm table = tables.PlatformTable default_return_url = 'dcim:platform_list' -class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_platform' +class PlatformBulkDeleteView(BulkDeleteView): queryset = Platform.objects.all() table = tables.PlatformTable default_return_url = 'dcim:platform_list' @@ -1088,8 +975,7 @@ class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Devices # -class DeviceListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_device' +class DeviceListView(ObjectListView): queryset = Device.objects.prefetch_related( 'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6' ) @@ -1099,58 +985,72 @@ class DeviceListView(PermissionRequiredMixin, ObjectListView): template_name = 'dcim/device_list.html' -class DeviceView(PermissionRequiredMixin, View): - permission_required = 'dcim.view_device' +class DeviceView(ObjectView): + queryset = Device.objects.prefetch_related( + 'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform', 'primary_ip4', 'primary_ip6' + ) def get(self, request, pk): - device = get_object_or_404(Device.objects.prefetch_related( - 'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform', 'primary_ip4', 'primary_ip6' - ), pk=pk) + device = get_object_or_404(self.queryset, pk=pk) # VirtualChassis members if device.virtual_chassis is not None: - vc_members = Device.objects.filter( + vc_members = Device.objects.restrict(request.user, 'view').filter( virtual_chassis=device.virtual_chassis ).order_by('vc_position') else: vc_members = [] # Console ports - console_ports = device.consoleports.prefetch_related('connected_endpoint__device', 'cable') + console_ports = ConsolePort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related( + 'connected_endpoint__device', 'cable', + ) # Console server ports - consoleserverports = device.consoleserverports.prefetch_related('connected_endpoint__device', 'cable') + consoleserverports = ConsoleServerPort.objects.restrict(request.user, 'view').filter( + device=device + ).prefetch_related( + 'connected_endpoint__device', 'cable', + ) # Power ports - power_ports = device.powerports.prefetch_related('_connected_poweroutlet__device', 'cable') + power_ports = PowerPort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related( + '_connected_poweroutlet__device', 'cable', + ) # Power outlets - poweroutlets = device.poweroutlets.prefetch_related('connected_endpoint__device', 'cable', 'power_port') + poweroutlets = PowerOutlet.objects.restrict(request.user, 'view').filter(device=device).prefetch_related( + 'connected_endpoint__device', 'cable', 'power_port', + ) # Interfaces - interfaces = device.vc_interfaces.prefetch_related( + interfaces = device.vc_interfaces.restrict(request.user, 'view').filter(device=device).prefetch_related( 'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable', 'cable__termination_a', 'cable__termination_b', 'ip_addresses', 'tags' ) # Front ports - front_ports = device.frontports.prefetch_related('rear_port', 'cable') + front_ports = FrontPort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related( + 'rear_port', 'cable', + ) # Rear ports - rear_ports = device.rearports.prefetch_related('cable') + rear_ports = RearPort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related('cable') # Device bays - device_bays = device.device_bays.prefetch_related('installed_device__device_type__manufacturer') + device_bays = DeviceBay.objects.restrict(request.user, 'view').filter(device=device).prefetch_related( + 'installed_device__device_type__manufacturer', + ) # Services - services = device.services.all() + services = Service.objects.restrict(request.user, 'view').filter(device=device) # Secrets - secrets = device.secrets.all() + secrets = Secret.objects.restrict(request.user, 'view').filter(device=device) # Find up to ten devices in the same site with the same functional role for quick reference. - related_devices = Device.objects.filter( + related_devices = Device.objects.restrict(request.user, 'view').filter( site=device.site, device_role=device.device_role ).exclude( pk=device.pk @@ -1177,13 +1077,13 @@ class DeviceView(PermissionRequiredMixin, View): }) -class DeviceInventoryView(PermissionRequiredMixin, View): - permission_required = 'dcim.view_device' +class DeviceInventoryView(ObjectView): + queryset = Device.objects.all() def get(self, request, pk): - device = get_object_or_404(Device, pk=pk) - inventory_items = InventoryItem.objects.filter( + device = get_object_or_404(self.queryset, pk=pk) + inventory_items = InventoryItem.objects.restrict(request.user, 'view').filter( device=device, parent=None ).prefetch_related( 'manufacturer', 'child_items' @@ -1196,12 +1096,13 @@ class DeviceInventoryView(PermissionRequiredMixin, View): }) -class DeviceStatusView(PermissionRequiredMixin, View): - permission_required = ('dcim.view_device', 'dcim.napalm_read') +class DeviceStatusView(ObjectView): + additional_permissions = ['dcim.napalm_read_device'] + queryset = Device.objects.all() def get(self, request, pk): - device = get_object_or_404(Device, pk=pk) + device = get_object_or_404(self.queryset, pk=pk) return render(request, 'dcim/device_status.html', { 'device': device, @@ -1209,13 +1110,16 @@ class DeviceStatusView(PermissionRequiredMixin, View): }) -class DeviceLLDPNeighborsView(PermissionRequiredMixin, View): - permission_required = ('dcim.view_device', 'dcim.napalm_read') +class DeviceLLDPNeighborsView(ObjectView): + additional_permissions = ['dcim.napalm_read_device'] + queryset = Device.objects.all() def get(self, request, pk): - device = get_object_or_404(Device, pk=pk) - interfaces = device.vc_interfaces.exclude(type__in=NONCONNECTABLE_IFACE_TYPES).prefetch_related( + device = get_object_or_404(self.queryset, pk=pk) + interfaces = device.vc_interfaces.restrict(request.user, 'view').exclude( + type__in=NONCONNECTABLE_IFACE_TYPES + ).prefetch_related( '_connected_interface__device' ) @@ -1226,12 +1130,13 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View): }) -class DeviceConfigView(PermissionRequiredMixin, View): - permission_required = ('dcim.view_device', 'dcim.napalm_read') +class DeviceConfigView(ObjectView): + additional_permissions = ['dcim.napalm_read_device'] + queryset = Device.objects.all() def get(self, request, pk): - device = get_object_or_404(Device, pk=pk) + device = get_object_or_404(self.queryset, pk=pk) return render(request, 'dcim/device_config.html', { 'device': device, @@ -1239,40 +1144,33 @@ class DeviceConfigView(PermissionRequiredMixin, View): }) -class DeviceConfigContextView(PermissionRequiredMixin, ObjectConfigContextView): - permission_required = 'dcim.view_device' - object_class = Device +class DeviceConfigContextView(ObjectConfigContextView): + queryset = Device.objects.all() base_template = 'dcim/device.html' -class DeviceCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.add_device' +class DeviceEditView(ObjectEditView): queryset = Device.objects.all() model_form = forms.DeviceForm template_name = 'dcim/device_edit.html' default_return_url = 'dcim:device_list' -class DeviceEditView(DeviceCreateView): - permission_required = 'dcim.change_device' - - -class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_device' +class DeviceDeleteView(ObjectDeleteView): queryset = Device.objects.all() default_return_url = 'dcim:device_list' -class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_device' +class DeviceBulkImportView(BulkImportView): + queryset = Device.objects.all() model_form = forms.DeviceCSVForm table = tables.DeviceImportTable template_name = 'dcim/device_import.html' default_return_url = 'dcim:device_list' -class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_device' +class ChildDeviceBulkImportView(BulkImportView): + queryset = Device.objects.all() model_form = forms.ChildDeviceCSVForm table = tables.DeviceImportTable template_name = 'dcim/device_import_child.html' @@ -1290,8 +1188,7 @@ class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView): return obj -class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_device' +class DeviceBulkEditView(BulkEditView): queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer') filterset = filters.DeviceFilterSet table = tables.DeviceTable @@ -1299,8 +1196,7 @@ class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView): default_return_url = 'dcim:device_list' -class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_device' +class DeviceBulkDeleteView(BulkDeleteView): queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer') filterset = filters.DeviceFilterSet table = tables.DeviceTable @@ -1311,8 +1207,7 @@ class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Console ports # -class ConsolePortListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_consoleport' +class ConsolePortListView(ObjectListView): queryset = ConsolePort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable') filterset = filters.ConsolePortFilterSet filterset_form = forms.ConsolePortFilterForm @@ -1320,42 +1215,37 @@ class ConsolePortListView(PermissionRequiredMixin, ObjectListView): action_buttons = ('import', 'export') -class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_consoleport' - model = ConsolePort +class ConsolePortCreateView(ComponentCreateView): + queryset = ConsolePort.objects.all() form = forms.ConsolePortCreateForm model_form = forms.ConsolePortForm template_name = 'dcim/device_component_add.html' -class ConsolePortEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_consoleport' +class ConsolePortEditView(ObjectEditView): queryset = ConsolePort.objects.all() model_form = forms.ConsolePortForm -class ConsolePortDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_consoleport' +class ConsolePortDeleteView(ObjectDeleteView): queryset = ConsolePort.objects.all() -class ConsolePortBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_consoleport' +class ConsolePortBulkImportView(BulkImportView): + queryset = ConsolePort.objects.all() model_form = forms.ConsolePortCSVForm table = tables.ConsolePortImportTable default_return_url = 'dcim:consoleport_list' -class ConsolePortBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_consoleport' +class ConsolePortBulkEditView(BulkEditView): queryset = ConsolePort.objects.all() filterset = filters.ConsolePortFilterSet table = tables.ConsolePortTable form = forms.ConsolePortBulkEditForm -class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_consoleport' +class ConsolePortBulkDeleteView(BulkDeleteView): queryset = ConsolePort.objects.all() filterset = filters.ConsolePortFilterSet table = tables.ConsolePortTable @@ -1366,8 +1256,7 @@ class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Console server ports # -class ConsoleServerPortListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_consoleserverport' +class ConsoleServerPortListView(ObjectListView): queryset = ConsoleServerPort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable') filterset = filters.ConsoleServerPortFilterSet filterset_form = forms.ConsoleServerPortFilterForm @@ -1375,54 +1264,47 @@ class ConsoleServerPortListView(PermissionRequiredMixin, ObjectListView): action_buttons = ('import', 'export') -class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_consoleserverport' - model = ConsoleServerPort +class ConsoleServerPortCreateView(ComponentCreateView): + queryset = ConsoleServerPort.objects.all() form = forms.ConsoleServerPortCreateForm model_form = forms.ConsoleServerPortForm template_name = 'dcim/device_component_add.html' -class ConsoleServerPortEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_consoleserverport' +class ConsoleServerPortEditView(ObjectEditView): queryset = ConsoleServerPort.objects.all() model_form = forms.ConsoleServerPortForm -class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_consoleserverport' +class ConsoleServerPortDeleteView(ObjectDeleteView): queryset = ConsoleServerPort.objects.all() -class ConsoleServerPortBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_consoleserverport' +class ConsoleServerPortBulkImportView(BulkImportView): + queryset = ConsoleServerPort.objects.all() model_form = forms.ConsoleServerPortCSVForm table = tables.ConsoleServerPortImportTable default_return_url = 'dcim:consoleserverport_list' -class ConsoleServerPortBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_consoleserverport' +class ConsoleServerPortBulkEditView(BulkEditView): queryset = ConsoleServerPort.objects.all() filterset = filters.ConsoleServerPortFilterSet table = tables.ConsoleServerPortTable form = forms.ConsoleServerPortBulkEditForm -class ConsoleServerPortBulkRenameView(PermissionRequiredMixin, BulkRenameView): - permission_required = 'dcim.change_consoleserverport' +class ConsoleServerPortBulkRenameView(BulkRenameView): queryset = ConsoleServerPort.objects.all() form = forms.ConsoleServerPortBulkRenameForm -class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): - permission_required = 'dcim.change_consoleserverport' - model = ConsoleServerPort +class ConsoleServerPortBulkDisconnectView(BulkDisconnectView): + queryset = ConsoleServerPort.objects.all() form = forms.ConsoleServerPortBulkDisconnectForm -class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_consoleserverport' +class ConsoleServerPortBulkDeleteView(BulkDeleteView): queryset = ConsoleServerPort.objects.all() filterset = filters.ConsoleServerPortFilterSet table = tables.ConsoleServerPortTable @@ -1433,8 +1315,7 @@ class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Power ports # -class PowerPortListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_powerport' +class PowerPortListView(ObjectListView): queryset = PowerPort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable') filterset = filters.PowerPortFilterSet filterset_form = forms.PowerPortFilterForm @@ -1442,42 +1323,37 @@ class PowerPortListView(PermissionRequiredMixin, ObjectListView): action_buttons = ('import', 'export') -class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_powerport' - model = PowerPort +class PowerPortCreateView(ComponentCreateView): + queryset = PowerPort.objects.all() form = forms.PowerPortCreateForm model_form = forms.PowerPortForm template_name = 'dcim/device_component_add.html' -class PowerPortEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_powerport' +class PowerPortEditView(ObjectEditView): queryset = PowerPort.objects.all() model_form = forms.PowerPortForm -class PowerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_powerport' +class PowerPortDeleteView(ObjectDeleteView): queryset = PowerPort.objects.all() -class PowerPortBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_powerport' +class PowerPortBulkImportView(BulkImportView): + queryset = PowerPort.objects.all() model_form = forms.PowerPortCSVForm table = tables.PowerPortImportTable default_return_url = 'dcim:powerport_list' -class PowerPortBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_powerport' +class PowerPortBulkEditView(BulkEditView): queryset = PowerPort.objects.all() filterset = filters.PowerPortFilterSet table = tables.PowerPortTable form = forms.PowerPortBulkEditForm -class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_powerport' +class PowerPortBulkDeleteView(BulkDeleteView): queryset = PowerPort.objects.all() filterset = filters.PowerPortFilterSet table = tables.PowerPortTable @@ -1488,8 +1364,7 @@ class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Power outlets # -class PowerOutletListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_poweroutlet' +class PowerOutletListView(ObjectListView): queryset = PowerOutlet.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable') filterset = filters.PowerOutletFilterSet filterset_form = forms.PowerOutletFilterForm @@ -1497,54 +1372,47 @@ class PowerOutletListView(PermissionRequiredMixin, ObjectListView): action_buttons = ('import', 'export') -class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_poweroutlet' - model = PowerOutlet +class PowerOutletCreateView(ComponentCreateView): + queryset = PowerOutlet.objects.all() form = forms.PowerOutletCreateForm model_form = forms.PowerOutletForm template_name = 'dcim/device_component_add.html' -class PowerOutletEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_poweroutlet' +class PowerOutletEditView(ObjectEditView): queryset = PowerOutlet.objects.all() model_form = forms.PowerOutletForm -class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_poweroutlet' +class PowerOutletDeleteView(ObjectDeleteView): queryset = PowerOutlet.objects.all() -class PowerOutletBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_poweroutlet' +class PowerOutletBulkImportView(BulkImportView): + queryset = PowerOutlet.objects.all() model_form = forms.PowerOutletCSVForm table = tables.PowerOutletImportTable default_return_url = 'dcim:poweroutlet_list' -class PowerOutletBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_poweroutlet' +class PowerOutletBulkEditView(BulkEditView): queryset = PowerOutlet.objects.all() filterset = filters.PowerOutletFilterSet table = tables.PowerOutletTable form = forms.PowerOutletBulkEditForm -class PowerOutletBulkRenameView(PermissionRequiredMixin, BulkRenameView): - permission_required = 'dcim.change_poweroutlet' +class PowerOutletBulkRenameView(BulkRenameView): queryset = PowerOutlet.objects.all() form = forms.PowerOutletBulkRenameForm -class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): - permission_required = 'dcim.change_poweroutlet' - model = PowerOutlet +class PowerOutletBulkDisconnectView(BulkDisconnectView): + queryset = PowerOutlet.objects.all() form = forms.PowerOutletBulkDisconnectForm -class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_poweroutlet' +class PowerOutletBulkDeleteView(BulkDeleteView): queryset = PowerOutlet.objects.all() filterset = filters.PowerOutletFilterSet table = tables.PowerOutletTable @@ -1555,8 +1423,7 @@ class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Interfaces # -class InterfaceListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_interface' +class InterfaceListView(ObjectListView): queryset = Interface.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable') filterset = filters.InterfaceFilterSet filterset_form = forms.InterfaceFilterForm @@ -1564,16 +1431,16 @@ class InterfaceListView(PermissionRequiredMixin, ObjectListView): action_buttons = ('import', 'export') -class InterfaceView(PermissionRequiredMixin, View): - permission_required = 'dcim.view_interface' +class InterfaceView(ObjectView): + queryset = Interface.objects.all() def get(self, request, pk): - interface = get_object_or_404(Interface, pk=pk) + interface = get_object_or_404(self.queryset, pk=pk) # Get assigned IP addresses ipaddress_table = InterfaceIPAddressTable( - data=interface.ip_addresses.prefetch_related('vrf', 'tenant'), + data=interface.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), orderable=False ) @@ -1600,55 +1467,48 @@ class InterfaceView(PermissionRequiredMixin, View): }) -class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_interface' - model = Interface +class InterfaceCreateView(ComponentCreateView): + queryset = Interface.objects.all() form = forms.InterfaceCreateForm model_form = forms.InterfaceForm template_name = 'dcim/device_component_add.html' -class InterfaceEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_interface' +class InterfaceEditView(ObjectEditView): queryset = Interface.objects.all() model_form = forms.InterfaceForm template_name = 'dcim/interface_edit.html' -class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_interface' +class InterfaceDeleteView(ObjectDeleteView): queryset = Interface.objects.all() -class InterfaceBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_interface' +class InterfaceBulkImportView(BulkImportView): + queryset = Interface.objects.all() model_form = forms.InterfaceCSVForm table = tables.InterfaceImportTable default_return_url = 'dcim:interface_list' -class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_interface' +class InterfaceBulkEditView(BulkEditView): queryset = Interface.objects.all() filterset = filters.InterfaceFilterSet table = tables.InterfaceTable form = forms.InterfaceBulkEditForm -class InterfaceBulkRenameView(PermissionRequiredMixin, BulkRenameView): - permission_required = 'dcim.change_interface' +class InterfaceBulkRenameView(BulkRenameView): queryset = Interface.objects.all() form = forms.InterfaceBulkRenameForm -class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): - permission_required = 'dcim.change_interface' - model = Interface +class InterfaceBulkDisconnectView(BulkDisconnectView): + queryset = Interface.objects.all() form = forms.InterfaceBulkDisconnectForm -class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_interface' +class InterfaceBulkDeleteView(BulkDeleteView): queryset = Interface.objects.all() filterset = filters.InterfaceFilterSet table = tables.InterfaceTable @@ -1659,8 +1519,7 @@ class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Front ports # -class FrontPortListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_frontport' +class FrontPortListView(ObjectListView): queryset = FrontPort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable') filterset = filters.FrontPortFilterSet filterset_form = forms.FrontPortFilterForm @@ -1668,54 +1527,47 @@ class FrontPortListView(PermissionRequiredMixin, ObjectListView): action_buttons = ('import', 'export') -class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_frontport' - model = FrontPort +class FrontPortCreateView(ComponentCreateView): + queryset = FrontPort.objects.all() form = forms.FrontPortCreateForm model_form = forms.FrontPortForm template_name = 'dcim/device_component_add.html' -class FrontPortEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_frontport' +class FrontPortEditView(ObjectEditView): queryset = FrontPort.objects.all() model_form = forms.FrontPortForm -class FrontPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_frontport' +class FrontPortDeleteView(ObjectDeleteView): queryset = FrontPort.objects.all() -class FrontPortBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_frontport' +class FrontPortBulkImportView(BulkImportView): + queryset = FrontPort.objects.all() model_form = forms.FrontPortCSVForm table = tables.FrontPortImportTable default_return_url = 'dcim:frontport_list' -class FrontPortBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_frontport' +class FrontPortBulkEditView(BulkEditView): queryset = FrontPort.objects.all() filterset = filters.FrontPortFilterSet table = tables.FrontPortTable form = forms.FrontPortBulkEditForm -class FrontPortBulkRenameView(PermissionRequiredMixin, BulkRenameView): - permission_required = 'dcim.change_frontport' +class FrontPortBulkRenameView(BulkRenameView): queryset = FrontPort.objects.all() form = forms.FrontPortBulkRenameForm -class FrontPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): - permission_required = 'dcim.change_frontport' - model = FrontPort +class FrontPortBulkDisconnectView(BulkDisconnectView): + queryset = FrontPort.objects.all() form = forms.FrontPortBulkDisconnectForm -class FrontPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_frontport' +class FrontPortBulkDeleteView(BulkDeleteView): queryset = FrontPort.objects.all() filterset = filters.FrontPortFilterSet table = tables.FrontPortTable @@ -1726,8 +1578,7 @@ class FrontPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Rear ports # -class RearPortListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_rearport' +class RearPortListView(ObjectListView): queryset = RearPort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable') filterset = filters.RearPortFilterSet filterset_form = forms.RearPortFilterForm @@ -1735,54 +1586,47 @@ class RearPortListView(PermissionRequiredMixin, ObjectListView): action_buttons = ('import', 'export') -class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_rearport' - model = RearPort +class RearPortCreateView(ComponentCreateView): + queryset = RearPort.objects.all() form = forms.RearPortCreateForm model_form = forms.RearPortForm template_name = 'dcim/device_component_add.html' -class RearPortEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_rearport' +class RearPortEditView(ObjectEditView): queryset = RearPort.objects.all() model_form = forms.RearPortForm -class RearPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_rearport' +class RearPortDeleteView(ObjectDeleteView): queryset = RearPort.objects.all() -class RearPortBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_rearport' +class RearPortBulkImportView(BulkImportView): + queryset = RearPort.objects.all() model_form = forms.RearPortCSVForm table = tables.RearPortImportTable default_return_url = 'dcim:rearport_list' -class RearPortBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_rearport' +class RearPortBulkEditView(BulkEditView): queryset = RearPort.objects.all() filterset = filters.RearPortFilterSet table = tables.RearPortTable form = forms.RearPortBulkEditForm -class RearPortBulkRenameView(PermissionRequiredMixin, BulkRenameView): - permission_required = 'dcim.change_rearport' +class RearPortBulkRenameView(BulkRenameView): queryset = RearPort.objects.all() form = forms.RearPortBulkRenameForm -class RearPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): - permission_required = 'dcim.change_rearport' - model = RearPort +class RearPortBulkDisconnectView(BulkDisconnectView): + queryset = RearPort.objects.all() form = forms.RearPortBulkDisconnectForm -class RearPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_rearport' +class RearPortBulkDeleteView(BulkDeleteView): queryset = RearPort.objects.all() filterset = filters.RearPortFilterSet table = tables.RearPortTable @@ -1793,8 +1637,7 @@ class RearPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Device bays # -class DeviceBayListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_devicebay' +class DeviceBayListView(ObjectListView): queryset = DeviceBay.objects.prefetch_related( 'device', 'device__site', 'installed_device', 'installed_device__site' ) @@ -1804,31 +1647,27 @@ class DeviceBayListView(PermissionRequiredMixin, ObjectListView): action_buttons = ('import', 'export') -class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_devicebay' - model = DeviceBay +class DeviceBayCreateView(ComponentCreateView): + queryset = DeviceBay.objects.all() form = forms.DeviceBayCreateForm model_form = forms.DeviceBayForm template_name = 'dcim/device_component_add.html' -class DeviceBayEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_devicebay' +class DeviceBayEditView(ObjectEditView): queryset = DeviceBay.objects.all() model_form = forms.DeviceBayForm -class DeviceBayDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_devicebay' +class DeviceBayDeleteView(ObjectDeleteView): queryset = DeviceBay.objects.all() -class DeviceBayPopulateView(PermissionRequiredMixin, View): - permission_required = 'dcim.change_devicebay' +class DeviceBayPopulateView(ObjectEditView): + queryset = DeviceBay.objects.all() def get(self, request, pk): - - device_bay = get_object_or_404(DeviceBay, pk=pk) + device_bay = get_object_or_404(self.queryset, pk=pk) form = forms.PopulateDeviceBayForm(device_bay) return render(request, 'dcim/devicebay_populate.html', { @@ -1838,8 +1677,7 @@ class DeviceBayPopulateView(PermissionRequiredMixin, View): }) def post(self, request, pk): - - device_bay = get_object_or_404(DeviceBay, pk=pk) + device_bay = get_object_or_404(self.queryset, pk=pk) form = forms.PopulateDeviceBayForm(device_bay, request.POST) if form.is_valid(): @@ -1857,12 +1695,12 @@ class DeviceBayPopulateView(PermissionRequiredMixin, View): }) -class DeviceBayDepopulateView(PermissionRequiredMixin, View): - permission_required = 'dcim.change_devicebay' +class DeviceBayDepopulateView(ObjectEditView): + queryset = DeviceBay.objects.all() def get(self, request, pk): - device_bay = get_object_or_404(DeviceBay, pk=pk) + device_bay = get_object_or_404(self.queryset, pk=pk) form = ConfirmationForm() return render(request, 'dcim/devicebay_depopulate.html', { @@ -1873,7 +1711,7 @@ class DeviceBayDepopulateView(PermissionRequiredMixin, View): def post(self, request, pk): - device_bay = get_object_or_404(DeviceBay, pk=pk) + device_bay = get_object_or_404(self.queryset, pk=pk) form = ConfirmationForm(request.POST) if form.is_valid(): @@ -1892,29 +1730,26 @@ class DeviceBayDepopulateView(PermissionRequiredMixin, View): }) -class DeviceBayBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_devicebay' +class DeviceBayBulkImportView(BulkImportView): + queryset = DeviceBay.objects.all() model_form = forms.DeviceBayCSVForm table = tables.DeviceBayImportTable default_return_url = 'dcim:devicebay_list' -class DeviceBayBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_devicebay' +class DeviceBayBulkEditView(BulkEditView): queryset = DeviceBay.objects.all() filterset = filters.DeviceBayFilterSet table = tables.DeviceBayTable form = forms.DeviceBayBulkEditForm -class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView): - permission_required = 'dcim.change_devicebay' +class DeviceBayBulkRenameView(BulkRenameView): queryset = DeviceBay.objects.all() form = forms.DeviceBayBulkRenameForm -class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_devicebay' +class DeviceBayBulkDeleteView(BulkDeleteView): queryset = DeviceBay.objects.all() filterset = filters.DeviceBayFilterSet table = tables.DeviceBayTable @@ -1925,96 +1760,88 @@ class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Bulk Device component creation # -class DeviceBulkAddConsolePortView(PermissionRequiredMixin, BulkComponentCreateView): - permission_required = 'dcim.add_consoleport' +class DeviceBulkAddConsolePortView(BulkComponentCreateView): parent_model = Device parent_field = 'device' form = forms.ConsolePortBulkCreateForm - model = ConsolePort + queryset = ConsolePort.objects.all() model_form = forms.ConsolePortForm filterset = filters.DeviceFilterSet table = tables.DeviceTable default_return_url = 'dcim:device_list' -class DeviceBulkAddConsoleServerPortView(PermissionRequiredMixin, BulkComponentCreateView): - permission_required = 'dcim.add_consoleserverport' +class DeviceBulkAddConsoleServerPortView(BulkComponentCreateView): parent_model = Device parent_field = 'device' form = forms.ConsoleServerPortBulkCreateForm - model = ConsoleServerPort + queryset = ConsoleServerPort.objects.all() model_form = forms.ConsoleServerPortForm filterset = filters.DeviceFilterSet table = tables.DeviceTable default_return_url = 'dcim:device_list' -class DeviceBulkAddPowerPortView(PermissionRequiredMixin, BulkComponentCreateView): - permission_required = 'dcim.add_powerport' +class DeviceBulkAddPowerPortView(BulkComponentCreateView): parent_model = Device parent_field = 'device' form = forms.PowerPortBulkCreateForm - model = PowerPort + queryset = PowerPort.objects.all() model_form = forms.PowerPortForm filterset = filters.DeviceFilterSet table = tables.DeviceTable default_return_url = 'dcim:device_list' -class DeviceBulkAddPowerOutletView(PermissionRequiredMixin, BulkComponentCreateView): - permission_required = 'dcim.add_poweroutlet' +class DeviceBulkAddPowerOutletView(BulkComponentCreateView): parent_model = Device parent_field = 'device' form = forms.PowerOutletBulkCreateForm - model = PowerOutlet + queryset = PowerOutlet.objects.all() model_form = forms.PowerOutletForm filterset = filters.DeviceFilterSet table = tables.DeviceTable default_return_url = 'dcim:device_list' -class DeviceBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateView): - permission_required = 'dcim.add_interface' +class DeviceBulkAddInterfaceView(BulkComponentCreateView): parent_model = Device parent_field = 'device' form = forms.InterfaceBulkCreateForm - model = Interface + queryset = Interface.objects.all() model_form = forms.InterfaceForm filterset = filters.DeviceFilterSet table = tables.DeviceTable default_return_url = 'dcim:device_list' -# class DeviceBulkAddFrontPortView(PermissionRequiredMixin, BulkComponentCreateView): -# permission_required = 'dcim.add_frontport' +# class DeviceBulkAddFrontPortView(BulkComponentCreateView): # parent_model = Device # parent_field = 'device' # form = forms.FrontPortBulkCreateForm -# model = FrontPort +# queryset = FrontPort.objects.all() # model_form = forms.FrontPortForm # filterset = filters.DeviceFilterSet # table = tables.DeviceTable # default_return_url = 'dcim:device_list' -class DeviceBulkAddRearPortView(PermissionRequiredMixin, BulkComponentCreateView): - permission_required = 'dcim.add_rearport' +class DeviceBulkAddRearPortView(BulkComponentCreateView): parent_model = Device parent_field = 'device' form = forms.RearPortBulkCreateForm - model = RearPort + queryset = RearPort.objects.all() model_form = forms.RearPortForm filterset = filters.DeviceFilterSet table = tables.DeviceTable default_return_url = 'dcim:device_list' -class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateView): - permission_required = 'dcim.add_devicebay' +class DeviceBulkAddDeviceBayView(BulkComponentCreateView): parent_model = Device parent_field = 'device' form = forms.DeviceBayBulkCreateForm - model = DeviceBay + queryset = DeviceBay.objects.all() model_form = forms.DeviceBayForm filterset = filters.DeviceFilterSet table = tables.DeviceTable @@ -2025,8 +1852,7 @@ class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateVie # Cables # -class CableListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_cable' +class CableListView(ObjectListView): queryset = Cable.objects.prefetch_related( 'termination_a', 'termination_b' ) @@ -2036,27 +1862,33 @@ class CableListView(PermissionRequiredMixin, ObjectListView): action_buttons = ('import', 'export') -class CableView(PermissionRequiredMixin, View): - permission_required = 'dcim.view_cable' +class CableView(ObjectView): + queryset = Cable.objects.all() def get(self, request, pk): - cable = get_object_or_404(Cable, pk=pk) + cable = get_object_or_404(self.queryset, pk=pk) return render(request, 'dcim/cable.html', { 'cable': cable, }) -class CableTraceView(PermissionRequiredMixin, View): +class CableTraceView(ObjectView): """ Trace a cable path beginning from the given termination. """ - permission_required = 'dcim.view_cable' + additional_permissions = ['dcim.view_cable'] - def get(self, request, model, pk): + def dispatch(self, request, *args, **kwargs): + model = kwargs.pop('model') + self.queryset = model.objects.all() - obj = get_object_or_404(model, pk=pk) + return super().dispatch(request, *args, **kwargs) + + def get(self, request, pk): + + obj = get_object_or_404(self.queryset, pk=pk) path, split_ends = obj.trace() total_length = sum( [entry[1]._abs_length for entry in path if entry[1] and entry[1]._abs_length] @@ -2070,23 +1902,15 @@ class CableTraceView(PermissionRequiredMixin, View): }) -class CableCreateView(PermissionRequiredMixin, GetReturnURLMixin, View): - permission_required = 'dcim.add_cable' +class CableCreateView(ObjectEditView): + queryset = Cable.objects.all() template_name = 'dcim/cable_connect.html' + default_return_url = 'dcim:cable_list' def dispatch(self, request, *args, **kwargs): - termination_a_type = kwargs.get('termination_a_type') - termination_a_id = kwargs.get('termination_a_id') - - termination_b_type_name = kwargs.get('termination_b_type') - self.termination_b_type = ContentType.objects.get(model=termination_b_type_name.replace('-', '')) - - self.obj = Cable( - termination_a=termination_a_type.objects.get(pk=termination_a_id), - termination_b_type=self.termination_b_type - ) - self.form_class = { + # Set the model_form class based on the type of component being connected + self.model_form = { 'console-port': forms.ConnectCableToConsolePortForm, 'console-server-port': forms.ConnectCableToConsoleServerPortForm, 'power-port': forms.ConnectCableToPowerPortForm, @@ -2096,85 +1920,65 @@ class CableCreateView(PermissionRequiredMixin, GetReturnURLMixin, View): 'rear-port': forms.ConnectCableToRearPortForm, 'power-feed': forms.ConnectCableToPowerFeedForm, 'circuit-termination': forms.ConnectCableToCircuitTerminationForm, - }[termination_b_type_name] + }[kwargs.get('termination_b_type')] return super().dispatch(request, *args, **kwargs) + def alter_obj(self, obj, request, url_args, url_kwargs): + termination_a_type = url_kwargs.get('termination_a_type') + termination_a_id = url_kwargs.get('termination_a_id') + termination_b_type_name = url_kwargs.get('termination_b_type') + self.termination_b_type = ContentType.objects.get(model=termination_b_type_name.replace('-', '')) + + # Initialize Cable termination attributes + obj.termination_a = termination_a_type.objects.get(pk=termination_a_id) + obj.termination_b_type = self.termination_b_type + + return obj + def get(self, request, *args, **kwargs): + obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs) # Parse initial data manually to avoid setting field values as lists initial_data = {k: request.GET[k] for k in request.GET} # Set initial site and rack based on side A termination (if not already set) if 'termination_b_site' not in initial_data: - initial_data['termination_b_site'] = getattr(self.obj.termination_a.parent, 'site', None) + initial_data['termination_b_site'] = getattr(obj.termination_a.parent, 'site', None) if 'termination_b_rack' not in initial_data: - initial_data['termination_b_rack'] = getattr(self.obj.termination_a.parent, 'rack', None) + initial_data['termination_b_rack'] = getattr(obj.termination_a.parent, 'rack', None) - form = self.form_class(instance=self.obj, initial=initial_data) + form = self.model_form(instance=obj, initial=initial_data) return render(request, self.template_name, { - 'obj': self.obj, + 'obj': obj, 'obj_type': Cable._meta.verbose_name, 'termination_b_type': self.termination_b_type.name, 'form': form, - 'return_url': self.get_return_url(request, self.obj), - }) - - def post(self, request, *args, **kwargs): - - form = self.form_class(request.POST, request.FILES, instance=self.obj) - - if form.is_valid(): - obj = form.save() - - msg = 'Created cable {}'.format( - obj.get_absolute_url(), - escape(obj) - ) - messages.success(request, mark_safe(msg)) - - if '_addanother' in request.POST: - return redirect(request.get_full_path()) - - return_url = form.cleaned_data.get('return_url') - if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()): - return redirect(return_url) - else: - return redirect(self.get_return_url(request, obj)) - - return render(request, self.template_name, { - 'obj': self.obj, - 'obj_type': Cable._meta.verbose_name, - 'termination_b_type': self.termination_b_type.name, - 'form': form, - 'return_url': self.get_return_url(request, self.obj), + 'return_url': self.get_return_url(request, obj), }) -class CableEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_cable' +class CableEditView(ObjectEditView): queryset = Cable.objects.all() model_form = forms.CableForm template_name = 'dcim/cable_edit.html' default_return_url = 'dcim:cable_list' -class CableDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_cable' +class CableDeleteView(ObjectDeleteView): queryset = Cable.objects.all() default_return_url = 'dcim:cable_list' -class CableBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_cable' +class CableBulkImportView(BulkImportView): + queryset = Cable.objects.all() model_form = forms.CableCSVForm table = tables.CableTable default_return_url = 'dcim:cable_list' -class CableBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_cable' +class CableBulkEditView(BulkEditView): queryset = Cable.objects.prefetch_related('termination_a', 'termination_b') filterset = filters.CableFilterSet table = tables.CableTable @@ -2182,8 +1986,7 @@ class CableBulkEditView(PermissionRequiredMixin, BulkEditView): default_return_url = 'dcim:cable_list' -class CableBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_cable' +class CableBulkDeleteView(BulkDeleteView): queryset = Cable.objects.prefetch_related('termination_a', 'termination_b') filterset = filters.CableFilterSet table = tables.CableTable @@ -2194,8 +1997,7 @@ class CableBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Connections # -class ConsoleConnectionsListView(PermissionRequiredMixin, ObjectListView): - permission_required = ('dcim.view_consoleport', 'dcim.view_consoleserverport') +class ConsoleConnectionsListView(ObjectListView): queryset = ConsolePort.objects.prefetch_related( 'device', 'connected_endpoint__device' ).filter( @@ -2226,8 +2028,7 @@ class ConsoleConnectionsListView(PermissionRequiredMixin, ObjectListView): return '\n'.join(csv_data) -class PowerConnectionsListView(PermissionRequiredMixin, ObjectListView): - permission_required = ('dcim.view_powerport', 'dcim.view_poweroutlet') +class PowerConnectionsListView(ObjectListView): queryset = PowerPort.objects.prefetch_related( 'device', '_connected_poweroutlet__device' ).filter( @@ -2258,8 +2059,7 @@ class PowerConnectionsListView(PermissionRequiredMixin, ObjectListView): return '\n'.join(csv_data) -class InterfaceConnectionsListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_interface' +class InterfaceConnectionsListView(ObjectListView): queryset = Interface.objects.prefetch_related( 'device', 'cable', '_connected_interface__device' ).filter( @@ -2298,8 +2098,7 @@ class InterfaceConnectionsListView(PermissionRequiredMixin, ObjectListView): # Inventory items # -class InventoryItemListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_inventoryitem' +class InventoryItemListView(ObjectListView): queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer') filterset = filters.InventoryItemFilterSet filterset_form = forms.InventoryItemFilterForm @@ -2307,34 +2106,30 @@ class InventoryItemListView(PermissionRequiredMixin, ObjectListView): action_buttons = ('import', 'export') -class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_inventoryitem' +class InventoryItemEditView(ObjectEditView): queryset = InventoryItem.objects.all() model_form = forms.InventoryItemForm -class InventoryItemCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_inventoryitem' - model = InventoryItem +class InventoryItemCreateView(ComponentCreateView): + queryset = InventoryItem.objects.all() form = forms.InventoryItemCreateForm model_form = forms.InventoryItemForm template_name = 'dcim/device_component_add.html' -class InventoryItemDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_inventoryitem' +class InventoryItemDeleteView(ObjectDeleteView): queryset = InventoryItem.objects.all() -class InventoryItemBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_inventoryitem' +class InventoryItemBulkImportView(BulkImportView): + queryset = InventoryItem.objects.all() model_form = forms.InventoryItemCSVForm table = tables.InventoryItemTable default_return_url = 'dcim:inventoryitem_list' -class InventoryItemBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_inventoryitem' +class InventoryItemBulkEditView(BulkEditView): queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer') filterset = filters.InventoryItemFilterSet table = tables.InventoryItemTable @@ -2342,8 +2137,7 @@ class InventoryItemBulkEditView(PermissionRequiredMixin, BulkEditView): default_return_url = 'dcim:inventoryitem_list' -class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_inventoryitem' +class InventoryItemBulkDeleteView(BulkDeleteView): queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer') table = tables.InventoryItemTable template_name = 'dcim/inventoryitem_bulk_delete.html' @@ -2354,8 +2148,7 @@ class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Virtual chassis # -class VirtualChassisListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_virtualchassis' +class VirtualChassisListView(ObjectListView): queryset = VirtualChassis.objects.prefetch_related('master').annotate(member_count=Count('members')) table = tables.VirtualChassisTable filterset = filters.VirtualChassisFilterSet @@ -2363,19 +2156,22 @@ class VirtualChassisListView(PermissionRequiredMixin, ObjectListView): action_buttons = ('export',) -class VirtualChassisView(PermissionRequiredMixin, View): - permission_required = 'dcim.view_virtualchassis' +class VirtualChassisView(ObjectView): + queryset = VirtualChassis.objects.prefetch_related('members') def get(self, request, pk): - virtualchassis = get_object_or_404(VirtualChassis.objects.prefetch_related('members'), pk=pk) + virtualchassis = get_object_or_404(self.queryset, pk=pk) return render(request, 'dcim/virtualchassis.html', { 'virtualchassis': virtualchassis, }) -class VirtualChassisCreateView(PermissionRequiredMixin, View): - permission_required = 'dcim.add_virtualchassis' +class VirtualChassisCreateView(ObjectPermissionRequiredMixin, View): + queryset = VirtualChassis.objects.all() + + def get_required_permission(self): + return 'dcim.add_virtualchassis' def post(self, request): @@ -2429,8 +2225,11 @@ class VirtualChassisCreateView(PermissionRequiredMixin, View): }) -class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View): - permission_required = 'dcim.change_virtualchassis' +class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, View): + queryset = VirtualChassis.objects.all() + + def get_required_permission(self): + return 'dcim.change_virtualchassis' def get(self, request, pk): @@ -2494,18 +2293,20 @@ class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View): }) -class VirtualChassisDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_virtualchassis' +class VirtualChassisDeleteView(ObjectDeleteView): queryset = VirtualChassis.objects.all() default_return_url = 'dcim:device_list' -class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, View): - permission_required = 'dcim.change_virtualchassis' +class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMixin, View): + queryset = VirtualChassis.objects.all() + + def get_required_permission(self): + return 'dcim.change_virtualchassis' def get(self, request, pk): - virtual_chassis = get_object_or_404(VirtualChassis, pk=pk) + virtual_chassis = get_object_or_404(self.queryset, pk=pk) initial_data = {k: request.GET[k] for k in request.GET} member_select_form = forms.VCMemberSelectForm(initial=initial_data) @@ -2520,7 +2321,7 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi def post(self, request, pk): - virtual_chassis = get_object_or_404(VirtualChassis, pk=pk) + virtual_chassis = get_object_or_404(self.queryset, pk=pk) member_select_form = forms.VCMemberSelectForm(request.POST) @@ -2554,12 +2355,15 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi }) -class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin, View): - permission_required = 'dcim.change_virtualchassis' +class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURLMixin, View): + queryset = Device.objects.all() + + def get_required_permission(self): + return 'dcim.change_device' def get(self, request, pk): - device = get_object_or_404(Device, pk=pk, virtual_chassis__isnull=False) + device = get_object_or_404(self.queryset, pk=pk, virtual_chassis__isnull=False) form = ConfirmationForm(initial=request.GET) return render(request, 'dcim/virtualchassis_remove_member.html', { @@ -2570,7 +2374,7 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin, def post(self, request, pk): - device = get_object_or_404(Device, pk=pk, virtual_chassis__isnull=False) + device = get_object_or_404(self.queryset, pk=pk, virtual_chassis__isnull=False) form = ConfirmationForm(request.POST) # Protect master device from being removed @@ -2601,8 +2405,7 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin, }) -class VirtualChassisBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_virtualchassis' +class VirtualChassisBulkEditView(BulkEditView): queryset = VirtualChassis.objects.all() filterset = filters.VirtualChassisFilterSet table = tables.VirtualChassisTable @@ -2610,8 +2413,7 @@ class VirtualChassisBulkEditView(PermissionRequiredMixin, BulkEditView): default_return_url = 'dcim:virtualchassis_list' -class VirtualChassisBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_virtualchassis' +class VirtualChassisBulkDeleteView(BulkDeleteView): queryset = VirtualChassis.objects.all() filterset = filters.VirtualChassisFilterSet table = tables.VirtualChassisTable @@ -2622,8 +2424,7 @@ class VirtualChassisBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Power panels # -class PowerPanelListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_powerpanel' +class PowerPanelListView(ObjectListView): queryset = PowerPanel.objects.prefetch_related( 'site', 'rack_group' ).annotate( @@ -2634,12 +2435,12 @@ class PowerPanelListView(PermissionRequiredMixin, ObjectListView): table = tables.PowerPanelTable -class PowerPanelView(PermissionRequiredMixin, View): - permission_required = 'dcim.view_powerpanel' +class PowerPanelView(ObjectView): + queryset = PowerPanel.objects.prefetch_related('site', 'rack_group') def get(self, request, pk): - powerpanel = get_object_or_404(PowerPanel.objects.prefetch_related('site', 'rack_group'), pk=pk) + powerpanel = get_object_or_404(self.queryset, pk=pk) powerfeed_table = tables.PowerFeedTable( data=PowerFeed.objects.filter(power_panel=powerpanel).prefetch_related('rack'), orderable=False @@ -2652,32 +2453,25 @@ class PowerPanelView(PermissionRequiredMixin, View): }) -class PowerPanelCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.add_powerpanel' +class PowerPanelEditView(ObjectEditView): queryset = PowerPanel.objects.all() model_form = forms.PowerPanelForm default_return_url = 'dcim:powerpanel_list' -class PowerPanelEditView(PowerPanelCreateView): - permission_required = 'dcim.change_powerpanel' - - -class PowerPanelDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_powerpanel' +class PowerPanelDeleteView(ObjectDeleteView): queryset = PowerPanel.objects.all() default_return_url = 'dcim:powerpanel_list' -class PowerPanelBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_powerpanel' +class PowerPanelBulkImportView(BulkImportView): + queryset = PowerPanel.objects.all() model_form = forms.PowerPanelCSVForm table = tables.PowerPanelTable default_return_url = 'dcim:powerpanel_list' -class PowerPanelBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_powerpanel' +class PowerPanelBulkEditView(BulkEditView): queryset = PowerPanel.objects.prefetch_related('site', 'rack_group') filterset = filters.PowerPanelFilterSet table = tables.PowerPanelTable @@ -2685,8 +2479,7 @@ class PowerPanelBulkEditView(PermissionRequiredMixin, BulkEditView): default_return_url = 'dcim:powerpanel_list' -class PowerPanelBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_powerpanel' +class PowerPanelBulkDeleteView(BulkDeleteView): queryset = PowerPanel.objects.prefetch_related( 'site', 'rack_group' ).annotate( @@ -2701,8 +2494,7 @@ class PowerPanelBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Power feeds # -class PowerFeedListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'dcim.view_powerfeed' +class PowerFeedListView(ObjectListView): queryset = PowerFeed.objects.prefetch_related( 'power_panel', 'rack' ) @@ -2711,45 +2503,38 @@ class PowerFeedListView(PermissionRequiredMixin, ObjectListView): table = tables.PowerFeedTable -class PowerFeedView(PermissionRequiredMixin, View): - permission_required = 'dcim.view_powerfeed' +class PowerFeedView(ObjectView): + queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack') def get(self, request, pk): - powerfeed = get_object_or_404(PowerFeed.objects.prefetch_related('power_panel', 'rack'), pk=pk) + powerfeed = get_object_or_404(self.queryset, pk=pk) return render(request, 'dcim/powerfeed.html', { 'powerfeed': powerfeed, }) -class PowerFeedCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.add_powerfeed' +class PowerFeedEditView(ObjectEditView): queryset = PowerFeed.objects.all() model_form = forms.PowerFeedForm template_name = 'dcim/powerfeed_edit.html' default_return_url = 'dcim:powerfeed_list' -class PowerFeedEditView(PowerFeedCreateView): - permission_required = 'dcim.change_powerfeed' - - -class PowerFeedDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_powerfeed' +class PowerFeedDeleteView(ObjectDeleteView): queryset = PowerFeed.objects.all() default_return_url = 'dcim:powerfeed_list' -class PowerFeedBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.add_powerfeed' +class PowerFeedBulkImportView(BulkImportView): + queryset = PowerFeed.objects.all() model_form = forms.PowerFeedCSVForm table = tables.PowerFeedTable default_return_url = 'dcim:powerfeed_list' -class PowerFeedBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_powerfeed' +class PowerFeedBulkEditView(BulkEditView): queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack') filterset = filters.PowerFeedFilterSet table = tables.PowerFeedTable @@ -2757,8 +2542,7 @@ class PowerFeedBulkEditView(PermissionRequiredMixin, BulkEditView): default_return_url = 'dcim:powerfeed_list' -class PowerFeedBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_powerfeed' +class PowerFeedBulkDeleteView(BulkDeleteView): queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack') filterset = filters.PowerFeedFilterSet table = tables.PowerFeedTable diff --git a/netbox/extras/migrations/0024_scripts.py b/netbox/extras/migrations/0024_scripts.py index 82d0afdc9..c8d81e5e2 100644 --- a/netbox/extras/migrations/0024_scripts.py +++ b/netbox/extras/migrations/0024_scripts.py @@ -16,7 +16,6 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), ], options={ - 'permissions': (('run_script', 'Can run script'),), 'managed': False, }, ), diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index f98a7b34f..9e000774f 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -12,6 +12,7 @@ from django.template import Template, Context from django.urls import reverse from rest_framework.utils.encoders import JSONEncoder +from utilities.querysets import RestrictedQuerySet from utilities.utils import deepmerge, render_jinja2 from extras.choices import * from extras.constants import * @@ -563,9 +564,6 @@ class Script(models.Model): """ class Meta: managed = False - permissions = ( - ('run_script', 'Can run script'), - ) # @@ -670,6 +668,8 @@ class ObjectChange(models.Model): editable=False ) + objects = RestrictedQuerySet.as_manager() + csv_headers = [ 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id', 'related_object_type', 'related_object_id', 'object_repr', 'object_data', diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index d68ca2ce6..d5792ebda 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -6,6 +6,7 @@ from taggit.models import TagBase, GenericTaggedItemBase from utilities.choices import ColorChoices from utilities.fields import ColorField from utilities.models import ChangeLoggedModel +from utilities.querysets import RestrictedQuerySet # @@ -21,6 +22,8 @@ class Tag(TagBase, ChangeLoggedModel): blank=True, ) + objects = RestrictedQuerySet.as_manager() + def get_absolute_url(self): return reverse('extras:tag', args=[self.slug]) diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index 812c66714..9d9b55778 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -2,6 +2,8 @@ from collections import OrderedDict from django.db.models import Q, QuerySet +from utilities.querysets import RestrictedQuerySet + class CustomFieldQueryset: """ @@ -19,7 +21,7 @@ class CustomFieldQueryset: yield obj -class ConfigContextQuerySet(QuerySet): +class ConfigContextQuerySet(RestrictedQuerySet): def get_for_object(self, obj): """ diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index c94d8cd3f..4df06e12f 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -1,7 +1,6 @@ from datetime import date from django.contrib.contenttypes.models import ContentType -from django.test import Client, TestCase from django.urls import reverse from rest_framework import status @@ -9,7 +8,7 @@ from dcim.forms import SiteCSVForm from dcim.models import Site from extras.choices import * from extras.models import CustomField, CustomFieldValue, CustomFieldChoice -from utilities.testing import APITestCase, create_test_user +from utilities.testing import APITestCase, TestCase from virtualization.models import VirtualMachine @@ -470,17 +469,10 @@ class CustomFieldChoiceAPITest(APITestCase): class CustomFieldImportTest(TestCase): - - def setUp(self): - - user = create_test_user( - permissions=[ - 'dcim.view_site', - 'dcim.add_site', - ] - ) - self.client = Client() - self.client.force_login(user) + user_permissions = ( + 'dcim.view_site', + 'dcim.add_site', + ) @classmethod def setUpTestData(cls): diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 370055b26..6d41886fc 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -10,13 +10,17 @@ from extras.models import ConfigContext, ObjectChange, Tag from utilities.testing import ViewTestCases, TestCase -class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase): +# TODO: Change base class to PrimaryObjectViewTestCase +class TagTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase +): model = Tag - # Disable inapplicable tests - test_create_object = None - test_import_objects = None - @classmethod def setUpTestData(cls): @@ -38,16 +42,16 @@ class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase): } -class ConfigContextTestCase(ViewTestCases.PrimaryObjectViewTestCase): +# TODO: Change base class to PrimaryObjectViewTestCase +class ConfigContextTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase +): model = ConfigContext - # Disable inapplicable tests - test_import_objects = None - - # TODO: Resolve model discrepancies when creating/editing ConfigContexts - test_create_object = None - test_edit_object = None - @classmethod def setUpTestData(cls): diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index a486ce7fc..3eee303a3 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -18,7 +18,7 @@ urlpatterns = [ # Config contexts path('config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'), - path('config-contexts/add/', views.ConfigContextCreateView.as_view(), name='configcontext_add'), + path('config-contexts/add/', views.ConfigContextEditView.as_view(), name='configcontext_add'), path('config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'), path('config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'), path('config-contexts//', views.ConfigContextView.as_view(), name='configcontext'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 6ad4d5e3a..e80aa1d62 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,7 +1,6 @@ from django import template from django.conf import settings from django.contrib import messages -from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.contenttypes.models import ContentType from django.db.models import Count, Q from django.http import Http404, HttpResponseForbidden @@ -13,7 +12,10 @@ from django_tables2 import RequestConfig from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator from utilities.utils import shallow_compare_dict -from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView +from utilities.views import ( + BulkDeleteView, BulkEditView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView, + ObjectPermissionRequiredMixin, +) from . import filters, forms from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem from .reports import get_report, get_reports @@ -25,8 +27,7 @@ from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemT # Tags # -class TagListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'extras.view_tag' +class TagListView(ObjectListView): queryset = Tag.objects.annotate( items=Count('extras_taggeditem_items', distinct=True) ).order_by( @@ -38,12 +39,12 @@ class TagListView(PermissionRequiredMixin, ObjectListView): action_buttons = () -class TagView(PermissionRequiredMixin, View): - permission_required = 'extras.view_tag' +class TagView(ObjectView): + queryset = Tag.objects.all() def get(self, request, slug): - tag = get_object_or_404(Tag, slug=slug) + tag = get_object_or_404(self.queryset, slug=slug) tagged_items = TaggedItem.objects.filter( tag=tag ).prefetch_related( @@ -65,22 +66,19 @@ class TagView(PermissionRequiredMixin, View): }) -class TagEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'extras.change_tag' +class TagEditView(ObjectEditView): queryset = Tag.objects.all() model_form = forms.TagForm default_return_url = 'extras:tag_list' template_name = 'extras/tag_edit.html' -class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'extras.delete_tag' +class TagDeleteView(ObjectDeleteView): queryset = Tag.objects.all() default_return_url = 'extras:tag_list' -class TagBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'extras.change_tag' +class TagBulkEditView(BulkEditView): queryset = Tag.objects.annotate( items=Count('extras_taggeditem_items', distinct=True) ).order_by( @@ -91,8 +89,7 @@ class TagBulkEditView(PermissionRequiredMixin, BulkEditView): default_return_url = 'extras:tag_list' -class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'extras.delete_tag' +class TagBulkDeleteView(BulkDeleteView): queryset = Tag.objects.annotate( items=Count('extras_taggeditem_items') ).order_by( @@ -106,8 +103,7 @@ class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Config contexts # -class ConfigContextListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'extras.view_configcontext' +class ConfigContextListView(ObjectListView): queryset = ConfigContext.objects.all() filterset = filters.ConfigContextFilterSet filterset_form = forms.ConfigContextFilterForm @@ -115,11 +111,11 @@ class ConfigContextListView(PermissionRequiredMixin, ObjectListView): action_buttons = ('add',) -class ConfigContextView(PermissionRequiredMixin, View): - permission_required = 'extras.view_configcontext' +class ConfigContextView(ObjectView): + queryset = ConfigContext.objects.all() def get(self, request, pk): - configcontext = get_object_or_404(ConfigContext, pk=pk) + configcontext = get_object_or_404(self.queryset, pk=pk) # Determine user's preferred output format if request.GET.get('format') in ['json', 'yaml']: @@ -137,20 +133,14 @@ class ConfigContextView(PermissionRequiredMixin, View): }) -class ConfigContextCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'extras.add_configcontext' +class ConfigContextEditView(ObjectEditView): queryset = ConfigContext.objects.all() model_form = forms.ConfigContextForm default_return_url = 'extras:configcontext_list' template_name = 'extras/configcontext_edit.html' -class ConfigContextEditView(ConfigContextCreateView): - permission_required = 'extras.change_configcontext' - - -class ConfigContextBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'extras.change_configcontext' +class ConfigContextBulkEditView(BulkEditView): queryset = ConfigContext.objects.all() filterset = filters.ConfigContextFilterSet table = ConfigContextTable @@ -158,28 +148,25 @@ class ConfigContextBulkEditView(PermissionRequiredMixin, BulkEditView): default_return_url = 'extras:configcontext_list' -class ConfigContextDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'extras.delete_configcontext' +class ConfigContextDeleteView(ObjectDeleteView): queryset = ConfigContext.objects.all() default_return_url = 'extras:configcontext_list' -class ConfigContextBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'extras.delete_configcontext' +class ConfigContextBulkDeleteView(BulkDeleteView): queryset = ConfigContext.objects.all() table = ConfigContextTable default_return_url = 'extras:configcontext_list' -class ObjectConfigContextView(View): - object_class = None +class ObjectConfigContextView(ObjectView): base_template = None def get(self, request, pk): - obj = get_object_or_404(self.object_class, pk=pk) - source_contexts = ConfigContext.objects.get_for_object(obj) - model_name = self.object_class._meta.model_name + obj = get_object_or_404(self.queryset, pk=pk) + source_contexts = ConfigContext.objects.restrict(request.user, 'view').get_for_object(obj) + model_name = self.queryset.model._meta.model_name # Determine user's preferred output format if request.GET.get('format') in ['json', 'yaml']: @@ -206,8 +193,7 @@ class ObjectConfigContextView(View): # Change logging # -class ObjectChangeListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'extras.view_objectchange' +class ObjectChangeListView(ObjectListView): queryset = ObjectChange.objects.prefetch_related('user', 'changed_object_type') filterset = filters.ObjectChangeFilterSet filterset_form = forms.ObjectChangeFilterForm @@ -216,20 +202,24 @@ class ObjectChangeListView(PermissionRequiredMixin, ObjectListView): action_buttons = ('export',) -class ObjectChangeView(PermissionRequiredMixin, View): - permission_required = 'extras.view_objectchange' +class ObjectChangeView(ObjectView): + queryset = ObjectChange.objects.all() def get(self, request, pk): - objectchange = get_object_or_404(ObjectChange, pk=pk) + objectchange = get_object_or_404(self.queryset, pk=pk) - related_changes = ObjectChange.objects.filter(request_id=objectchange.request_id).exclude(pk=objectchange.pk) + related_changes = ObjectChange.objects.restrict(request.user, 'view').filter( + request_id=objectchange.request_id + ).exclude( + pk=objectchange.pk + ) related_changes_table = ObjectChangeTable( data=related_changes[:50], orderable=False ) - objectchanges = ObjectChange.objects.filter( + objectchanges = ObjectChange.objects.restrict(request.user, 'view').filter( changed_object_type=objectchange.changed_object_type, changed_object_id=objectchange.changed_object_id, ) @@ -271,7 +261,7 @@ class ObjectChangeLogView(View): # Gather all changes for this object (and its related objects) content_type = ContentType.objects.get_for_model(model) - objectchanges = ObjectChange.objects.prefetch_related( + objectchanges = ObjectChange.objects.restrict(request.user, 'view').prefetch_related( 'user', 'changed_object_type' ).filter( Q(changed_object_type=content_type, changed_object_id=obj.pk) | @@ -310,8 +300,7 @@ class ObjectChangeLogView(View): # Image attachments # -class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'extras.change_imageattachment' +class ImageAttachmentEditView(ObjectEditView): queryset = ImageAttachment.objects.all() model_form = forms.ImageAttachmentForm @@ -326,8 +315,7 @@ class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView): return imageattachment.parent.get_absolute_url() -class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'extras.delete_imageattachment' +class ImageAttachmentDeleteView(ObjectDeleteView): queryset = ImageAttachment.objects.all() def get_return_url(self, request, imageattachment): @@ -338,11 +326,12 @@ class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView): # Reports # -class ReportListView(PermissionRequiredMixin, View): +class ReportListView(ObjectPermissionRequiredMixin, View): """ Retrieve all of the available reports from disk and the recorded ReportResult (if any) for each. """ - permission_required = 'extras.view_reportresult' + def get_required_permission(self): + return 'extras.view_reportresult' def get(self, request): @@ -362,11 +351,12 @@ class ReportListView(PermissionRequiredMixin, View): }) -class ReportView(PermissionRequiredMixin, View): +class ReportView(ObjectPermissionRequiredMixin, View): """ Display a single Report and its associated ReportResult (if any). """ - permission_required = 'extras.view_reportresult' + def get_required_permission(self): + return 'extras.view_reportresult' def get(self, request, name): @@ -385,11 +375,12 @@ class ReportView(PermissionRequiredMixin, View): }) -class ReportRunView(PermissionRequiredMixin, View): +class ReportRunView(ObjectPermissionRequiredMixin, View): """ Run a Report and record a new ReportResult. """ - permission_required = 'extras.add_reportresult' + def get_required_permission(self): + return 'extras.add_reportresult' def post(self, request, name): @@ -415,8 +406,10 @@ class ReportRunView(PermissionRequiredMixin, View): # Scripts # -class ScriptListView(PermissionRequiredMixin, View): - permission_required = 'extras.view_script' +class ScriptListView(ObjectPermissionRequiredMixin, View): + + def get_required_permission(self): + return 'extras.view_script' def get(self, request): @@ -425,8 +418,10 @@ class ScriptListView(PermissionRequiredMixin, View): }) -class ScriptView(PermissionRequiredMixin, View): - permission_required = 'extras.view_script' +class ScriptView(ObjectPermissionRequiredMixin, View): + + def get_required_permission(self): + return 'extras.view_script' def _get_script(self, module, name): scripts = get_scripts() diff --git a/netbox/ipam/managers.py b/netbox/ipam/managers.py index 8811e504a..1ef00e125 100644 --- a/netbox/ipam/managers.py +++ b/netbox/ipam/managers.py @@ -1,9 +1,10 @@ -from django.db import models +from django.db.models import Manager from ipam.lookups import Host, Inet +from utilities.querysets import RestrictedQuerySet -class IPAddressManager(models.Manager): +class IPAddressManager(Manager.from_queryset(RestrictedQuerySet)): def get_queryset(self): """ @@ -13,5 +14,4 @@ class IPAddressManager(models.Manager): then re-cast this value to INET() so that records will be ordered properly. We are essentially re-casting each IP address as a /32 or /128. """ - qs = super().get_queryset() - return qs.order_by(Inet(Host('address'))) + return super().get_queryset().order_by(Inet(Host('address'))) diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index eeb985b7c..b99a6c919 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -12,6 +12,7 @@ from dcim.models import Device, Interface from extras.models import CustomFieldModel, ObjectChange, TaggedItem from extras.utils import extras_features from utilities.models import ChangeLoggedModel +from utilities.querysets import RestrictedQuerySet from utilities.utils import serialize_object from virtualization.models import VirtualMachine from .choices import * @@ -74,9 +75,10 @@ class VRF(ChangeLoggedModel, CustomFieldModel): content_type_field='obj_type', object_id_field='obj_id' ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] clone_fields = [ 'tenant', 'enforce_unique', 'description', @@ -131,6 +133,8 @@ class RIR(ChangeLoggedModel): blank=True ) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'is_private', 'description'] class Meta: @@ -179,9 +183,10 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel): content_type_field='obj_type', object_id_field='obj_id' ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['prefix', 'rir', 'date_added', 'description'] clone_fields = [ 'rir', 'date_added', 'description', @@ -274,6 +279,8 @@ class Role(ChangeLoggedModel): blank=True, ) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'weight', 'description'] class Meta: @@ -360,9 +367,9 @@ class Prefix(ChangeLoggedModel, CustomFieldModel): content_type_field='obj_type', object_id_field='obj_id' ) + tags = TaggableManager(through=TaggedItem) objects = PrefixQuerySet.as_manager() - tags = TaggableManager(through=TaggedItem) csv_headers = [ 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'description', @@ -631,9 +638,9 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): content_type_field='obj_type', object_id_field='obj_id' ) + tags = TaggableManager(through=TaggedItem) objects = IPAddressManager() - tags = TaggableManager(through=TaggedItem) csv_headers = [ 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary', @@ -828,6 +835,8 @@ class VLANGroup(ChangeLoggedModel): blank=True ) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'site', 'description'] class Meta: @@ -923,9 +932,10 @@ class VLAN(ChangeLoggedModel, CustomFieldModel): content_type_field='obj_type', object_id_field='obj_id' ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description'] clone_fields = [ 'site', 'group', 'tenant', 'status', 'role', 'description', @@ -1039,9 +1049,10 @@ class Service(ChangeLoggedModel, CustomFieldModel): content_type_field='obj_type', object_id_field='obj_id' ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'port', 'description'] class Meta: diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py index 3a48be789..6d2dc6f33 100644 --- a/netbox/ipam/querysets.py +++ b/netbox/ipam/querysets.py @@ -1,7 +1,7 @@ -from django.db.models import QuerySet +from utilities.querysets import RestrictedQuerySet -class PrefixQuerySet(QuerySet): +class PrefixQuerySet(RestrictedQuerySet): def annotate_depth(self, limit=None): """ diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 8867a6b43..794284dba 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -333,12 +333,18 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): } -class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): +# TODO: Update base class to PrimaryObjectViewTestCase +class ServiceTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkImportObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase +): model = Service - # TODO: Resolve URL for Service creation - test_create_object = None - @classmethod def setUpTestData(cls): diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index f1211473e..de8fc86eb 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -9,7 +9,7 @@ urlpatterns = [ # VRFs path('vrfs/', views.VRFListView.as_view(), name='vrf_list'), - path('vrfs/add/', views.VRFCreateView.as_view(), name='vrf_add'), + path('vrfs/add/', views.VRFEditView.as_view(), name='vrf_add'), path('vrfs/import/', views.VRFBulkImportView.as_view(), name='vrf_import'), path('vrfs/edit/', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'), path('vrfs/delete/', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'), @@ -20,7 +20,7 @@ urlpatterns = [ # RIRs path('rirs/', views.RIRListView.as_view(), name='rir_list'), - path('rirs/add/', views.RIRCreateView.as_view(), name='rir_add'), + path('rirs/add/', views.RIREditView.as_view(), name='rir_add'), path('rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'), path('rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'), path('rirs//edit/', views.RIREditView.as_view(), name='rir_edit'), @@ -28,7 +28,7 @@ urlpatterns = [ # Aggregates path('aggregates/', views.AggregateListView.as_view(), name='aggregate_list'), - path('aggregates/add/', views.AggregateCreateView.as_view(), name='aggregate_add'), + path('aggregates/add/', views.AggregateEditView.as_view(), name='aggregate_add'), path('aggregates/import/', views.AggregateBulkImportView.as_view(), name='aggregate_import'), path('aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'), path('aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'), @@ -39,7 +39,7 @@ urlpatterns = [ # Roles path('roles/', views.RoleListView.as_view(), name='role_list'), - path('roles/add/', views.RoleCreateView.as_view(), name='role_add'), + path('roles/add/', views.RoleEditView.as_view(), name='role_add'), path('roles/import/', views.RoleBulkImportView.as_view(), name='role_import'), path('roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'), path('roles//edit/', views.RoleEditView.as_view(), name='role_edit'), @@ -47,7 +47,7 @@ urlpatterns = [ # Prefixes path('prefixes/', views.PrefixListView.as_view(), name='prefix_list'), - path('prefixes/add/', views.PrefixCreateView.as_view(), name='prefix_add'), + path('prefixes/add/', views.PrefixEditView.as_view(), name='prefix_add'), path('prefixes/import/', views.PrefixBulkImportView.as_view(), name='prefix_import'), path('prefixes/edit/', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'), path('prefixes/delete/', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'), @@ -60,7 +60,7 @@ urlpatterns = [ # IP addresses path('ip-addresses/', views.IPAddressListView.as_view(), name='ipaddress_list'), - path('ip-addresses/add/', views.IPAddressCreateView.as_view(), name='ipaddress_add'), + path('ip-addresses/add/', views.IPAddressEditView.as_view(), name='ipaddress_add'), path('ip-addresses/bulk-add/', views.IPAddressBulkCreateView.as_view(), name='ipaddress_bulk_add'), path('ip-addresses/import/', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'), path('ip-addresses/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'), @@ -73,7 +73,7 @@ urlpatterns = [ # VLAN groups path('vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'), - path('vlan-groups/add/', views.VLANGroupCreateView.as_view(), name='vlangroup_add'), + path('vlan-groups/add/', views.VLANGroupEditView.as_view(), name='vlangroup_add'), path('vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'), path('vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'), path('vlan-groups//edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), @@ -82,7 +82,7 @@ urlpatterns = [ # VLANs path('vlans/', views.VLANListView.as_view(), name='vlan_list'), - path('vlans/add/', views.VLANCreateView.as_view(), name='vlan_add'), + path('vlans/add/', views.VLANEditView.as_view(), name='vlan_add'), path('vlans/import/', views.VLANBulkImportView.as_view(), name='vlan_import'), path('vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'), path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 92eb5b823..98fe1d73d 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1,16 +1,15 @@ import netaddr from django.conf import settings -from django.contrib.auth.mixins import PermissionRequiredMixin from django.db.models import Count, Q from django.db.models.expressions import RawSQL from django.shortcuts import get_object_or_404, redirect, render -from django.views.generic import View from django_tables2 import RequestConfig from dcim.models import Device, Interface from utilities.paginator import EnhancedPaginator from utilities.views import ( - BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, + BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, + ObjectListView, ) from virtualization.models import VirtualMachine from . import filters, forms, tables @@ -112,21 +111,20 @@ def add_available_vlans(vlan_group, vlans): # VRFs # -class VRFListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'ipam.view_vrf' +class VRFListView(ObjectListView): queryset = VRF.objects.prefetch_related('tenant') filterset = filters.VRFFilterSet filterset_form = forms.VRFFilterForm table = tables.VRFTable -class VRFView(PermissionRequiredMixin, View): - permission_required = 'ipam.view_vrf' +class VRFView(ObjectView): + queryset = VRF.objects.all() def get(self, request, pk): - vrf = get_object_or_404(VRF.objects.all(), pk=pk) - prefix_count = Prefix.objects.filter(vrf=vrf).count() + vrf = get_object_or_404(self.queryset, pk=pk) + prefix_count = Prefix.objects.restrict(request.user, 'view').filter(vrf=vrf).count() return render(request, 'ipam/vrf.html', { 'vrf': vrf, @@ -134,33 +132,26 @@ class VRFView(PermissionRequiredMixin, View): }) -class VRFCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'ipam.add_vrf' +class VRFEditView(ObjectEditView): queryset = VRF.objects.all() model_form = forms.VRFForm template_name = 'ipam/vrf_edit.html' default_return_url = 'ipam:vrf_list' -class VRFEditView(VRFCreateView): - permission_required = 'ipam.change_vrf' - - -class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'ipam.delete_vrf' +class VRFDeleteView(ObjectDeleteView): queryset = VRF.objects.all() default_return_url = 'ipam:vrf_list' -class VRFBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'ipam.add_vrf' +class VRFBulkImportView(BulkImportView): + queryset = VRF.objects.all() model_form = forms.VRFCSVForm table = tables.VRFTable default_return_url = 'ipam:vrf_list' -class VRFBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'ipam.change_vrf' +class VRFBulkEditView(BulkEditView): queryset = VRF.objects.prefetch_related('tenant') filterset = filters.VRFFilterSet table = tables.VRFTable @@ -168,8 +159,7 @@ class VRFBulkEditView(PermissionRequiredMixin, BulkEditView): default_return_url = 'ipam:vrf_list' -class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'ipam.delete_vrf' +class VRFBulkDeleteView(BulkDeleteView): queryset = VRF.objects.prefetch_related('tenant') filterset = filters.VRFFilterSet table = tables.VRFTable @@ -180,8 +170,7 @@ class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # RIRs # -class RIRListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'ipam.view_rir' +class RIRListView(ObjectListView): queryset = RIR.objects.annotate(aggregate_count=Count('aggregates')) filterset = filters.RIRFilterSet filterset_form = forms.RIRFilterForm @@ -257,26 +246,20 @@ class RIRListView(PermissionRequiredMixin, ObjectListView): return rirs -class RIRCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'ipam.add_rir' +class RIREditView(ObjectEditView): queryset = RIR.objects.all() model_form = forms.RIRForm default_return_url = 'ipam:rir_list' -class RIREditView(RIRCreateView): - permission_required = 'ipam.change_rir' - - -class RIRBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'ipam.add_rir' +class RIRBulkImportView(BulkImportView): + queryset = RIR.objects.all() model_form = forms.RIRCSVForm table = tables.RIRTable default_return_url = 'ipam:rir_list' -class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'ipam.delete_rir' +class RIRBulkDeleteView(BulkDeleteView): queryset = RIR.objects.annotate(aggregate_count=Count('aggregates')) filterset = filters.RIRFilterSet table = tables.RIRTable @@ -287,8 +270,7 @@ class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Aggregates # -class AggregateListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'ipam.view_aggregate' +class AggregateListView(ObjectListView): queryset = Aggregate.objects.prefetch_related('rir').annotate( child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ()) ) @@ -314,15 +296,15 @@ class AggregateListView(PermissionRequiredMixin, ObjectListView): } -class AggregateView(PermissionRequiredMixin, View): - permission_required = 'ipam.view_aggregate' +class AggregateView(ObjectView): + queryset = Aggregate.objects.all() def get(self, request, pk): - aggregate = get_object_or_404(Aggregate, pk=pk) + aggregate = get_object_or_404(self.queryset, pk=pk) # Find all child prefixes contained by this aggregate - child_prefixes = Prefix.objects.filter( + child_prefixes = Prefix.objects.restrict(request.user, 'view').filter( prefix__net_contained_or_equal=str(aggregate.prefix) ).prefetch_related( 'site', 'role' @@ -359,33 +341,26 @@ class AggregateView(PermissionRequiredMixin, View): }) -class AggregateCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'ipam.add_aggregate' +class AggregateEditView(ObjectEditView): queryset = Aggregate.objects.all() model_form = forms.AggregateForm template_name = 'ipam/aggregate_edit.html' default_return_url = 'ipam:aggregate_list' -class AggregateEditView(AggregateCreateView): - permission_required = 'ipam.change_aggregate' - - -class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'ipam.delete_aggregate' +class AggregateDeleteView(ObjectDeleteView): queryset = Aggregate.objects.all() default_return_url = 'ipam:aggregate_list' -class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'ipam.add_aggregate' +class AggregateBulkImportView(BulkImportView): + queryset = Aggregate.objects.all() model_form = forms.AggregateCSVForm table = tables.AggregateTable default_return_url = 'ipam:aggregate_list' -class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'ipam.change_aggregate' +class AggregateBulkEditView(BulkEditView): queryset = Aggregate.objects.prefetch_related('rir') filterset = filters.AggregateFilterSet table = tables.AggregateTable @@ -393,8 +368,7 @@ class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView): default_return_url = 'ipam:aggregate_list' -class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'ipam.delete_aggregate' +class AggregateBulkDeleteView(BulkDeleteView): queryset = Aggregate.objects.prefetch_related('rir') filterset = filters.AggregateFilterSet table = tables.AggregateTable @@ -405,32 +379,25 @@ class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Prefix/VLAN roles # -class RoleListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'ipam.view_role' +class RoleListView(ObjectListView): queryset = Role.objects.all() table = tables.RoleTable -class RoleCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'ipam.add_role' +class RoleEditView(ObjectEditView): queryset = Role.objects.all() model_form = forms.RoleForm default_return_url = 'ipam:role_list' -class RoleEditView(RoleCreateView): - permission_required = 'ipam.change_role' - - -class RoleBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'ipam.add_role' +class RoleBulkImportView(BulkImportView): + queryset = Role.objects.all() model_form = forms.RoleCSVForm table = tables.RoleTable default_return_url = 'ipam:role_list' -class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'ipam.delete_role' +class RoleBulkDeleteView(BulkDeleteView): queryset = Role.objects.all() table = tables.RoleTable default_return_url = 'ipam:role_list' @@ -440,8 +407,7 @@ class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Prefixes # -class PrefixListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'ipam.view_prefix' +class PrefixListView(ObjectListView): queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') filterset = filters.PrefixFilterSet filterset_form = forms.PrefixFilterForm @@ -454,22 +420,22 @@ class PrefixListView(PermissionRequiredMixin, ObjectListView): return self.queryset.annotate_depth(limit=limit) -class PrefixView(PermissionRequiredMixin, View): - permission_required = 'ipam.view_prefix' +class PrefixView(ObjectView): + queryset = Prefix.objects.prefetch_related('vrf', 'site__region', 'tenant__group', 'vlan__group', 'role') def get(self, request, pk): - prefix = get_object_or_404(Prefix.objects.prefetch_related( - 'vrf', 'site__region', 'tenant__group', 'vlan__group', 'role' - ), pk=pk) + prefix = get_object_or_404(self.queryset, pk=pk) try: - aggregate = Aggregate.objects.get(prefix__net_contains_or_equals=str(prefix.prefix)) + aggregate = Aggregate.objects.restrict(request.user, 'view').get( + prefix__net_contains_or_equals=str(prefix.prefix) + ) except Aggregate.DoesNotExist: aggregate = None # Parent prefixes table - parent_prefixes = Prefix.objects.filter( + parent_prefixes = Prefix.objects.restrict(request.user, 'view').filter( Q(vrf=prefix.vrf) | Q(vrf__isnull=True) ).filter( prefix__net_contains=str(prefix.prefix) @@ -480,7 +446,7 @@ class PrefixView(PermissionRequiredMixin, View): parent_prefix_table.exclude = ('vrf',) # Duplicate prefixes table - duplicate_prefixes = Prefix.objects.filter( + duplicate_prefixes = Prefix.objects.restrict(request.user, 'view').filter( vrf=prefix.vrf, prefix=str(prefix.prefix) ).exclude( pk=prefix.pk @@ -498,15 +464,15 @@ class PrefixView(PermissionRequiredMixin, View): }) -class PrefixPrefixesView(PermissionRequiredMixin, View): - permission_required = 'ipam.view_prefix' +class PrefixPrefixesView(ObjectView): + queryset = Prefix.objects.all() def get(self, request, pk): - prefix = get_object_or_404(Prefix.objects.all(), pk=pk) + prefix = get_object_or_404(self.queryset, pk=pk) # Child prefixes table - child_prefixes = prefix.get_child_prefixes().prefetch_related( + child_prefixes = prefix.get_child_prefixes().restrict(request.user, 'view').prefetch_related( 'site', 'vlan', 'role', ).annotate_depth(limit=0) @@ -542,15 +508,15 @@ class PrefixPrefixesView(PermissionRequiredMixin, View): }) -class PrefixIPAddressesView(PermissionRequiredMixin, View): - permission_required = 'ipam.view_prefix' +class PrefixIPAddressesView(ObjectView): + queryset = Prefix.objects.all() def get(self, request, pk): - prefix = get_object_or_404(Prefix.objects.all(), pk=pk) + prefix = get_object_or_404(self.queryset, pk=pk) # Find all IPAddresses belonging to this Prefix - ipaddresses = prefix.get_child_ips().prefetch_related( + ipaddresses = prefix.get_child_ips().restrict(request.user, 'view').prefetch_related( 'vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for' ) @@ -586,34 +552,27 @@ class PrefixIPAddressesView(PermissionRequiredMixin, View): }) -class PrefixCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'ipam.add_prefix' +class PrefixEditView(ObjectEditView): queryset = Prefix.objects.all() model_form = forms.PrefixForm template_name = 'ipam/prefix_edit.html' default_return_url = 'ipam:prefix_list' -class PrefixEditView(PrefixCreateView): - permission_required = 'ipam.change_prefix' - - -class PrefixDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'ipam.delete_prefix' +class PrefixDeleteView(ObjectDeleteView): queryset = Prefix.objects.all() template_name = 'ipam/prefix_delete.html' default_return_url = 'ipam:prefix_list' -class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'ipam.add_prefix' +class PrefixBulkImportView(BulkImportView): + queryset = Prefix.objects.all() model_form = forms.PrefixCSVForm table = tables.PrefixTable default_return_url = 'ipam:prefix_list' -class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'ipam.change_prefix' +class PrefixBulkEditView(BulkEditView): queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') filterset = filters.PrefixFilterSet table = tables.PrefixTable @@ -621,8 +580,7 @@ class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView): default_return_url = 'ipam:prefix_list' -class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'ipam.delete_prefix' +class PrefixBulkDeleteView(BulkDeleteView): queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') filterset = filters.PrefixFilterSet table = tables.PrefixTable @@ -633,8 +591,7 @@ class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # IP addresses # -class IPAddressListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'ipam.view_ipaddress' +class IPAddressListView(ObjectListView): queryset = IPAddress.objects.prefetch_related( 'vrf__tenant', 'tenant', 'nat_inside', 'interface__device', 'interface__virtual_machine' ) @@ -643,15 +600,15 @@ class IPAddressListView(PermissionRequiredMixin, ObjectListView): table = tables.IPAddressDetailTable -class IPAddressView(PermissionRequiredMixin, View): - permission_required = 'ipam.view_ipaddress' +class IPAddressView(ObjectView): + queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant') def get(self, request, pk): - ipaddress = get_object_or_404(IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'), pk=pk) + ipaddress = get_object_or_404(self.queryset, pk=pk) # Parent prefixes table - parent_prefixes = Prefix.objects.filter( + parent_prefixes = Prefix.objects.restrict(request.user, 'view').filter( vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip) ).prefetch_related( 'site', 'role' @@ -660,7 +617,7 @@ class IPAddressView(PermissionRequiredMixin, View): parent_prefixes_table.exclude = ('vrf',) # Duplicate IPs table - duplicate_ips = IPAddress.objects.filter( + duplicate_ips = IPAddress.objects.restrict(request.user, 'view').filter( vrf=ipaddress.vrf, address=str(ipaddress.address) ).exclude( pk=ipaddress.pk @@ -673,14 +630,13 @@ class IPAddressView(PermissionRequiredMixin, View): duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False) # Related IP table - related_ips = IPAddress.objects.prefetch_related( + related_ips = IPAddress.objects.restrict(request.user, 'view').prefetch_related( 'interface__device' ).exclude( address=str(ipaddress.address) ).filter( vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address) ) - related_ips_table = tables.IPAddressTable(related_ips, orderable=False) paginate = { @@ -697,8 +653,7 @@ class IPAddressView(PermissionRequiredMixin, View): }) -class IPAddressCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'ipam.add_ipaddress' +class IPAddressEditView(ObjectEditView): queryset = IPAddress.objects.all() model_form = forms.IPAddressForm template_name = 'ipam/ipaddress_edit.html' @@ -716,15 +671,11 @@ class IPAddressCreateView(PermissionRequiredMixin, ObjectEditView): return obj -class IPAddressEditView(IPAddressCreateView): - permission_required = 'ipam.change_ipaddress' - - -class IPAddressAssignView(PermissionRequiredMixin, View): +class IPAddressAssignView(ObjectView): """ Search for IPAddresses to be assigned to an Interface. """ - permission_required = 'ipam.change_ipaddress' + queryset = IPAddress.objects.all() def dispatch(self, request, *args, **kwargs): @@ -735,7 +686,6 @@ class IPAddressAssignView(PermissionRequiredMixin, View): return super().dispatch(request, *args, **kwargs) def get(self, request): - form = forms.IPAddressAssignForm() return render(request, 'ipam/ipaddress_assign.html', { @@ -744,13 +694,12 @@ class IPAddressAssignView(PermissionRequiredMixin, View): }) def post(self, request): - form = forms.IPAddressAssignForm(request.POST) table = None if form.is_valid(): - addresses = IPAddress.objects.prefetch_related( + addresses = self.queryset.prefetch_related( 'vrf', 'tenant', 'interface__device', 'interface__virtual_machine' ) # Limit to 100 results @@ -764,14 +713,12 @@ class IPAddressAssignView(PermissionRequiredMixin, View): }) -class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'ipam.delete_ipaddress' +class IPAddressDeleteView(ObjectDeleteView): queryset = IPAddress.objects.all() default_return_url = 'ipam:ipaddress_list' -class IPAddressBulkCreateView(PermissionRequiredMixin, BulkCreateView): - permission_required = 'ipam.add_ipaddress' +class IPAddressBulkCreateView(BulkCreateView): form = forms.IPAddressBulkCreateForm model_form = forms.IPAddressBulkAddForm pattern_target = 'address' @@ -779,15 +726,14 @@ class IPAddressBulkCreateView(PermissionRequiredMixin, BulkCreateView): default_return_url = 'ipam:ipaddress_list' -class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'ipam.add_ipaddress' +class IPAddressBulkImportView(BulkImportView): + queryset = IPAddress.objects.all() model_form = forms.IPAddressCSVForm table = tables.IPAddressTable default_return_url = 'ipam:ipaddress_list' -class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'ipam.change_ipaddress' +class IPAddressBulkEditView(BulkEditView): queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant').prefetch_related('interface__device') filterset = filters.IPAddressFilterSet table = tables.IPAddressTable @@ -795,8 +741,7 @@ class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView): default_return_url = 'ipam:ipaddress_list' -class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'ipam.delete_ipaddress' +class IPAddressBulkDeleteView(BulkDeleteView): queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant').prefetch_related('interface__device') filterset = filters.IPAddressFilterSet table = tables.IPAddressTable @@ -807,48 +752,40 @@ class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # VLAN groups # -class VLANGroupListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'ipam.view_vlangroup' +class VLANGroupListView(ObjectListView): queryset = VLANGroup.objects.prefetch_related('site').annotate(vlan_count=Count('vlans')) filterset = filters.VLANGroupFilterSet filterset_form = forms.VLANGroupFilterForm table = tables.VLANGroupTable -class VLANGroupCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'ipam.add_vlangroup' +class VLANGroupEditView(ObjectEditView): queryset = VLANGroup.objects.all() model_form = forms.VLANGroupForm default_return_url = 'ipam:vlangroup_list' -class VLANGroupEditView(VLANGroupCreateView): - permission_required = 'ipam.change_vlangroup' - - -class VLANGroupBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'ipam.add_vlangroup' +class VLANGroupBulkImportView(BulkImportView): + queryset = VLANGroup.objects.all() model_form = forms.VLANGroupCSVForm table = tables.VLANGroupTable default_return_url = 'ipam:vlangroup_list' -class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'ipam.delete_vlangroup' +class VLANGroupBulkDeleteView(BulkDeleteView): queryset = VLANGroup.objects.prefetch_related('site').annotate(vlan_count=Count('vlans')) filterset = filters.VLANGroupFilterSet table = tables.VLANGroupTable default_return_url = 'ipam:vlangroup_list' -class VLANGroupVLANsView(PermissionRequiredMixin, View): - permission_required = 'ipam.view_vlangroup' +class VLANGroupVLANsView(ObjectView): + queryset = VLANGroup.objects.all() def get(self, request, pk): + vlan_group = get_object_or_404(self.queryset, pk=pk) - vlan_group = get_object_or_404(VLANGroup.objects.all(), pk=pk) - - vlans = VLAN.objects.filter(group_id=pk) + vlans = VLAN.objects.restrict(request.user, 'view').filter(group_id=pk) vlans = add_available_vlans(vlan_group, vlans) vlan_table = tables.VLANDetailTable(vlans) @@ -882,23 +819,22 @@ class VLANGroupVLANsView(PermissionRequiredMixin, View): # VLANs # -class VLANListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'ipam.view_vlan' +class VLANListView(ObjectListView): queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role').prefetch_related('prefixes') filterset = filters.VLANFilterSet filterset_form = forms.VLANFilterForm table = tables.VLANDetailTable -class VLANView(PermissionRequiredMixin, View): - permission_required = 'ipam.view_vlan' +class VLANView(ObjectView): + queryset = VLAN.objects.prefetch_related('site__region', 'tenant__group', 'role') def get(self, request, pk): - vlan = get_object_or_404(VLAN.objects.prefetch_related( - 'site__region', 'tenant__group', 'role' - ), pk=pk) - prefixes = Prefix.objects.filter(vlan=vlan).prefetch_related('vrf', 'site', 'role') + vlan = get_object_or_404(self.queryset, pk=pk) + prefixes = Prefix.objects.restrict(request.user, 'view').filter(vlan=vlan).prefetch_related( + 'vrf', 'site', 'role' + ) prefix_table = tables.PrefixTable(list(prefixes), orderable=False) prefix_table.exclude = ('vlan',) @@ -908,13 +844,13 @@ class VLANView(PermissionRequiredMixin, View): }) -class VLANMembersView(PermissionRequiredMixin, View): - permission_required = 'ipam.view_vlan' +class VLANMembersView(ObjectView): + queryset = VLAN.objects.all() def get(self, request, pk): - vlan = get_object_or_404(VLAN.objects.all(), pk=pk) - members = vlan.get_members().prefetch_related('device', 'virtual_machine') + vlan = get_object_or_404(self.queryset, pk=pk) + members = vlan.get_members().restrict(request.user, 'view').prefetch_related('device', 'virtual_machine') members_table = tables.VLANMemberTable(members) @@ -931,33 +867,26 @@ class VLANMembersView(PermissionRequiredMixin, View): }) -class VLANCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'ipam.add_vlan' +class VLANEditView(ObjectEditView): queryset = VLAN.objects.all() model_form = forms.VLANForm template_name = 'ipam/vlan_edit.html' default_return_url = 'ipam:vlan_list' -class VLANEditView(VLANCreateView): - permission_required = 'ipam.change_vlan' - - -class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'ipam.delete_vlan' +class VLANDeleteView(ObjectDeleteView): queryset = VLAN.objects.all() default_return_url = 'ipam:vlan_list' -class VLANBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'ipam.add_vlan' +class VLANBulkImportView(BulkImportView): + queryset = VLAN.objects.all() model_form = forms.VLANCSVForm table = tables.VLANTable default_return_url = 'ipam:vlan_list' -class VLANBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'ipam.change_vlan' +class VLANBulkEditView(BulkEditView): queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role') filterset = filters.VLANFilterSet table = tables.VLANTable @@ -965,8 +894,7 @@ class VLANBulkEditView(PermissionRequiredMixin, BulkEditView): default_return_url = 'ipam:vlan_list' -class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'ipam.delete_vlan' +class VLANBulkDeleteView(BulkDeleteView): queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role') filterset = filters.VLANFilterSet table = tables.VLANTable @@ -977,8 +905,7 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Services # -class ServiceListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'ipam.view_service' +class ServiceListView(ObjectListView): queryset = Service.objects.prefetch_related('device', 'virtual_machine') filterset = filters.ServiceFilterSet filterset_form = forms.ServiceFilterForm @@ -986,20 +913,19 @@ class ServiceListView(PermissionRequiredMixin, ObjectListView): action_buttons = ('export',) -class ServiceView(PermissionRequiredMixin, View): - permission_required = 'ipam.view_service' +class ServiceView(ObjectView): + queryset = Service.objects.all() def get(self, request, pk): - service = get_object_or_404(Service, pk=pk) + service = get_object_or_404(self.queryset, pk=pk) return render(request, 'ipam/service.html', { 'service': service, }) -class ServiceCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'ipam.add_service' +class ServiceEditView(ObjectEditView): queryset = Service.objects.all() model_form = forms.ServiceForm template_name = 'ipam/service_edit.html' @@ -1015,24 +941,18 @@ class ServiceCreateView(PermissionRequiredMixin, ObjectEditView): return service.parent.get_absolute_url() -class ServiceBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'ipam.add_service' +class ServiceBulkImportView(BulkImportView): + queryset = Service.objects.all() model_form = forms.ServiceCSVForm table = tables.ServiceTable default_return_url = 'ipam:service_list' -class ServiceEditView(ServiceCreateView): - permission_required = 'ipam.change_service' - - -class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'ipam.delete_service' +class ServiceDeleteView(ObjectDeleteView): queryset = Service.objects.all() -class ServiceBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'ipam.change_service' +class ServiceBulkEditView(BulkEditView): queryset = Service.objects.prefetch_related('device', 'virtual_machine') filterset = filters.ServiceFilterSet table = tables.ServiceTable @@ -1040,8 +960,7 @@ class ServiceBulkEditView(PermissionRequiredMixin, BulkEditView): default_return_url = 'ipam:service_list' -class ServiceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'ipam.delete_service' +class ServiceBulkDeleteView(BulkDeleteView): queryset = Service.objects.prefetch_related('device', 'virtual_machine') filterset = filters.ServiceFilterSet table = tables.ServiceTable diff --git a/netbox/netbox/api.py b/netbox/netbox/api.py index 0e04719f9..a67a5d60a 100644 --- a/netbox/netbox/api.py +++ b/netbox/netbox/api.py @@ -2,7 +2,7 @@ from django.conf import settings from django.db.models import QuerySet from rest_framework import authentication, exceptions from rest_framework.pagination import LimitOffsetPagination -from rest_framework.permissions import DjangoModelPermissions, SAFE_METHODS +from rest_framework.permissions import DjangoObjectPermissions, SAFE_METHODS from rest_framework.renderers import BrowsableAPIRenderer from rest_framework.utils import formatting @@ -51,7 +51,7 @@ class TokenAuthentication(authentication.TokenAuthentication): return token.user, token -class TokenPermissions(DjangoModelPermissions): +class TokenPermissions(DjangoObjectPermissions): """ Custom permissions handler which extends the built-in DjangoModelPermissions to validate a Token's write ability for unsafe requests (POST/PUT/PATCH/DELETE). @@ -74,15 +74,29 @@ class TokenPermissions(DjangoModelPermissions): super().__init__() + def _verify_write_permission(self, request): + # If token authentication is in use, verify that the token allows write operations (for unsafe methods). + if request.method in SAFE_METHODS: + return True + if isinstance(request.auth, Token) and request.auth.write_enabled: + return True + def has_permission(self, request, view): - # If token authentication is in use, verify that the token allows write operations (for unsafe methods). - if request.method not in SAFE_METHODS and isinstance(request.auth, Token): - if not request.auth.write_enabled: - return False + # Enforce Token write ability + if not self._verify_write_permission(request): + return False return super().has_permission(request, view) + def has_object_permission(self, request, view, obj): + + # Enforce Token write ability + if not self._verify_write_permission(request): + return False + + return super().has_object_permission(request, view, obj) + # # Pagination diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py new file mode 100644 index 000000000..02b0be0f3 --- /dev/null +++ b/netbox/netbox/authentication.py @@ -0,0 +1,134 @@ +import logging + +from django.conf import settings +from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _RemoteUserBackend +from django.contrib.auth.models import Group +from django.db.models import Q + +from users.models import ObjectPermission +from utilities.permissions import permission_is_exempt, resolve_permission, resolve_permission_ct + + +class ObjectPermissionBackend(ModelBackend): + + def get_all_permissions(self, user_obj, obj=None): + if not user_obj.is_active or user_obj.is_anonymous: + return dict() + if not hasattr(user_obj, '_object_perm_cache'): + user_obj._object_perm_cache = self.get_object_permissions(user_obj) + return user_obj._object_perm_cache + + def get_object_permissions(self, user_obj): + """ + Return all permissions granted to the user by an ObjectPermission. + """ + # Retrieve all assigned ObjectPermissions + object_permissions = ObjectPermission.objects.filter( + Q(users=user_obj) | + Q(groups__user=user_obj) + ).prefetch_related('object_types') + + # Create a dictionary mapping permissions to their constraints + perms = dict() + for obj_perm in object_permissions: + for object_type in obj_perm.object_types.all(): + for action in obj_perm.actions: + perm_name = f"{object_type.app_label}.{action}_{object_type.model}" + if perm_name in perms: + perms[perm_name].append(obj_perm.constraints) + else: + perms[perm_name] = [obj_perm.constraints] + + return perms + + def has_perm(self, user_obj, perm, obj=None): + app_label, action, model_name = resolve_permission(perm) + + # Superusers implicitly have all permissions + if user_obj.is_active and user_obj.is_superuser: + return True + + # Permission is exempt from enforcement (i.e. listed in EXEMPT_VIEW_PERMISSIONS) + if permission_is_exempt(perm): + return True + + # Handle inactive/anonymous users + if not user_obj.is_active or user_obj.is_anonymous: + return False + + # If no applicable ObjectPermissions have been created for this user/permission, deny permission + if perm not in self.get_all_permissions(user_obj): + return False + + # If no object has been specified, grant permission. (The presence of a permission in this set tells + # us that the user has permission for *some* objects, but not necessarily a specific object.) + if obj is None: + return True + + # Sanity check: Ensure that the requested permission applies to the specified object + model = obj._meta.model + if model._meta.label_lower != '.'.join((app_label, model_name)): + raise ValueError(f"Invalid permission {perm} for model {model}") + + # Compile a query filter that matches all instances of the specified model + obj_perm_constraints = self.get_all_permissions(user_obj)[perm] + constraints = Q() + for perm_constraints in obj_perm_constraints: + if perm_constraints: + constraints |= Q(**perm_constraints) + else: + # Found ObjectPermission with null constraints; allow model-level access + constraints = Q() + break + + # Permission to perform the requested action on the object depends on whether the specified object matches + # the specified constraints. Note that this check is made against the *database* record representing the object, + # not the instance itself. + return model.objects.filter(constraints, pk=obj.pk).exists() + + +class RemoteUserBackend(_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(f"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 object permissions to the user + permissions_list = [] + for permission_name, constraints in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS.items(): + try: + object_type, action = resolve_permission_ct(permission_name) + # TODO: Merge multiple actions into a single ObjectPermission per content type + obj_perm = ObjectPermission(actions=[action], constraints=constraints) + obj_perm.save() + obj_perm.users.add(user) + obj_perm.object_types.add(object_type) + permissions_list.append(permission_name) + except ValueError: + logging.error( + f"Invalid permission name: '{permission_name}'. Permissions must be in the form " + "._. (Example: dcim.add_site)" + ) + if permissions_list: + logger.debug(f"Assigned permissions to remotely-authenticated user {user}: {permissions_list}") + + return user + + def has_perm(self, user_obj, perm, obj=None): + return False diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 941cbcd88..7b39fb19e 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -205,11 +205,11 @@ PREFER_IPV4 = False # Remote authentication support REMOTE_AUTH_ENABLED = False -REMOTE_AUTH_BACKEND = 'utilities.auth_backends.RemoteUserBackend' +REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend' REMOTE_AUTH_HEADER = 'HTTP_REMOTE_USER' REMOTE_AUTH_AUTO_CREATE_USER = True REMOTE_AUTH_DEFAULT_GROUPS = [] -REMOTE_AUTH_DEFAULT_PERMISSIONS = [] +REMOTE_AUTH_DEFAULT_PERMISSIONS = {} # This determines how often the GitHub API is called to check the latest release of NetBox. Must be at least 1 hour. RELEASE_CHECK_TIMEOUT = 24 * 3600 diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 1b1d527c7..692382262 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -97,9 +97,9 @@ PLUGINS = getattr(configuration, 'PLUGINS', []) PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {}) PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False) REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False) -REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'utilities.auth_backends.RemoteUserBackend') +REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend') REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', []) -REMOTE_AUTH_DEFAULT_PERMISSIONS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_PERMISSIONS', []) +REMOTE_AUTH_DEFAULT_PERMISSIONS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_PERMISSIONS', {}) REMOTE_AUTH_ENABLED = getattr(configuration, 'REMOTE_AUTH_ENABLED', False) REMOTE_AUTH_HEADER = getattr(configuration, 'REMOTE_AUTH_HEADER', 'HTTP_REMOTE_USER') RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None) @@ -127,6 +127,17 @@ if RELEASE_CHECK_URL: if RELEASE_CHECK_TIMEOUT < 3600: raise ImproperlyConfigured("RELEASE_CHECK_TIMEOUT has to be at least 3600 seconds (1 hour)") +# TODO: Remove in v2.10 +# Backward compatibility for REMOTE_AUTH_DEFAULT_PERMISSIONS +if type(REMOTE_AUTH_DEFAULT_PERMISSIONS) is not dict: + try: + REMOTE_AUTH_DEFAULT_PERMISSIONS = {perm: None for perm in REMOTE_AUTH_DEFAULT_PERMISSIONS} + warnings.warn( + "REMOTE_AUTH_DEFAULT_PERMISSIONS should be a dictionary. Backward compatibility will be removed in v2.10." + ) + except TypeError: + raise ImproperlyConfigured("REMOTE_AUTH_DEFAULT_PERMISSIONS must be a dictionary.") + # # Database @@ -339,7 +350,7 @@ TEMPLATES = [ # Set up authentication backends AUTHENTICATION_BACKENDS = [ REMOTE_AUTH_BACKEND, - 'utilities.auth_backends.ViewExemptModelBackend', + 'netbox.authentication.ObjectPermissionBackend', ] # Internationalization diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 42cddb082..0e9bea90d 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -1,8 +1,17 @@ from django.conf import settings from django.contrib.auth.models import Group, User -from django.test import Client, TestCase +from django.contrib.contenttypes.models import ContentType +from django.test import Client from django.test.utils import override_settings from django.urls import reverse +from netaddr import IPNetwork +from rest_framework.test import APIClient + +from dcim.models import Site +from ipam.choices import PrefixStatusChoices +from ipam.models import Prefix +from users.models import ObjectPermission, Token +from utilities.testing.testcases import TestCase class ExternalAuthenticationTestCase(TestCase): @@ -135,7 +144,7 @@ class ExternalAuthenticationTestCase(TestCase): @override_settings( REMOTE_AUTH_ENABLED=True, REMOTE_AUTH_AUTO_CREATE_USER=True, - REMOTE_AUTH_DEFAULT_PERMISSIONS=['dcim.add_site', 'dcim.change_site'], + REMOTE_AUTH_DEFAULT_PERMISSIONS={'dcim.add_site': None, 'dcim.change_site': None}, LOGIN_REQUIRED=True ) def test_remote_auth_default_permissions(self): @@ -149,7 +158,7 @@ class ExternalAuthenticationTestCase(TestCase): self.assertTrue(settings.REMOTE_AUTH_ENABLED) self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER) self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_REMOTE_USER') - self.assertEqual(settings.REMOTE_AUTH_DEFAULT_PERMISSIONS, ['dcim.add_site', 'dcim.change_site']) + self.assertEqual(settings.REMOTE_AUTH_DEFAULT_PERMISSIONS, {'dcim.add_site': None, 'dcim.change_site': None}) response = self.client.get(reverse('home'), follow=True, **headers) self.assertEqual(response.status_code, 200) @@ -157,3 +166,533 @@ class ExternalAuthenticationTestCase(TestCase): new_user = User.objects.get(username='remoteuser2') self.assertEqual(int(self.client.session.get('_auth_user_id')), new_user.pk, msg='Authentication failed') self.assertTrue(new_user.has_perms(['dcim.add_site', 'dcim.change_site'])) + + +class ObjectPermissionViewTestCase(TestCase): + + @classmethod + def setUpTestData(cls): + + cls.sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + ) + Site.objects.bulk_create(cls.sites) + + cls.prefixes = ( + Prefix(prefix=IPNetwork('10.0.0.0/24'), site=cls.sites[0]), + Prefix(prefix=IPNetwork('10.0.1.0/24'), site=cls.sites[0]), + Prefix(prefix=IPNetwork('10.0.2.0/24'), site=cls.sites[0]), + Prefix(prefix=IPNetwork('10.0.3.0/24'), site=cls.sites[1]), + Prefix(prefix=IPNetwork('10.0.4.0/24'), site=cls.sites[1]), + Prefix(prefix=IPNetwork('10.0.5.0/24'), site=cls.sites[1]), + Prefix(prefix=IPNetwork('10.0.6.0/24'), site=cls.sites[2]), + Prefix(prefix=IPNetwork('10.0.7.0/24'), site=cls.sites[2]), + Prefix(prefix=IPNetwork('10.0.8.0/24'), site=cls.sites[2]), + ) + Prefix.objects.bulk_create(cls.prefixes) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_get_object(self): + + # Attempt to retrieve object without permission + response = self.client.get(self.prefixes[0].get_absolute_url()) + self.assertHttpStatus(response, 403) + + # Assign object permission + obj_perm = ObjectPermission( + constraints={'site__name': 'Site 1'}, + actions=['view'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) + + # Retrieve permitted object + response = self.client.get(self.prefixes[0].get_absolute_url()) + self.assertHttpStatus(response, 200) + + # Attempt to retrieve non-permitted object + response = self.client.get(self.prefixes[3].get_absolute_url()) + self.assertHttpStatus(response, 404) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_list_objects(self): + + # Attempt to list objects without permission + response = self.client.get(reverse('ipam:prefix_list')) + self.assertHttpStatus(response, 403) + + # Assign object permission + obj_perm = ObjectPermission( + constraints={'site__name': 'Site 1'}, + actions=['view'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) + + # Retrieve all objects. Only permitted objects should be returned. + response = self.client.get(reverse('ipam:prefix_list')) + self.assertHttpStatus(response, 200) + self.assertIn(str(self.prefixes[0].prefix), str(response.content)) + self.assertNotIn(str(self.prefixes[3].prefix), str(response.content)) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_create_object(self): + initial_count = Prefix.objects.count() + form_data = { + 'prefix': '10.0.9.0/24', + 'site': self.sites[1].pk, + 'status': PrefixStatusChoices.STATUS_ACTIVE, + } + + # Attempt to create an object without permission + request = { + 'path': reverse('ipam:prefix_add'), + 'data': form_data, + 'follow': False, # Do not follow 302 redirects + } + response = self.client.post(**request) + self.assertHttpStatus(response, 403) + self.assertEqual(initial_count, Prefix.objects.count()) + + # Assign object permission + obj_perm = ObjectPermission( + constraints={'site__name': 'Site 1'}, + actions=['view', 'add'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) + + # Attempt to create a non-permitted object + request = { + 'path': reverse('ipam:prefix_add'), + 'data': form_data, + 'follow': True, # Follow 302 redirects + } + response = self.client.post(**request) + self.assertHttpStatus(response, 200) + self.assertEqual(Prefix.objects.count(), initial_count) + + # Create a permitted object + form_data['site'] = self.sites[0].pk + request = { + 'path': reverse('ipam:prefix_add'), + 'data': form_data, + 'follow': True, # Follow 302 redirects + } + response = self.client.post(**request) + self.assertHttpStatus(response, 200) + self.assertEqual(Prefix.objects.count(), initial_count + 1) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_edit_object(self): + form_data = { + 'prefix': '10.0.9.0/24', + 'site': self.sites[0].pk, + 'status': PrefixStatusChoices.STATUS_RESERVED, + } + + # Attempt to edit an object without permission + request = { + 'path': reverse('ipam:prefix_edit', kwargs={'pk': self.prefixes[0].pk}), + 'data': form_data, + 'follow': False, # Do not follow 302 redirects + } + response = self.client.post(**request) + self.assertHttpStatus(response, 403) + + # Assign object permission + obj_perm = ObjectPermission( + constraints={'site__name': 'Site 1'}, + actions=['view', 'change'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) + + # Attempt to edit a non-permitted object + request = { + 'path': reverse('ipam:prefix_edit', kwargs={'pk': self.prefixes[3].pk}), + 'data': form_data, + 'follow': True, # Follow 302 redirects + } + response = self.client.post(**request) + self.assertHttpStatus(response, 404) + + # Edit a permitted object + request = { + 'path': reverse('ipam:prefix_edit', kwargs={'pk': self.prefixes[0].pk}), + 'data': form_data, + 'follow': True, # Follow 302 redirects + } + response = self.client.post(**request) + self.assertHttpStatus(response, 200) + prefix = Prefix.objects.get(pk=self.prefixes[0].pk) + self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_RESERVED) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_delete_object(self): + form_data = { + 'confirm': True + } + + # Attempt to delete object without permission + request = { + 'path': reverse('ipam:prefix_delete', kwargs={'pk': self.prefixes[0].pk}), + 'data': form_data, + } + response = self.client.post(**request) + self.assertHttpStatus(response, 403) + + # Assign object permission + obj_perm = ObjectPermission( + constraints={'site__name': 'Site 1'}, + actions=['view', 'delete'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) + + # Delete permitted object + request = { + 'path': reverse('ipam:prefix_delete', kwargs={'pk': self.prefixes[0].pk}), + 'data': form_data, + 'follow': True, # Follow 302 redirects + } + response = self.client.post(**request) + self.assertHttpStatus(response, 200) + self.assertFalse(Prefix.objects.filter(pk=self.prefixes[0].pk).exists()) + + # Attempt to delete non-permitted object + request = { + 'path': reverse('ipam:prefix_delete', kwargs={'pk': self.prefixes[3].pk}), + 'data': form_data, + 'follow': True, # Follow 302 redirects + } + response = self.client.post(**request) + self.assertHttpStatus(response, 404) + self.assertTrue(Prefix.objects.filter(pk=self.prefixes[3].pk).exists()) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_bulk_import_objects(self): + initial_count = Prefix.objects.count() + form_data = { + 'csv': "prefix,status,site\n" + "10.0.9.0/24,Active,Site 1\n" + "10.0.10.0/24,Active,Site 2\n" + "10.0.11.0/24,Active,Site 3\n", + } + + # Attempt to import objects without permission + request = { + 'path': reverse('ipam:prefix_import'), + 'data': form_data, + } + response = self.client.post(**request) + self.assertHttpStatus(response, 403) + self.assertEqual(initial_count, Prefix.objects.count()) + + # Assign object permission + obj_perm = ObjectPermission( + constraints={'site__name': 'Site 1'}, + actions=['add'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) + + # Attempt to create non-permitted objects + request = { + 'path': reverse('ipam:prefix_import'), + 'data': form_data, + } + response = self.client.post(**request) + self.assertHttpStatus(response, 200) + self.assertEqual(Prefix.objects.count(), initial_count) + + # Create a permitted object + form_data = { + 'csv': "prefix,status,site\n" + "10.0.9.0/24,Active,Site 1\n" + "10.0.10.0/24,Active,Site 1\n" + "10.0.11.0/24,Active,Site 1\n", + } + request = { + 'path': reverse('ipam:prefix_import'), + 'data': form_data, + } + response = self.client.post(**request) + self.assertHttpStatus(response, 200) + self.assertEqual(Prefix.objects.count(), initial_count + 3) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_bulk_edit_objects(self): + form_data = { + 'pk': [p.pk for p in self.prefixes], + 'status': 'reserved', + '_apply': True, + } + + # Attempt to edit objects without permission + request = { + 'path': reverse('ipam:prefix_bulk_edit'), + 'data': form_data, + } + response = self.client.post(**request) + self.assertHttpStatus(response, 403) + + # Assign object permission + obj_perm = ObjectPermission( + constraints={'site__name': 'Site 1'}, + actions=['change'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) + + # Attempt to edit non-permitted objects + request = { + 'path': reverse('ipam:prefix_bulk_edit'), + 'data': form_data, + } + response = self.client.post(**request) + self.assertHttpStatus(response, 302) + self.assertEqual(Prefix.objects.get(pk=self.prefixes[3].pk).status, 'active') + + # Edit permitted objects + form_data['pk'] = [p.pk for p in self.prefixes[:3]] + request = { + 'path': reverse('ipam:prefix_bulk_edit'), + 'data': form_data, + } + response = self.client.post(**request) + self.assertHttpStatus(response, 302) + self.assertEqual(Prefix.objects.get(pk=self.prefixes[0].pk).status, 'reserved') + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_bulk_delete_objects(self): + form_data = { + 'pk': [p.pk for p in self.prefixes], + 'confirm': True, + '_confirm': True, + } + + # Attempt to delete objects without permission + request = { + 'path': reverse('ipam:prefix_bulk_delete'), + 'data': form_data, + } + response = self.client.post(**request) + self.assertHttpStatus(response, 403) + + # Assign object permission + obj_perm = ObjectPermission( + constraints={'site__name': 'Site 1'}, + actions=['view', 'delete'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) + + # Attempt to delete non-permitted object + request = { + 'path': reverse('ipam:prefix_bulk_delete'), + 'data': form_data, + } + response = self.client.post(**request) + self.assertHttpStatus(response, 200) + self.assertTrue(Prefix.objects.filter(pk=self.prefixes[3].pk).exists()) + + # Delete permitted objects + form_data['pk'] = [p.pk for p in self.prefixes[:3]] + request = { + 'path': reverse('ipam:prefix_bulk_delete'), + 'data': form_data, + } + response = self.client.post(**request) + self.assertHttpStatus(response, 302) + self.assertFalse(Prefix.objects.filter(pk=self.prefixes[0].pk).exists()) + + +class ObjectPermissionAPIViewTestCase(TestCase): + client_class = APIClient + + @classmethod + def setUpTestData(cls): + + cls.sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + ) + Site.objects.bulk_create(cls.sites) + + cls.prefixes = ( + Prefix(prefix=IPNetwork('10.0.0.0/24'), site=cls.sites[0]), + Prefix(prefix=IPNetwork('10.0.1.0/24'), site=cls.sites[0]), + Prefix(prefix=IPNetwork('10.0.2.0/24'), site=cls.sites[0]), + Prefix(prefix=IPNetwork('10.0.3.0/24'), site=cls.sites[1]), + Prefix(prefix=IPNetwork('10.0.4.0/24'), site=cls.sites[1]), + Prefix(prefix=IPNetwork('10.0.5.0/24'), site=cls.sites[1]), + Prefix(prefix=IPNetwork('10.0.6.0/24'), site=cls.sites[2]), + Prefix(prefix=IPNetwork('10.0.7.0/24'), site=cls.sites[2]), + Prefix(prefix=IPNetwork('10.0.8.0/24'), site=cls.sites[2]), + ) + Prefix.objects.bulk_create(cls.prefixes) + + def setUp(self): + """ + Create a test user and token for API calls. + """ + self.user = User.objects.create(username='testuser') + self.token = Token.objects.create(user=self.user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)} + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_get_object(self): + + # Attempt to retrieve object without permission + url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk}) + response = self.client.get(url, **self.header) + self.assertEqual(response.status_code, 403) + + # Assign object permission + obj_perm = ObjectPermission( + constraints={'site__name': 'Site 1'}, + actions=['view'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) + + # Retrieve permitted object + url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk}) + response = self.client.get(url, **self.header) + self.assertEqual(response.status_code, 200) + + # Attempt to retrieve non-permitted object + url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[3].pk}) + response = self.client.get(url, **self.header) + self.assertEqual(response.status_code, 404) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_list_objects(self): + url = reverse('ipam-api:prefix-list') + + # Attempt to list objects without permission + response = self.client.get(url, **self.header) + self.assertEqual(response.status_code, 403) + + # Assign object permission + obj_perm = ObjectPermission( + constraints={'site__name': 'Site 1'}, + actions=['view'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) + + # Retrieve all objects. Only permitted objects should be returned. + response = self.client.get(url, **self.header) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['count'], 3) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_create_object(self): + url = reverse('ipam-api:prefix-list') + data = { + 'prefix': '10.0.9.0/24', + 'site': self.sites[1].pk, + } + initial_count = Prefix.objects.count() + + # Attempt to create an object without permission + response = self.client.post(url, data, format='json', **self.header) + self.assertEqual(response.status_code, 403) + + # Assign object permission + obj_perm = ObjectPermission( + constraints={'site__name': 'Site 1'}, + actions=['add'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) + + # Attempt to create a non-permitted object + response = self.client.post(url, data, format='json', **self.header) + self.assertEqual(response.status_code, 403) + self.assertEqual(Prefix.objects.count(), initial_count) + + # Create a permitted object + data['site'] = self.sites[0].pk + response = self.client.post(url, data, format='json', **self.header) + self.assertEqual(response.status_code, 201) + self.assertEqual(Prefix.objects.count(), initial_count + 1) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_edit_object(self): + + # Attempt to edit an object without permission + data = {'site': self.sites[0].pk} + url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk}) + response = self.client.patch(url, data, format='json', **self.header) + self.assertEqual(response.status_code, 403) + + # Assign object permission + obj_perm = ObjectPermission( + constraints={'site__name': 'Site 1'}, + actions=['change'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) + + # Attempt to edit a non-permitted object + data = {'site': self.sites[0].pk} + url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[3].pk}) + response = self.client.patch(url, data, format='json', **self.header) + self.assertEqual(response.status_code, 404) + + # Edit a permitted object + data['status'] = 'reserved' + url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk}) + response = self.client.patch(url, data, format='json', **self.header) + self.assertEqual(response.status_code, 200) + + # Attempt to modify a permitted object to a non-permitted object + data['site'] = self.sites[1].pk + url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk}) + response = self.client.patch(url, data, format='json', **self.header) + self.assertEqual(response.status_code, 403) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_delete_object(self): + + # Attempt to delete an object without permission + url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk}) + response = self.client.delete(url, format='json', **self.header) + self.assertEqual(response.status_code, 403) + + # Assign object permission + obj_perm = ObjectPermission( + constraints={'site__name': 'Site 1'}, + actions=['delete'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) + + # Attempt to delete a non-permitted object + url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[3].pk}) + response = self.client.delete(url, format='json', **self.header) + self.assertEqual(response.status_code, 404) + + # Delete a permitted object + url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk}) + response = self.client.delete(url, format='json', **self.header) + self.assertEqual(response.status_code, 204) diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index d8aa2f9d1..a928b79ea 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -65,6 +65,7 @@ _patterns = [ path('api/ipam/', include('ipam.api.urls')), path('api/secrets/', include('secrets.api.urls')), path('api/tenancy/', include('tenancy.api.urls')), + path('api/users/', include('users.api.urls')), path('api/virtualization/', include('virtualization.api.urls')), path('api/docs/', schema_view.with_ui('swagger'), name='api_docs'), path('api/redoc/', schema_view.with_ui('redoc'), name='api_redocs'), diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 37a516409..7ac5f550b 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -194,52 +194,51 @@ class HomeView(View): def get(self, request): - connected_consoleports = ConsolePort.objects.filter( + connected_consoleports = ConsolePort.objects.restrict(request.user, 'view').filter( connected_endpoint__isnull=False ) - connected_powerports = PowerPort.objects.filter( + connected_powerports = PowerPort.objects.restrict(request.user, 'view').filter( _connected_poweroutlet__isnull=False ) - connected_interfaces = Interface.objects.filter( + connected_interfaces = Interface.objects.restrict(request.user, 'view').filter( _connected_interface__isnull=False, pk__lt=F('_connected_interface') ) - cables = Cable.objects.all() stats = { # Organization - 'site_count': Site.objects.count(), - 'tenant_count': Tenant.objects.count(), + 'site_count': Site.objects.restrict(request.user, 'view').count(), + 'tenant_count': Tenant.objects.restrict(request.user, 'view').count(), # DCIM - 'rack_count': Rack.objects.count(), - 'devicetype_count': DeviceType.objects.count(), - 'device_count': Device.objects.count(), + 'rack_count': Rack.objects.restrict(request.user, 'view').count(), + 'devicetype_count': DeviceType.objects.restrict(request.user, 'view').count(), + 'device_count': Device.objects.restrict(request.user, 'view').count(), 'interface_connections_count': connected_interfaces.count(), - 'cable_count': cables.count(), + 'cable_count': Cable.objects.restrict(request.user, 'view').count(), 'console_connections_count': connected_consoleports.count(), 'power_connections_count': connected_powerports.count(), - 'powerpanel_count': PowerPanel.objects.count(), - 'powerfeed_count': PowerFeed.objects.count(), + 'powerpanel_count': PowerPanel.objects.restrict(request.user, 'view').count(), + 'powerfeed_count': PowerFeed.objects.restrict(request.user, 'view').count(), # IPAM - 'vrf_count': VRF.objects.count(), - 'aggregate_count': Aggregate.objects.count(), - 'prefix_count': Prefix.objects.count(), - 'ipaddress_count': IPAddress.objects.count(), - 'vlan_count': VLAN.objects.count(), + 'vrf_count': VRF.objects.restrict(request.user, 'view').count(), + 'aggregate_count': Aggregate.objects.restrict(request.user, 'view').count(), + 'prefix_count': Prefix.objects.restrict(request.user, 'view').count(), + 'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').count(), + 'vlan_count': VLAN.objects.restrict(request.user, 'view').count(), # Circuits - 'provider_count': Provider.objects.count(), - 'circuit_count': Circuit.objects.count(), + 'provider_count': Provider.objects.restrict(request.user, 'view').count(), + 'circuit_count': Circuit.objects.restrict(request.user, 'view').count(), # Secrets - 'secret_count': Secret.objects.count(), + 'secret_count': Secret.objects.restrict(request.user, 'view').count(), # Virtualization - 'cluster_count': Cluster.objects.count(), - 'virtualmachine_count': VirtualMachine.objects.count(), + 'cluster_count': Cluster.objects.restrict(request.user, 'view').count(), + 'virtualmachine_count': VirtualMachine.objects.restrict(request.user, 'view').count(), } @@ -293,7 +292,7 @@ class SearchView(View): for obj_type in obj_types: - queryset = SEARCH_TYPES[obj_type]['queryset'] + queryset = SEARCH_TYPES[obj_type]['queryset'].restrict(request.user, 'view') filterset = SEARCH_TYPES[obj_type]['filterset'] table = SEARCH_TYPES[obj_type]['table'] url = SEARCH_TYPES[obj_type]['url'] @@ -344,5 +343,6 @@ class APIRootView(APIView): ('plugins', reverse('plugins-api:api-root', request=request, format=format)), ('secrets', reverse('secrets-api:api-root', request=request, format=format)), ('tenancy', reverse('tenancy-api:api-root', request=request, format=format)), + ('users', reverse('users-api:api-root', request=request, format=format)), ('virtualization', reverse('virtualization-api:api-root', request=request, format=format)), ))) diff --git a/netbox/secrets/admin.py b/netbox/secrets/admin.py index 94cd1c7fa..e11128674 100644 --- a/netbox/secrets/admin.py +++ b/netbox/secrets/admin.py @@ -23,7 +23,7 @@ class UserKeyAdmin(admin.ModelAdmin): actions = super().get_actions(request) if 'delete_selected' in actions: del actions['delete_selected'] - if not request.user.has_perm('secrets.activate_userkey'): + if not request.user.has_perm('secrets.change_userkey'): del actions['activate_selected'] return actions diff --git a/netbox/secrets/decorators.py b/netbox/secrets/decorators.py deleted file mode 100644 index e2f44ac90..000000000 --- a/netbox/secrets/decorators.py +++ /dev/null @@ -1,24 +0,0 @@ -from django.contrib import messages -from django.shortcuts import redirect - -from .models import UserKey - - -def userkey_required(): - """ - Decorator for views which require that the user has an active UserKey (typically for encryption/decryption of - Secrets). - """ - def _decorator(view): - def wrapped_view(request, *args, **kwargs): - try: - uk = UserKey.objects.get(user=request.user) - except UserKey.DoesNotExist: - messages.warning(request, "This operation requires an active user key, but you don't have one.") - return redirect('user:userkey') - if not uk.is_active(): - messages.warning(request, "This operation is not available. Your user key has not been activated.") - return redirect('user:userkey') - return view(request, *args, **kwargs) - return wrapped_view - return _decorator diff --git a/netbox/secrets/migrations/0001_initial.py b/netbox/secrets/migrations/0001_initial.py index 1281a266a..3664bae63 100644 --- a/netbox/secrets/migrations/0001_initial.py +++ b/netbox/secrets/migrations/0001_initial.py @@ -56,7 +56,6 @@ class Migration(migrations.Migration): ], options={ 'ordering': ['user__username'], - 'permissions': (('activate_userkey', 'Can activate user keys for decryption'),), }, ), migrations.AddField( diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 830e91096..bf5858ff8 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -1,5 +1,4 @@ import os -import sys from Crypto.Cipher import AES from Crypto.PublicKey import RSA @@ -18,6 +17,7 @@ from dcim.models import Device from extras.models import CustomFieldModel, TaggedItem from extras.utils import extras_features from utilities.models import ChangeLoggedModel +from utilities.querysets import RestrictedQuerySet from .exceptions import InvalidKey from .hashers import SecretValidationHasher from .querysets import UserKeyQuerySet @@ -64,9 +64,6 @@ class UserKey(models.Model): class Meta: ordering = ['user__username'] - permissions = ( - ('activate_userkey', "Can activate user keys for decryption"), - ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -269,6 +266,8 @@ class SecretRole(ChangeLoggedModel): blank=True ) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'description'] class Meta: @@ -334,9 +333,10 @@ class Secret(ChangeLoggedModel, CustomFieldModel): content_type_field='obj_type', object_id_field='obj_id' ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + plaintext = None csv_headers = ['device', 'role', 'name', 'plaintext'] diff --git a/netbox/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py index 339c370d8..8d716a465 100644 --- a/netbox/secrets/tests/test_api.py +++ b/netbox/secrets/tests/test_api.py @@ -5,8 +5,7 @@ from rest_framework import status from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from secrets.models import Secret, SecretRole, SessionKey, UserKey -from users.models import Token -from utilities.testing import APITestCase, create_test_user +from utilities.testing import APITestCase from .constants import PRIVATE_KEY, PUBLIC_KEY @@ -124,16 +123,16 @@ class SecretRoleTest(APITestCase): class SecretTest(APITestCase): def setUp(self): + super().setUp() - # Create a non-superuser test user - self.user = create_test_user('testuser', permissions=( + self.user.is_superuser = False + self.user.save() + self.add_permissions( 'secrets.add_secret', 'secrets.change_secret', 'secrets.delete_secret', 'secrets.view_secret', - )) - self.token = Token.objects.create(user=self.user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)} + ) userkey = UserKey(user=self.user, public_key=PUBLIC_KEY) userkey.save() @@ -178,24 +177,25 @@ class SecretTest(APITestCase): self.secret3.save() def test_get_secret(self): - url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk}) - # Secret plaintext not be decrypted as the user has not been assigned to the role + # Secret plaintext should not be decrypted as the user has not been assigned to the role response = self.client.get(url, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertIsNone(response.data['plaintext']) # The plaintext should be present once the user has been assigned to the role self.secretrole1.users.add(self.user) response = self.client.get(url, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(response.data['plaintext'], self.plaintexts[0]) def test_list_secrets(self): - url = reverse('secrets-api:secret-list') - # Secret plaintext not be decrypted as the user has not been assigned to the role + # Secret plaintext should not be decrypted as the user has not been assigned to the role response = self.client.get(url, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(response.data['count'], 3) for secret in response.data['results']: self.assertIsNone(secret['plaintext']) @@ -203,12 +203,12 @@ class SecretTest(APITestCase): # The plaintext should be present once the user has been assigned to the role self.secretrole1.users.add(self.user) response = self.client.get(url, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(response.data['count'], 3) for i, secret in enumerate(response.data['results']): self.assertEqual(secret['plaintext'], self.plaintexts[i]) def test_create_secret(self): - data = { 'device': self.device.pk, 'role': self.secretrole1.pk, @@ -216,6 +216,9 @@ class SecretTest(APITestCase): 'plaintext': 'Secret #4 Plaintext', } + # Assign test user to secret role + self.secretrole1.users.add(self.user) + url = reverse('secrets-api:secret-list') response = self.client.post(url, data, format='json', **self.header) @@ -228,7 +231,6 @@ class SecretTest(APITestCase): self.assertEqual(secret4.plaintext, data['plaintext']) def test_create_secret_bulk(self): - data = [ { 'device': self.device.pk, @@ -250,6 +252,9 @@ class SecretTest(APITestCase): }, ] + # Assign test user to secret role + self.secretrole1.users.add(self.user) + url = reverse('secrets-api:secret-list') response = self.client.post(url, data, format='json', **self.header) @@ -260,13 +265,15 @@ class SecretTest(APITestCase): self.assertEqual(response.data[2]['plaintext'], data[2]['plaintext']) def test_update_secret(self): - data = { 'device': self.device.pk, 'role': self.secretrole2.pk, 'plaintext': 'NewPlaintext', } + # Assign test user to secret role + self.secretrole1.users.add(self.user) + url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk}) response = self.client.put(url, data, format='json', **self.header) @@ -279,6 +286,8 @@ class SecretTest(APITestCase): self.assertEqual(secret1.plaintext, data['plaintext']) def test_delete_secret(self): + # Assign test user to secret role + self.secretrole1.users.add(self.user) url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk}) response = self.client.delete(url, **self.header) diff --git a/netbox/secrets/tests/test_views.py b/netbox/secrets/tests/test_views.py index 96439a10d..577ba4ef4 100644 --- a/netbox/secrets/tests/test_views.py +++ b/netbox/secrets/tests/test_views.py @@ -36,15 +36,16 @@ class SecretRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): ) -class SecretTestCase(ViewTestCases.PrimaryObjectViewTestCase): +# TODO: Change base class to PrimaryObjectViewTestCase +class SecretTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase +): model = Secret - # Disable inapplicable tests - test_create_object = None - - # TODO: Check permissions enforcement on secrets.views.secret_edit - test_edit_object = None - @classmethod def setUpTestData(cls): diff --git a/netbox/secrets/urls.py b/netbox/secrets/urls.py index a19ec6ae0..84c2da398 100644 --- a/netbox/secrets/urls.py +++ b/netbox/secrets/urls.py @@ -9,7 +9,7 @@ urlpatterns = [ # Secret roles path('secret-roles/', views.SecretRoleListView.as_view(), name='secretrole_list'), - path('secret-roles/add/', views.SecretRoleCreateView.as_view(), name='secretrole_add'), + path('secret-roles/add/', views.SecretRoleEditView.as_view(), name='secretrole_add'), path('secret-roles/import/', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'), path('secret-roles/delete/', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'), path('secret-roles//edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'), @@ -17,12 +17,12 @@ urlpatterns = [ # Secrets path('secrets/', views.SecretListView.as_view(), name='secret_list'), - path('secrets/add/', views.secret_add, name='secret_add'), + path('secrets/add/', views.SecretEditView.as_view(), name='secret_add'), path('secrets/import/', views.SecretBulkImportView.as_view(), name='secret_import'), path('secrets/edit/', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'), path('secrets/delete/', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'), path('secrets//', views.SecretView.as_view(), name='secret'), - path('secrets//edit/', views.secret_edit, name='secret_edit'), + path('secrets//edit/', views.SecretEditView.as_view(), name='secret_edit'), path('secrets//delete/', views.SecretDeleteView.as_view(), name='secret_delete'), path('secrets//changelog/', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}), diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index b40e41cb3..a5aabaecd 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -1,19 +1,17 @@ import base64 +import logging from django.contrib import messages -from django.contrib.auth.decorators import permission_required -from django.contrib.auth.mixins import PermissionRequiredMixin from django.db.models import Count from django.shortcuts import get_object_or_404, redirect, render -from django.urls import reverse -from django.views.generic import View +from django.utils.html import escape +from django.utils.safestring import mark_safe from utilities.views import ( - BulkDeleteView, BulkEditView, BulkImportView, GetReturnURLMixin, ObjectDeleteView, ObjectEditView, ObjectListView, + BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView, ) from . import filters, forms, tables -from .decorators import userkey_required -from .models import SecretRole, Secret, SessionKey +from .models import SecretRole, Secret, SessionKey, UserKey def get_session_key(request): @@ -30,32 +28,25 @@ def get_session_key(request): # Secret roles # -class SecretRoleListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'secrets.view_secretrole' +class SecretRoleListView(ObjectListView): queryset = SecretRole.objects.annotate(secret_count=Count('secrets')) table = tables.SecretRoleTable -class SecretRoleCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'secrets.add_secretrole' +class SecretRoleEditView(ObjectEditView): queryset = SecretRole.objects.all() model_form = forms.SecretRoleForm default_return_url = 'secrets:secretrole_list' -class SecretRoleEditView(SecretRoleCreateView): - permission_required = 'secrets.change_secretrole' - - -class SecretRoleBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'secrets.add_secretrole' +class SecretRoleBulkImportView(BulkImportView): + queryset = SecretRole.objects.all() model_form = forms.SecretRoleCSVForm table = tables.SecretRoleTable default_return_url = 'secrets:secretrole_list' -class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'secrets.delete_secretrole' +class SecretRoleBulkDeleteView(BulkDeleteView): queryset = SecretRole.objects.annotate(secret_count=Count('secrets')) table = tables.SecretRoleTable default_return_url = 'secrets:secretrole_list' @@ -65,8 +56,7 @@ class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Secrets # -class SecretListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'secrets.view_secret' +class SecretListView(ObjectListView): queryset = Secret.objects.prefetch_related('role', 'device') filterset = filters.SecretFilterSet filterset_form = forms.SecretFilterForm @@ -74,129 +64,94 @@ class SecretListView(PermissionRequiredMixin, ObjectListView): action_buttons = ('import', 'export') -class SecretView(PermissionRequiredMixin, View): - permission_required = 'secrets.view_secret' +class SecretView(ObjectView): + queryset = Secret.objects.all() def get(self, request, pk): - secret = get_object_or_404(Secret, pk=pk) + secret = get_object_or_404(self.queryset, pk=pk) return render(request, 'secrets/secret.html', { 'secret': secret, }) -@permission_required('secrets.add_secret') -@userkey_required() -def secret_add(request): +class SecretEditView(ObjectEditView): + queryset = Secret.objects.all() + model_form = forms.SecretForm + template_name = 'secrets/secret_edit.html' - secret = Secret() - session_key = get_session_key(request) + def dispatch(self, request, *args, **kwargs): + + # Check that the user has a valid UserKey + try: + uk = UserKey.objects.get(user=request.user) + except UserKey.DoesNotExist: + messages.warning(request, "This operation requires an active user key, but you don't have one.") + return redirect('user:userkey') + if not uk.is_active(): + messages.warning(request, "This operation is not available. Your user key has not been activated.") + return redirect('user:userkey') + + return super().dispatch(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + logger = logging.getLogger('netbox.views.ObjectEditView') + session_key = get_session_key(request) + secret = self.get_object(kwargs) + form = self.model_form(request.POST, instance=secret) - if request.method == 'POST': - form = forms.SecretForm(request.POST, instance=secret) if form.is_valid(): + logger.debug("Form validation was successful") - # We need a valid session key in order to create a Secret - if session_key is None: + # We must have a session key in order to create a secret or update the plaintext of an existing secret + if (form.cleaned_data['plaintext'] or secret.pk is None) and session_key is None: + logger.debug("Unable to proceed: No session key was provided with the request") form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.") - # Create and encrypt the new Secret else: master_key = None try: sk = SessionKey.objects.get(userkey__user=request.user) master_key = sk.get_master_key(session_key) except SessionKey.DoesNotExist: + logger.debug("Unable to proceed: User has no session key assigned") form.add_error(None, "No session key found for this user.") if master_key is not None: + logger.debug("Successfully resolved master key for encryption") secret = form.save(commit=False) - secret.plaintext = str(form.cleaned_data['plaintext']) + if form.cleaned_data['plaintext']: + secret.plaintext = str(form.cleaned_data['plaintext']) secret.encrypt(master_key) secret.save() form.save_m2m() - messages.success(request, "Added new secret: {}.".format(secret)) - if '_addanother' in request.POST: - return redirect('secrets:secret_add') - else: - return redirect('secrets:secret', pk=secret.pk) + msg = '{} secret'.format('Created' if not form.instance.pk else 'Modified') + logger.info(f"{msg} {secret} (PK: {secret.pk})") + msg = '{} {}'.format(msg, secret.get_absolute_url(), escape(secret)) + messages.success(request, mark_safe(msg)) - else: - initial_data = { - 'device': request.GET.get('device'), - } - form = forms.SecretForm(initial=initial_data) + return redirect(self.get_return_url(request, secret)) - return render(request, 'secrets/secret_edit.html', { - 'secret': secret, - 'form': form, - 'return_url': GetReturnURLMixin().get_return_url(request, secret) - }) + else: + logger.debug("Form validation failed") + + return render(request, self.template_name, { + 'obj': secret, + 'obj_type': self.queryset.model._meta.verbose_name, + 'form': form, + 'return_url': self.get_return_url(request, secret), + }) -@permission_required('secrets.change_secret') -@userkey_required() -def secret_edit(request, pk): - - secret = get_object_or_404(Secret, pk=pk) - session_key = get_session_key(request) - - if request.method == 'POST': - form = forms.SecretForm(request.POST, instance=secret) - if form.is_valid(): - - # Re-encrypt the Secret if a plaintext and session key have been provided. - if form.cleaned_data['plaintext'] and session_key is not None: - - # Retrieve the master key using the provided session key - master_key = None - try: - sk = SessionKey.objects.get(userkey__user=request.user) - master_key = sk.get_master_key(session_key) - except SessionKey.DoesNotExist: - form.add_error(None, "No session key found for this user.") - - # Create and encrypt the new Secret - if master_key is not None: - secret = form.save(commit=False) - secret.plaintext = form.cleaned_data['plaintext'] - secret.encrypt(master_key) - secret.save() - messages.success(request, "Modified secret {}.".format(secret)) - return redirect('secrets:secret', pk=secret.pk) - else: - form.add_error(None, "Invalid session key. Unable to encrypt secret data.") - - # We can't save the plaintext without a session key. - elif form.cleaned_data['plaintext']: - form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.") - - # If no new plaintext was specified, a session key is not needed. - else: - secret = form.save() - messages.success(request, "Modified secret {}.".format(secret)) - return redirect('secrets:secret', pk=secret.pk) - - else: - form = forms.SecretForm(instance=secret) - - return render(request, 'secrets/secret_edit.html', { - 'secret': secret, - 'form': form, - 'return_url': reverse('secrets:secret', kwargs={'pk': secret.pk}), - }) - - -class SecretDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'secrets.delete_secret' +class SecretDeleteView(ObjectDeleteView): queryset = Secret.objects.all() default_return_url = 'secrets:secret_list' class SecretBulkImportView(BulkImportView): - permission_required = 'secrets.add_secret' + queryset = Secret.objects.all() model_form = forms.SecretCSVForm table = tables.SecretTable template_name = 'secrets/secret_import.html' @@ -243,8 +198,7 @@ class SecretBulkImportView(BulkImportView): }) -class SecretBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'secrets.change_secret' +class SecretBulkEditView(BulkEditView): queryset = Secret.objects.prefetch_related('role', 'device') filterset = filters.SecretFilterSet table = tables.SecretTable @@ -252,8 +206,7 @@ class SecretBulkEditView(PermissionRequiredMixin, BulkEditView): default_return_url = 'secrets:secret_list' -class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'secrets.delete_secret' +class SecretBulkDeleteView(BulkDeleteView): queryset = Secret.objects.prefetch_related('role', 'device') filterset = filters.SecretFilterSet table = tables.SecretTable diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index ef1a301e2..a42250a3d 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -101,7 +101,7 @@ Inventory {{ device.inventory_items.count }} - {% if perms.dcim.napalm_read %} + {% if perms.dcim.napalm_read_device %} {% if device.status != 'active' %} {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='Device must be in active status' %} {% elif not device.platform %} diff --git a/netbox/templates/secrets/secret_edit.html b/netbox/templates/secrets/secret_edit.html index cb3935521..6893e2d14 100644 --- a/netbox/templates/secrets/secret_edit.html +++ b/netbox/templates/secrets/secret_edit.html @@ -9,7 +9,7 @@ {{ form.private_key }}
-

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

+

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

{% if form.non_field_errors %}
Errors
@@ -30,17 +30,17 @@
Secret Data
- {% if secret.pk and secret|decryptable_by:request.user %} + {% if obj.pk and obj|decryptable_by:request.user %}
-

********

+

********

- -
@@ -69,9 +69,9 @@
- {% if secret.pk %} + {% if obj.pk %} - Cancel + Cancel {% else %} diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 077fb6ad1..2e415b965 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -7,6 +7,8 @@ from taggit.managers import TaggableManager from extras.models import CustomFieldModel, ObjectChange, TaggedItem from extras.utils import extras_features from utilities.models import ChangeLoggedModel +from utilities.mptt import TreeManager +from utilities.querysets import RestrictedQuerySet from utilities.utils import serialize_object @@ -40,6 +42,8 @@ class TenantGroup(MPTTModel, ChangeLoggedModel): blank=True ) + objects = TreeManager() + csv_headers = ['name', 'slug', 'parent', 'description'] class Meta: @@ -104,9 +108,10 @@ class Tenant(ChangeLoggedModel, CustomFieldModel): content_type_field='obj_type', object_id_field='obj_id' ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'group', 'description', 'comments'] clone_fields = [ 'group', 'description', diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index 0218a5674..4c65ce4e8 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -9,7 +9,7 @@ urlpatterns = [ # Tenant groups path('tenant-groups/', views.TenantGroupListView.as_view(), name='tenantgroup_list'), - path('tenant-groups/add/', views.TenantGroupCreateView.as_view(), name='tenantgroup_add'), + path('tenant-groups/add/', views.TenantGroupEditView.as_view(), name='tenantgroup_add'), path('tenant-groups/import/', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'), path('tenant-groups/delete/', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'), path('tenant-groups//edit/', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'), @@ -17,7 +17,7 @@ urlpatterns = [ # Tenants path('tenants/', views.TenantListView.as_view(), name='tenant_list'), - path('tenants/add/', views.TenantCreateView.as_view(), name='tenant_add'), + path('tenants/add/', views.TenantEditView.as_view(), name='tenant_add'), path('tenants/import/', views.TenantBulkImportView.as_view(), name='tenant_import'), path('tenants/edit/', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'), path('tenants/delete/', views.TenantBulkDeleteView.as_view(), name='tenant_bulk_delete'), diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 2af44094f..a82b231f5 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -1,13 +1,11 @@ -from django.contrib.auth.mixins import PermissionRequiredMixin from django.db.models import Count from django.shortcuts import get_object_or_404, render -from django.views.generic import View from circuits.models import Circuit from dcim.models import Site, Rack, Device, RackReservation from ipam.models import IPAddress, Prefix, VLAN, VRF from utilities.views import ( - BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, + BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView, ) from virtualization.models import VirtualMachine, Cluster from . import filters, forms, tables @@ -18,8 +16,7 @@ from .models import Tenant, TenantGroup # Tenant groups # -class TenantGroupListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'tenancy.view_tenantgroup' +class TenantGroupListView(ObjectListView): queryset = TenantGroup.objects.add_related_count( TenantGroup.objects.all(), Tenant, @@ -30,26 +27,20 @@ class TenantGroupListView(PermissionRequiredMixin, ObjectListView): table = tables.TenantGroupTable -class TenantGroupCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'tenancy.add_tenantgroup' +class TenantGroupEditView(ObjectEditView): queryset = TenantGroup.objects.all() model_form = forms.TenantGroupForm default_return_url = 'tenancy:tenantgroup_list' -class TenantGroupEditView(TenantGroupCreateView): - permission_required = 'tenancy.change_tenantgroup' - - -class TenantGroupBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'tenancy.add_tenantgroup' +class TenantGroupBulkImportView(BulkImportView): + queryset = TenantGroup.objects.all() model_form = forms.TenantGroupCSVForm table = tables.TenantGroupTable default_return_url = 'tenancy:tenantgroup_list' -class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'tenancy.delete_tenantgroup' +class TenantGroupBulkDeleteView(BulkDeleteView): queryset = TenantGroup.objects.annotate(tenant_count=Count('tenants')) table = tables.TenantGroupTable default_return_url = 'tenancy:tenantgroup_list' @@ -59,32 +50,31 @@ class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Tenants # -class TenantListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'tenancy.view_tenant' +class TenantListView(ObjectListView): queryset = Tenant.objects.prefetch_related('group') filterset = filters.TenantFilterSet filterset_form = forms.TenantFilterForm table = tables.TenantTable -class TenantView(PermissionRequiredMixin, View): - permission_required = 'tenancy.view_tenant' +class TenantView(ObjectView): + queryset = Tenant.objects.prefetch_related('group') def get(self, request, slug): - tenant = get_object_or_404(Tenant, slug=slug) + tenant = get_object_or_404(self.queryset, slug=slug) stats = { - 'site_count': Site.objects.filter(tenant=tenant).count(), - 'rack_count': Rack.objects.filter(tenant=tenant).count(), - 'rackreservation_count': RackReservation.objects.filter(tenant=tenant).count(), - 'device_count': Device.objects.filter(tenant=tenant).count(), - 'vrf_count': VRF.objects.filter(tenant=tenant).count(), - 'prefix_count': Prefix.objects.filter(tenant=tenant).count(), - 'ipaddress_count': IPAddress.objects.filter(tenant=tenant).count(), - 'vlan_count': VLAN.objects.filter(tenant=tenant).count(), - 'circuit_count': Circuit.objects.filter(tenant=tenant).count(), - 'virtualmachine_count': VirtualMachine.objects.filter(tenant=tenant).count(), - 'cluster_count': Cluster.objects.filter(tenant=tenant).count(), + 'site_count': Site.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'rack_count': Rack.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'rackreservation_count': RackReservation.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'device_count': Device.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'vrf_count': VRF.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'virtualmachine_count': VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'cluster_count': Cluster.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), } return render(request, 'tenancy/tenant.html', { @@ -93,33 +83,26 @@ class TenantView(PermissionRequiredMixin, View): }) -class TenantCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'tenancy.add_tenant' +class TenantEditView(ObjectEditView): queryset = Tenant.objects.all() model_form = forms.TenantForm template_name = 'tenancy/tenant_edit.html' default_return_url = 'tenancy:tenant_list' -class TenantEditView(TenantCreateView): - permission_required = 'tenancy.change_tenant' - - -class TenantDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'tenancy.delete_tenant' +class TenantDeleteView(ObjectDeleteView): queryset = Tenant.objects.all() default_return_url = 'tenancy:tenant_list' -class TenantBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'tenancy.add_tenant' +class TenantBulkImportView(BulkImportView): + queryset = Tenant.objects.all() model_form = forms.TenantCSVForm table = tables.TenantTable default_return_url = 'tenancy:tenant_list' -class TenantBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'tenancy.change_tenant' +class TenantBulkEditView(BulkEditView): queryset = Tenant.objects.prefetch_related('group') filterset = filters.TenantFilterSet table = tables.TenantTable @@ -127,8 +110,7 @@ class TenantBulkEditView(PermissionRequiredMixin, BulkEditView): default_return_url = 'tenancy:tenant_list' -class TenantBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'tenancy.delete_tenant' +class TenantBulkDeleteView(BulkDeleteView): queryset = Tenant.objects.prefetch_related('group') filterset = filters.TenantFilterSet table = tables.TenantTable diff --git a/netbox/users/admin.py b/netbox/users/admin.py index 42e651712..cc7a1b379 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -1,12 +1,31 @@ from django import forms from django.contrib import admin from django.contrib.auth.admin import UserAdmin as UserAdmin_ -from django.contrib.auth.models import User +from django.contrib.auth.models import Group as StockGroup, User as StockUser +from django.core.exceptions import FieldError, ValidationError -from .models import Token, UserConfig +from extras.admin import order_content_types +from .models import AdminGroup, AdminUser, ObjectPermission, Token, UserConfig -# Unregister the built-in UserAdmin so that we can use our custom admin view below -admin.site.unregister(User) + +# +# Users & groups +# + +# Unregister the built-in GroupAdmin and UserAdmin classes so that we can use our custom admin classes below +admin.site.unregister(StockGroup) +admin.site.unregister(StockUser) + + +@admin.register(AdminGroup) +class GroupAdmin(admin.ModelAdmin): + fields = ('name',) + list_display = ('name', 'user_count') + ordering = ('name',) + search_fields = ('name',) + + def user_count(self, obj): + return obj.user_set.count() class UserConfigInline(admin.TabularInline): @@ -16,14 +35,48 @@ class UserConfigInline(admin.TabularInline): verbose_name = 'Preferences' -@admin.register(User) +class ObjectPermissionInline(admin.TabularInline): + model = AdminUser.object_permissions.through + fields = ['object_types', 'actions', 'constraints'] + readonly_fields = fields + extra = 0 + verbose_name = 'Permission' + + def object_types(self, instance): + return ', '.join(instance.objectpermission.object_types.values_list('model', flat=True)) + + def actions(self, instance): + return ', '.join(instance.objectpermission.actions) + + def constraints(self, instance): + return instance.objectpermission.constraints + + def has_add_permission(self, request, obj): + # Don't allow the creation of new ObjectPermission assignments via this form + return False + + +@admin.register(AdminUser) class UserAdmin(UserAdmin_): list_display = [ 'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active' ] - inlines = (UserConfigInline,) + fieldsets = ( + (None, {'fields': ('username', 'password', 'first_name', 'last_name', 'email')}), + ('Groups', {'fields': ('groups',)}), + ('Permissions', { + 'fields': ('is_active', 'is_staff', 'is_superuser'), + }), + ('Important dates', {'fields': ('last_login', 'date_joined')}), + ) + inlines = [ObjectPermissionInline, UserConfigInline] + filter_horizontal = ('groups',) +# +# REST API tokens +# + class TokenAdminForm(forms.ModelForm): key = forms.CharField( required=False, @@ -43,3 +96,115 @@ class TokenAdmin(admin.ModelAdmin): list_display = [ 'key', 'user', 'created', 'expires', 'write_enabled', 'description' ] + + +# +# Permissions +# + +class ObjectPermissionForm(forms.ModelForm): + can_view = forms.BooleanField(required=False) + can_add = forms.BooleanField(required=False) + can_change = forms.BooleanField(required=False) + can_delete = forms.BooleanField(required=False) + + class Meta: + model = ObjectPermission + exclude = [] + help_texts = { + 'actions': 'Actions granted in addition to those listed above', + 'constraints': 'JSON expression of a queryset filter that will return only permitted objects. Leave null ' + 'to match all objects of this type.' + } + labels = { + 'actions': 'Additional actions' + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Make the actions field optional since the admin form uses it only for non-CRUD actions + self.fields['actions'].required = False + + # Format ContentType choices + order_content_types(self.fields['object_types']) + self.fields['object_types'].choices.insert(0, ('', '---------')) + + # Order group and user fields + self.fields['groups'].queryset = self.fields['groups'].queryset.order_by('name') + self.fields['users'].queryset = self.fields['users'].queryset.order_by('username') + + # Check the appropriate checkboxes when editing an existing ObjectPermission + if self.instance.pk: + for action in ['view', 'add', 'change', 'delete']: + if action in self.instance.actions: + self.fields[f'can_{action}'].initial = True + self.instance.actions.remove(action) + + def clean(self): + object_types = self.cleaned_data['object_types'] + constraints = self.cleaned_data['constraints'] + + # Append any of the selected CRUD checkboxes to the actions list + if not self.cleaned_data.get('actions'): + self.cleaned_data['actions'] = list() + for action in ['view', 'add', 'change', 'delete']: + if self.cleaned_data[f'can_{action}'] and action not in self.cleaned_data['actions']: + self.cleaned_data['actions'].append(action) + + # At least one action must be specified + if not self.cleaned_data['actions']: + raise ValidationError("At least one action must be selected.") + + # Validate the specified model constraints by attempting to execute a query. We don't care whether the query + # returns anything; we just want to make sure the specified constraints are valid. + if constraints: + for ct in object_types: + model = ct.model_class() + try: + model.objects.filter(**constraints).exists() + except FieldError as e: + raise ValidationError({ + 'constraints': f'Invalid filter for {model}: {e}' + }) + + +@admin.register(ObjectPermission) +class ObjectPermissionAdmin(admin.ModelAdmin): + fieldsets = ( + ('Objects', { + 'fields': ('object_types',) + }), + ('Assignment', { + 'fields': ('groups', 'users') + }), + ('Actions', { + 'fields': (('can_view', 'can_add', 'can_change', 'can_delete'), 'actions') + }), + ('Constraints', { + 'fields': ('constraints',) + }), + ) + filter_horizontal = ('object_types', 'groups', 'users') + form = ObjectPermissionForm + list_display = [ + 'list_models', 'list_users', 'list_groups', 'actions', 'constraints', + ] + list_filter = [ + 'groups', 'users' + ] + + def get_queryset(self, request): + return super().get_queryset(request).prefetch_related('object_types', 'users', 'groups') + + def list_models(self, obj): + return ', '.join([f"{ct}" for ct in obj.object_types.all()]) + list_models.short_description = 'Models' + + def list_users(self, obj): + return ', '.join([u.username for u in obj.users.all()]) + list_users.short_description = 'Users' + + def list_groups(self, obj): + return ', '.join([g.name for g in obj.groups.all()]) + list_groups.short_description = 'Groups' diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py index d1b649713..f7721cf94 100644 --- a/netbox/users/api/nested_serializers.py +++ b/netbox/users/api/nested_serializers.py @@ -1,4 +1,4 @@ -from django.contrib.auth.models import User +from django.contrib.auth.models import Group, User from utilities.api import WritableNestedSerializer @@ -8,9 +8,16 @@ _all_ = [ # -# Users +# Groups and users # +class NestedGroupSerializer(WritableNestedSerializer): + + class Meta: + model = Group + fields = ['id', 'name'] + + class NestedUserSerializer(WritableNestedSerializer): class Meta: diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 86d350e69..dc5301846 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -1,4 +1,28 @@ +from django.contrib.contenttypes.models import ContentType + +from users.models import ObjectPermission +from utilities.api import ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer from .nested_serializers import * -# Placeholder for future serializers +class ObjectPermissionSerializer(ValidatedModelSerializer): + object_types = ContentTypeField( + queryset=ContentType.objects.all(), + many=True + ) + groups = SerializedPKRelatedField( + queryset=Group.objects.all(), + serializer=NestedGroupSerializer, + required=False, + many=True + ) + users = SerializedPKRelatedField( + queryset=User.objects.all(), + serializer=NestedUserSerializer, + required=False, + many=True + ) + + class Meta: + model = ObjectPermission + fields = ('id', 'object_types', 'groups', 'users', 'actions', 'constraints') diff --git a/netbox/users/api/urls.py b/netbox/users/api/urls.py new file mode 100644 index 000000000..fffea5968 --- /dev/null +++ b/netbox/users/api/urls.py @@ -0,0 +1,21 @@ +from rest_framework import routers + +from . import views + + +class UsersRootView(routers.APIRootView): + """ + Users API root view + """ + def get_view_name(self): + return 'Users' + + +router = routers.DefaultRouter() +router.APIRootView = UsersRootView + +# Permissions +router.register('permissions', views.ObjectPermissionViewSet) + +app_name = 'users-api' +urlpatterns = router.urls diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py new file mode 100644 index 000000000..74b315b44 --- /dev/null +++ b/netbox/users/api/views.py @@ -0,0 +1,14 @@ +from utilities.api import ModelViewSet +from . import serializers + +from users.models import ObjectPermission + + +# +# ObjectPermissions +# + +class ObjectPermissionViewSet(ModelViewSet): + queryset = ObjectPermission.objects.prefetch_related('object_types', 'groups', 'users') + serializer_class = serializers.ObjectPermissionSerializer + # filterset_class = filters.ObjectPermissionFilterSet diff --git a/netbox/users/migrations/0007_proxy_group_user.py b/netbox/users/migrations/0007_proxy_group_user.py new file mode 100644 index 000000000..2aec9e425 --- /dev/null +++ b/netbox/users/migrations/0007_proxy_group_user.py @@ -0,0 +1,46 @@ +# Generated by Django 3.0.6 on 2020-05-29 14:30 + +import django.contrib.auth.models +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0011_update_proxy_permissions'), + ('users', '0006_create_userconfigs'), + ] + + operations = [ + migrations.CreateModel( + name='AdminGroup', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + 'verbose_name': 'Group', + }, + bases=('auth.group',), + managers=[ + ('objects', django.contrib.auth.models.GroupManager()), + ], + ), + migrations.CreateModel( + name='AdminUser', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + 'verbose_name': 'User', + }, + bases=('auth.user',), + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/netbox/users/migrations/0008_objectpermission.py b/netbox/users/migrations/0008_objectpermission.py new file mode 100644 index 000000000..3f16e1ee8 --- /dev/null +++ b/netbox/users/migrations/0008_objectpermission.py @@ -0,0 +1,31 @@ +from django.conf import settings +import django.contrib.postgres.fields +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('auth', '0011_update_proxy_permissions'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('users', '0007_proxy_group_user'), + ] + + operations = [ + migrations.CreateModel( + name='ObjectPermission', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('constraints', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)), + ('actions', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=30), size=None)), + ('object_types', models.ManyToManyField(limit_choices_to={'app_label__in': ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'virtualization']}, related_name='object_permissions', to='contenttypes.ContentType')), + ('groups', models.ManyToManyField(blank=True, related_name='object_permissions', to='auth.Group')), + ('users', models.ManyToManyField(blank=True, related_name='object_permissions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Permission', + }, + ), + ] diff --git a/netbox/users/migrations/0009_replicate_permissions.py b/netbox/users/migrations/0009_replicate_permissions.py new file mode 100644 index 000000000..a5d28beac --- /dev/null +++ b/netbox/users/migrations/0009_replicate_permissions.py @@ -0,0 +1,47 @@ +from django.db import migrations + + +ACTIONS = ['view', 'add', 'change', 'delete'] + + +def replicate_permissions(apps, schema_editor): + """ + Replicate all Permission assignments as ObjectPermissions. + """ + Permission = apps.get_model('auth', 'Permission') + ObjectPermission = apps.get_model('users', 'ObjectPermission') + + # TODO: Optimize this iteration so that ObjectPermissions with identical sets of users and groups + # are combined into a single ObjectPermission instance. + for perm in Permission.objects.all(): + if perm.codename.split('_')[0] in ACTIONS: + action = perm.codename.split('_')[0] + elif perm.codename == 'activate_userkey': + action = 'change' + elif perm.codename == 'run_script': + action = 'run' + else: + action = perm.codename + + if perm.group_set.exists() or perm.user_set.exists(): + obj_perm = ObjectPermission(actions=[action]) + obj_perm.save() + obj_perm.object_types.add(perm.content_type) + if perm.group_set.exists(): + obj_perm.groups.add(*list(perm.group_set.all())) + if perm.user_set.exists(): + obj_perm.users.add(*list(perm.user_set.all())) + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0008_objectpermission'), + ] + + operations = [ + migrations.RunPython( + code=replicate_permissions, + reverse_code=migrations.RunPython.noop + ) + ] diff --git a/netbox/users/models.py b/netbox/users/models.py index ea5762232..fa3277456 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -1,8 +1,9 @@ import binascii import os -from django.contrib.auth.models import User -from django.contrib.postgres.fields import JSONField +from django.contrib.auth.models import Group, User +from django.contrib.contenttypes.models import ContentType +from django.contrib.postgres.fields import ArrayField, JSONField from django.core.validators import MinLengthValidator from django.db import models from django.db.models.signals import post_save @@ -13,11 +14,38 @@ from utilities.utils import flatten_dict __all__ = ( + 'ObjectPermission', 'Token', 'UserConfig', ) +# +# Proxy models for admin +# + +class AdminGroup(Group): + """ + Proxy contrib.auth.models.Group for the admin UI + """ + class Meta: + verbose_name = 'Group' + proxy = True + + +class AdminUser(User): + """ + Proxy contrib.auth.models.User for the admin UI + """ + class Meta: + verbose_name = 'User' + proxy = True + + +# +# User preferences +# + class UserConfig(models.Model): """ This model stores arbitrary user-specific preferences in a JSON data structure. @@ -138,6 +166,10 @@ def create_userconfig(instance, created, **kwargs): UserConfig(user=instance).save() +# +# REST API +# + class Token(models.Model): """ An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens. @@ -190,3 +222,51 @@ class Token(models.Model): if self.expires is None or timezone.now() < self.expires: return False return True + + +# +# Permissions +# + +class ObjectPermission(models.Model): + """ + A mapping of view, add, change, and/or delete permission for users and/or groups to an arbitrary set of objects + identified by ORM query parameters. + """ + object_types = models.ManyToManyField( + to=ContentType, + limit_choices_to={ + 'app_label__in': [ + 'circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'virtualization', + ], + }, + related_name='object_permissions' + ) + groups = models.ManyToManyField( + to=Group, + blank=True, + related_name='object_permissions' + ) + users = models.ManyToManyField( + to=User, + blank=True, + related_name='object_permissions' + ) + actions = ArrayField( + base_field=models.CharField(max_length=30), + help_text="The list of actions granted by this permission" + ) + constraints = JSONField( + blank=True, + null=True, + help_text="Queryset filter matching the applicable objects of the selected type(s)" + ) + + class Meta: + verbose_name = "Permission" + + def __str__(self): + return '{}: {}'.format( + ', '.join(self.object_types.values_list('model', flat=True)), + ', '.join(self.actions) + ) diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py new file mode 100644 index 000000000..f507192ee --- /dev/null +++ b/netbox/users/tests/test_api.py @@ -0,0 +1,144 @@ +from django.contrib.auth.models import Group, User +from django.contrib.contenttypes.models import ContentType +from django.urls import reverse +from rest_framework import status + +from users.models import ObjectPermission +from utilities.testing import APITestCase + + +class AppTest(APITestCase): + + def test_root(self): + + url = reverse('users-api:api-root') + response = self.client.get('{}?format=api'.format(url), **self.header) + + self.assertEqual(response.status_code, 200) + + +class ObjectPermissionTest(APITestCase): + + @classmethod + def setUpTestData(cls): + + groups = ( + Group(name='Group 1'), + Group(name='Group 2'), + Group(name='Group 3'), + ) + Group.objects.bulk_create(groups) + + users = ( + User(username='User 1', is_active=True), + User(username='User 2', is_active=True), + User(username='User 3', is_active=True), + ) + User.objects.bulk_create(users) + + object_type = ContentType.objects.get(app_label='dcim', model='device') + + for i in range(0, 3): + objectpermission = ObjectPermission( + actions=['view', 'add', 'change', 'delete'], + constraints={'name': f'TEST{i+1}'} + ) + objectpermission.save() + objectpermission.object_types.add(object_type) + objectpermission.groups.add(groups[i]) + objectpermission.users.add(users[i]) + + def test_get_objectpermission(self): + objectpermission = ObjectPermission.objects.first() + url = reverse('users-api:objectpermission-detail', kwargs={'pk': objectpermission.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['id'], objectpermission.pk) + + def test_list_objectpermissions(self): + url = reverse('users-api:objectpermission-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], ObjectPermission.objects.count()) + + def test_create_objectpermission(self): + data = { + 'object_types': ['dcim.site'], + 'groups': [Group.objects.first().pk], + 'users': [User.objects.first().pk], + 'actions': ['view', 'add', 'change', 'delete'], + 'constraints': {'name': 'TEST4'}, + } + + url = reverse('users-api:objectpermission-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(ObjectPermission.objects.count(), 4) + objectpermission = ObjectPermission.objects.get(pk=response.data['id']) + self.assertEqual(objectpermission.groups.first().pk, data['groups'][0]) + self.assertEqual(objectpermission.users.first().pk, data['users'][0]) + self.assertEqual(objectpermission.actions, data['actions']) + self.assertEqual(objectpermission.constraints, data['constraints']) + + def test_create_objectpermission_bulk(self): + groups = Group.objects.all()[:3] + users = User.objects.all()[:3] + data = [ + { + 'object_types': ['dcim.site'], + 'groups': [groups[0].pk], + 'users': [users[0].pk], + 'actions': ['view', 'add', 'change', 'delete'], + 'constraints': {'name': 'TEST4'}, + }, + { + 'object_types': ['dcim.site'], + 'groups': [groups[1].pk], + 'users': [users[1].pk], + 'actions': ['view', 'add', 'change', 'delete'], + 'constraints': {'name': 'TEST5'}, + }, + { + 'object_types': ['dcim.site'], + 'groups': [groups[2].pk], + 'users': [users[2].pk], + 'actions': ['view', 'add', 'change', 'delete'], + 'constraints': {'name': 'TEST6'}, + }, + ] + + url = reverse('users-api:objectpermission-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(ObjectPermission.objects.count(), 6) + + def test_update_objectpermission(self): + objectpermission = ObjectPermission.objects.first() + data = { + 'object_types': ['dcim.site', 'dcim.device'], + 'groups': [g.pk for g in Group.objects.all()[:2]], + 'users': [u.pk for u in User.objects.all()[:2]], + 'actions': ['view'], + 'constraints': {'name': 'TEST'}, + } + + url = reverse('users-api:objectpermission-detail', kwargs={'pk': objectpermission.pk}) + response = self.client.put(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(ObjectPermission.objects.count(), 3) + objectpermission = ObjectPermission.objects.get(pk=response.data['id']) + self.assertEqual(objectpermission.groups.first().pk, data['groups'][0]) + self.assertEqual(objectpermission.users.first().pk, data['users'][0]) + self.assertEqual(objectpermission.actions, data['actions']) + self.assertEqual(objectpermission.constraints, data['constraints']) + + def test_delete_objectpermission(self): + objectpermission = ObjectPermission.objects.first() + url = reverse('users-api:objectpermission-detail', kwargs={'pk': objectpermission.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(ObjectPermission.objects.count(), 2) diff --git a/netbox/users/views.py b/netbox/users/views.py index c3e366542..f88ff040c 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -3,7 +3,7 @@ import logging from django.conf import settings from django.contrib import messages from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash -from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin +from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import update_last_login from django.contrib.auth.signals import user_logged_in from django.http import HttpResponseForbidden, HttpResponseRedirect @@ -320,8 +320,7 @@ class TokenEditView(LoginRequiredMixin, View): }) -class TokenDeleteView(PermissionRequiredMixin, View): - permission_required = 'users.delete_token' +class TokenDeleteView(LoginRequiredMixin, View): def get(self, request, pk): diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 205055669..50401dfd1 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -4,17 +4,18 @@ from collections import OrderedDict import pytz from django.conf import settings from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist +from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist, PermissionDenied +from django.db import transaction from django.db.models import ManyToManyField, ProtectedError -from django.http import Http404 from django.urls import reverse from rest_framework.exceptions import APIException from rest_framework.permissions import BasePermission from rest_framework.relations import PrimaryKeyRelatedField, RelatedField from rest_framework.response import Response from rest_framework.serializers import Field, ModelSerializer, ValidationError -from rest_framework.viewsets import ModelViewSet as _ModelViewSet, ViewSet +from rest_framework.viewsets import ModelViewSet as _ModelViewSet +from netbox.api import TokenPermissions from .utils import dict_to_filter_params, dynamic_import @@ -323,6 +324,27 @@ class ModelViewSet(_ModelViewSet): logger.debug(f"Using serializer {self.serializer_class}") return self.serializer_class + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + + if not request.user.is_authenticated or request.user.is_superuser: + return + + # TODO: Reconcile this with TokenPermissions.perms_map + action = { + 'GET': 'view', + 'OPTIONS': None, + 'HEAD': 'view', + 'POST': 'add', + 'PUT': 'change', + 'PATCH': 'change', + 'DELETE': 'delete', + }[request.method] + + # Restrict the view's QuerySet to allow only the permitted objects + if action: + self.queryset = self.queryset.restrict(request.user, action) + def dispatch(self, request, *args, **kwargs): logger = logging.getLogger('netbox.api.views.ModelViewSet') @@ -341,34 +363,49 @@ class ModelViewSet(_ModelViewSet): **kwargs ) - def list(self, *args, **kwargs): + def _validate_objects(self, instance): """ - Call to super to allow for caching + Check that the provided instance or list of instances are matched by the current queryset. This confirms that + any newly created or modified objects abide by the attributes granted by any applicable ObjectPermissions. """ - return super().list(*args, **kwargs) - - def retrieve(self, *args, **kwargs): - """ - Call to super to allow for caching - """ - return super().retrieve(*args, **kwargs) - - # - # Logging - # + if type(instance) is list: + # Check that all instances are still included in the view's queryset + conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count() + if conforming_count != len(instance): + raise ObjectDoesNotExist + else: + # Check that the instance is matched by the view's queryset + self.queryset.get(pk=instance.pk) def perform_create(self, serializer): - model = serializer.child.Meta.model if hasattr(serializer, 'many') else serializer.Meta.model + model = self.queryset.model logger = logging.getLogger('netbox.api.views.ModelViewSet') logger.info(f"Creating new {model._meta.verbose_name}") - return super().perform_create(serializer) + + # Enforce object-level permissions on save() + try: + with transaction.atomic(): + instance = serializer.save() + self._validate_objects(instance) + except ObjectDoesNotExist: + raise PermissionDenied() def perform_update(self, serializer): + model = self.queryset.model logger = logging.getLogger('netbox.api.views.ModelViewSet') - logger.info(f"Updating {serializer.instance} (PK: {serializer.instance.pk})") - return super().perform_update(serializer) + logger.info(f"Updating {model._meta.verbose_name} {serializer.instance} (PK: {serializer.instance.pk})") + + # Enforce object-level permissions on save() + try: + with transaction.atomic(): + instance = serializer.save() + self._validate_objects(instance) + except ObjectDoesNotExist: + raise PermissionDenied() def perform_destroy(self, instance): + model = self.queryset.model logger = logging.getLogger('netbox.api.views.ModelViewSet') - logger.info(f"Deleting {instance} (PK: {instance.pk})") + logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})") + return super().perform_destroy(instance) diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py deleted file mode 100644 index 6342bad2b..000000000 --- a/netbox/utilities/auth_backends.py +++ /dev/null @@ -1,73 +0,0 @@ -import logging - -from django.conf import settings -from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as RemoteUserBackend_ -from django.contrib.auth.models import Group, Permission - - -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) - - -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(f"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 " - "._. (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 diff --git a/netbox/utilities/mptt.py b/netbox/utilities/mptt.py new file mode 100644 index 000000000..1bae2053d --- /dev/null +++ b/netbox/utilities/mptt.py @@ -0,0 +1,19 @@ +from mptt.managers import TreeManager as TreeManager_ +from mptt.querysets import TreeQuerySet as TreeQuerySet_ + +from django.db.models import Manager +from .querysets import RestrictedQuerySet + + +class TreeQuerySet(TreeQuerySet_, RestrictedQuerySet): + """ + Mate django-mptt's TreeQuerySet with our RestrictedQuerySet for permissions enforcement. + """ + pass + + +class TreeManager(Manager.from_queryset(TreeQuerySet), TreeManager_): + """ + Extend django-mptt's TreeManager to incorporate RestrictedQuerySet(). + """ + pass diff --git a/netbox/utilities/permissions.py b/netbox/utilities/permissions.py new file mode 100644 index 000000000..44c34942f --- /dev/null +++ b/netbox/utilities/permissions.py @@ -0,0 +1,74 @@ +from django.conf import settings +from django.contrib.contenttypes.models import ContentType + + +def get_permission_for_model(model, action): + """ + Resolve the named permission for a given model (or instance) and action (e.g. view or add). + + :param model: A model or instance + :param action: View, add, change, or delete (string) + """ + if action not in ('view', 'add', 'change', 'delete'): + raise ValueError(f"Unsupported action: {action}") + + return '{}.{}_{}'.format( + model._meta.app_label, + action, + model._meta.model_name + ) + + +def resolve_permission(name): + """ + Given a permission name, return the app_label, action, and model_name components. For example, "dcim.view_site" + returns ("dcim", "view", "site"). + + :param name: Permission name in the format ._ + """ + try: + app_label, codename = name.split('.') + action, model_name = codename.rsplit('_', 1) + except ValueError: + raise ValueError( + f"Invalid permission name: {name}. Must be in the format ._" + ) + + return app_label, action, model_name + + +def resolve_permission_ct(name): + """ + Given a permission name, return the relevant ContentType and action. For example, "dcim.view_site" returns + (Site, "view"). + + :param name: Permission name in the format ._ + """ + app_label, action, model_name = resolve_permission(name) + try: + content_type = ContentType.objects.get(app_label=app_label, model=model_name) + except ContentType.DoesNotExist: + raise ValueError(f"Unknown app_label/model_name for {name}") + + return content_type, action + + +def permission_is_exempt(name): + """ + Determine whether a specified permission is exempt from evaluation. + + :param name: Permission name in the format ._ + """ + app_label, action, model_name = resolve_permission(name) + + if action == 'view': + if ( + # All models are exempt from view permission enforcement + '*' in settings.EXEMPT_VIEW_PERMISSIONS + ) or ( + # This specific model is exempt from view permission enforcement + '{}.{}'.format(app_label, model_name) in settings.EXEMPT_VIEW_PERMISSIONS + ): + return True + + return False diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py index 34b7a0cf3..1ac79e90a 100644 --- a/netbox/utilities/querysets.py +++ b/netbox/utilities/querysets.py @@ -1,3 +1,8 @@ +from django.db.models import Q, QuerySet + +from utilities.permissions import permission_is_exempt + + class DummyQuerySet: """ A fake QuerySet that can be used to cache relationships to objects that have been deleted. @@ -7,3 +12,35 @@ class DummyQuerySet: def all(self): return self._cache + + +class RestrictedQuerySet(QuerySet): + + def restrict(self, user, action): + """ + Filter the QuerySet to return only objects on which the specified user has been granted the specified + permission. + + :param user: User instance + :param action: The action which must be permitted (e.g. "view" for "dcim.view_site") + """ + # Resolve the full name of the required permission + app_label = self.model._meta.app_label + model_name = self.model._meta.model_name + permission_required = f'{app_label}.{action}_{model_name}' + + # Bypass restriction for superusers and exempt views + if user.is_superuser or permission_is_exempt(permission_required): + return self + + # User is anonymous or has not been granted the requisite permission + if not user.is_authenticated or permission_required not in user.get_all_permissions(): + return self.none() + + # Filter the queryset to include only objects with allowed attributes + attrs = Q() + for perm_attrs in user._object_perm_cache[permission_required]: + if perm_attrs: + attrs |= Q(**perm_attrs) + + return self.filter(attrs) diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py index d10bb025a..0db0ff936 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -1,11 +1,13 @@ -from django.contrib.auth.models import Permission, User +from django.contrib.contenttypes.models import ContentType +from django.contrib.auth.models import User from django.core.exceptions import ObjectDoesNotExist from django.forms.models import model_to_dict from django.test import Client, TestCase as _TestCase, override_settings from django.urls import reverse, NoReverseMatch from rest_framework.test import APIClient -from users.models import Token +from users.models import ObjectPermission, Token +from utilities.permissions import resolve_permission_ct from .utils import disable_warnings, post_data @@ -31,18 +33,11 @@ class TestCase(_TestCase): Assign a set of permissions to the test user. Accepts permission names in the form ._. """ for name in names: - app, codename = name.split('.') - perm = Permission.objects.get(content_type__app_label=app, codename=codename) - self.user.user_permissions.add(perm) - - def remove_permissions(self, *names): - """ - Remove a set of permissions from the test user, if assigned. - """ - for name in names: - app, codename = name.split('.') - perm = Permission.objects.get(content_type__app_label=app, codename=codename) - self.user.user_permissions.remove(perm) + ct, action = resolve_permission_ct(name) + obj_perm = ObjectPermission(actions=[action]) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ct) # # Convenience methods @@ -149,28 +144,55 @@ class ViewTestCases: """ Retrieve a single instance. """ - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_get_object(self): - instance = self.model.objects.first() - - # Attempt to make the request without required permissions - with disable_warnings('django.request'): - self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 403) - - # Assign the required permission and submit again - self.add_permissions( - '{}.view_{}'.format(self.model._meta.app_label, self.model._meta.model_name) - ) - response = self.client.get(instance.get_absolute_url()) - self.assertHttpStatus(response, 200) - @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) - def test_list_objects_anonymous(self): + def test_get_object_anonymous(self): # Make the request as an unauthenticated user self.client.logout() response = self.client.get(self.model.objects.first().get_absolute_url()) self.assertHttpStatus(response, 200) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_get_object_without_permission(self): + instance = self.model.objects.first() + + # Try GET without permission + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 403) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_get_object_with_model_permission(self): + instance = self.model.objects.first() + + # Add model-level permission + obj_perm = ObjectPermission( + actions=['view'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + # Try GET with model-level permission + self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_get_object_with_object_permission(self): + instance1, instance2 = self.model.objects.all()[:2] + + # Add object-level permission + obj_perm = ObjectPermission( + constraints={'pk': instance1.pk}, + actions=['view'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + # Try GET to permitted object + self.assertHttpStatus(self.client.get(instance1.get_absolute_url()), 200) + + # Try GET to non-permitted object + self.assertHttpStatus(self.client.get(instance2.get_absolute_url()), 404) + class CreateObjectViewTestCase(ModelViewTestCase): """ Create a single new instance. @@ -178,33 +200,82 @@ class ViewTestCases: form_data = {} @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_create_object(self): + def test_create_object_without_permission(self): # Try GET without permission with disable_warnings('django.request'): self.assertHttpStatus(self.client.post(self._get_url('add')), 403) - # Try GET with permission - self.add_permissions( - '{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name) - ) - response = self.client.get(path=self._get_url('add')) - self.assertHttpStatus(response, 200) + # Try POST without permission + request = { + 'path': self._get_url('add'), + 'data': post_data(self.form_data), + } + response = self.client.post(**request) + with disable_warnings('django.request'): + self.assertHttpStatus(response, 403) - # Try POST with permission + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_create_object_with_model_permission(self): + initial_count = self.model.objects.count() + + # Assign model-level permission + obj_perm = ObjectPermission( + actions=['add'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + # Try GET with model-level permission + self.assertHttpStatus(self.client.get(self._get_url('add')), 200) + + # Try POST with model-level permission + request = { + 'path': self._get_url('add'), + 'data': post_data(self.form_data), + } + self.assertHttpStatus(self.client.post(**request), 302) + self.assertEqual(initial_count + 1, self.model.objects.count()) + self.assertInstanceEqual(self.model.objects.order_by('pk').last(), self.form_data) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_create_object_with_object_permission(self): + initial_count = self.model.objects.count() + + # Assign object-level permission + obj_perm = ObjectPermission( + constraints={'pk__gt': 0}, # Dummy permission to allow all + actions=['add'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + # Try GET with object-level permission + self.assertHttpStatus(self.client.get(self._get_url('add')), 200) + + # Try to create permitted object + request = { + 'path': self._get_url('add'), + 'data': post_data(self.form_data), + } + self.assertHttpStatus(self.client.post(**request), 302) + self.assertEqual(initial_count + 1, self.model.objects.count()) + self.assertInstanceEqual(self.model.objects.order_by('pk').last(), self.form_data) + + # Nullify ObjectPermission to disallow new object creation + obj_perm.constraints = {'pk': 0} + obj_perm.save() + + # Try to create a non-permitted object initial_count = self.model.objects.count() request = { 'path': self._get_url('add'), 'data': post_data(self.form_data), - 'follow': False, # Do not follow 302 redirects } - response = self.client.post(**request) - self.assertHttpStatus(response, 302) - - # Validate object creation - self.assertEqual(initial_count + 1, self.model.objects.count()) - instance = self.model.objects.order_by('-pk').first() - self.assertInstanceEqual(instance, self.form_data) + self.assertHttpStatus(self.client.post(**request), 200) + self.assertEqual(initial_count, self.model.objects.count()) # Check that no object was created class EditObjectViewTestCase(ModelViewTestCase): """ @@ -213,80 +284,189 @@ class ViewTestCases: form_data = {} @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_edit_object(self): + def test_edit_object_without_permission(self): instance = self.model.objects.first() # Try GET without permission with disable_warnings('django.request'): self.assertHttpStatus(self.client.post(self._get_url('edit', instance)), 403) - # Try GET with permission - self.add_permissions( - '{}.change_{}'.format(self.model._meta.app_label, self.model._meta.model_name) - ) - response = self.client.get(path=self._get_url('edit', instance)) - self.assertHttpStatus(response, 200) - - # Try POST with permission + # Try POST without permission request = { 'path': self._get_url('edit', instance), 'data': post_data(self.form_data), - 'follow': False, # Do not follow 302 redirects } - response = self.client.post(**request) - self.assertHttpStatus(response, 302) + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.post(**request), 403) - # Validate object modifications - instance = self.model.objects.get(pk=instance.pk) - self.assertInstanceEqual(instance, self.form_data) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_edit_object_with_model_permission(self): + instance = self.model.objects.first() + + # Assign model-level permission + obj_perm = ObjectPermission( + actions=['change'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + # Try GET with model-level permission + self.assertHttpStatus(self.client.get(self._get_url('edit', instance)), 200) + + # Try POST with model-level permission + request = { + 'path': self._get_url('edit', instance), + 'data': post_data(self.form_data), + } + self.assertHttpStatus(self.client.post(**request), 302) + self.assertInstanceEqual(self.model.objects.get(pk=instance.pk), self.form_data) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_edit_object_with_object_permission(self): + instance1, instance2 = self.model.objects.all()[:2] + + # Assign object-level permission + obj_perm = ObjectPermission( + constraints={'pk': instance1.pk}, + actions=['change'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + # Try GET with a permitted object + self.assertHttpStatus(self.client.get(self._get_url('edit', instance1)), 200) + + # Try GET with a non-permitted object + self.assertHttpStatus(self.client.get(self._get_url('edit', instance2)), 404) + + # Try to edit a permitted object + request = { + 'path': self._get_url('edit', instance1), + 'data': post_data(self.form_data), + } + self.assertHttpStatus(self.client.post(**request), 302) + self.assertInstanceEqual(self.model.objects.get(pk=instance1.pk), self.form_data) + + # Try to edit a non-permitted object + request = { + 'path': self._get_url('edit', instance2), + 'data': post_data(self.form_data), + } + self.assertHttpStatus(self.client.post(**request), 404) class DeleteObjectViewTestCase(ModelViewTestCase): """ Delete a single instance. """ @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_delete_object(self): + def test_delete_object_without_permission(self): instance = self.model.objects.first() - # Try GET without permissions + # Try GET without permission with disable_warnings('django.request'): - self.assertHttpStatus(self.client.post(self._get_url('delete', instance)), 403) - - # Try GET with permission - self.add_permissions( - '{}.delete_{}'.format(self.model._meta.app_label, self.model._meta.model_name) - ) - response = self.client.get(path=self._get_url('delete', instance)) - self.assertHttpStatus(response, 200) + self.assertHttpStatus(self.client.get(self._get_url('delete', instance)), 403) + # Try POST without permission request = { 'path': self._get_url('delete', instance), - 'data': {'confirm': True}, - 'follow': False, # Do not follow 302 redirects + 'data': post_data({'confirm': True}), } - response = self.client.post(**request) - self.assertHttpStatus(response, 302) + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.post(**request), 403) - # Validate object deletion + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_delete_object_with_model_permission(self): + instance = self.model.objects.first() + + # Assign model-level permission + obj_perm = ObjectPermission( + actions=['delete'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + # Try GET with model-level permission + self.assertHttpStatus(self.client.get(self._get_url('delete', instance)), 200) + + # Try POST with model-level permission + request = { + 'path': self._get_url('delete', instance), + 'data': post_data({'confirm': True}), + } + self.assertHttpStatus(self.client.post(**request), 302) with self.assertRaises(ObjectDoesNotExist): self.model.objects.get(pk=instance.pk) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_delete_object_with_object_permission(self): + instance1, instance2 = self.model.objects.all()[:2] + + # Assign object-level permission + obj_perm = ObjectPermission( + constraints={'pk': instance1.pk}, + actions=['delete'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + # Try GET with a permitted object + self.assertHttpStatus(self.client.get(self._get_url('delete', instance1)), 200) + + # Try GET with a non-permitted object + self.assertHttpStatus(self.client.get(self._get_url('delete', instance2)), 404) + + # Try to delete a permitted object + request = { + 'path': self._get_url('delete', instance1), + 'data': post_data({'confirm': True}), + } + self.assertHttpStatus(self.client.post(**request), 302) + with self.assertRaises(ObjectDoesNotExist): + self.model.objects.get(pk=instance1.pk) + + # Try to delete a non-permitted object + request = { + 'path': self._get_url('delete', instance2), + 'data': post_data({'confirm': True}), + } + self.assertHttpStatus(self.client.post(**request), 404) + self.assertTrue(self.model.objects.filter(pk=instance2.pk).exists()) + class ListObjectsViewTestCase(ModelViewTestCase): """ Retrieve multiple instances. """ + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_list_objects_anonymous(self): + # Make the request as an unauthenticated user + self.client.logout() + response = self.client.get(self._get_url('list')) + self.assertHttpStatus(response, 200) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_list_objects(self): - # Attempt to make the request without required permissions + def test_list_objects_without_permission(self): + + # Try GET without permission with disable_warnings('django.request'): self.assertHttpStatus(self.client.get(self._get_url('list')), 403) - # Assign the required permission and submit again - self.add_permissions( - '{}.view_{}'.format(self.model._meta.app_label, self.model._meta.model_name) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_list_objects_with_model_permission(self): + + # Add model-level permission + obj_perm = ObjectPermission( + actions=['view'] ) - response = self.client.get(self._get_url('list')) - self.assertHttpStatus(response, 200) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + # Try GET with model-level permission + self.assertHttpStatus(self.client.get(self._get_url('list')), 200) # Built-in CSV export if hasattr(self.model, 'csv_headers'): @@ -294,12 +474,23 @@ class ViewTestCases: self.assertHttpStatus(response, 200) self.assertEqual(response.get('Content-Type'), 'text/csv') - @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) - def test_list_objects_anonymous(self): - # Make the request as an unauthenticated user - self.client.logout() - response = self.client.get(self._get_url('list')) - self.assertHttpStatus(response, 200) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_list_objects_with_object_permission(self): + instance1, instance2 = self.model.objects.all()[:2] + + # Add object-level permission + obj_perm = ObjectPermission( + constraints={'pk': instance1.pk}, + actions=['view'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + # Try GET with object-level permission + self.assertHttpStatus(self.client.get(self._get_url('list')), 200) + + # TODO: Verify that only the permitted object is returned class BulkCreateObjectsViewTestCase(ModelViewTestCase): """ @@ -314,17 +505,18 @@ class ViewTestCases: request = { 'path': self._get_url('add'), 'data': post_data(self.bulk_create_data), - 'follow': False, # Do not follow 302 redirects } # Attempt to make the request without required permissions with disable_warnings('django.request'): self.assertHttpStatus(self.client.post(**request), 403) - # Assign the required permission and submit again - self.add_permissions( - '{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name) - ) + # Assign object-level permission + obj_perm = ObjectPermission(actions=['add']) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + response = self.client.post(**request) self.assertHttpStatus(response, 302) @@ -332,41 +524,74 @@ class ViewTestCases: for instance in self.model.objects.order_by('-pk')[:self.bulk_create_count]: self.assertInstanceEqual(instance, self.bulk_create_data) - class ImportObjectsViewTestCase(ModelViewTestCase): + class BulkImportObjectsViewTestCase(ModelViewTestCase): """ Create multiple instances from imported data. """ csv_data = () + def _get_csv_data(self): + return '\n'.join(self.csv_data) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_import_objects(self): + def test_bulk_import_objects_without_permission(self): + data = { + 'csv': self._get_csv_data(), + } # Test GET without permission with disable_warnings('django.request'): self.assertHttpStatus(self.client.get(self._get_url('import')), 403) - # Test GET with permission - self.add_permissions( - '{}.view_{}'.format(self.model._meta.app_label, self.model._meta.model_name), - '{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name) + # Try POST without permission + response = self.client.post(self._get_url('import'), data) + with disable_warnings('django.request'): + self.assertHttpStatus(response, 403) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_bulk_import_objects_with_model_permission(self): + initial_count = self.model.objects.count() + data = { + 'csv': self._get_csv_data(), + } + + # Assign model-level permission + obj_perm = ObjectPermission( + actions=['add'] ) - response = self.client.get(self._get_url('import')) - self.assertHttpStatus(response, 200) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + # Try GET with model-level permission + self.assertHttpStatus(self.client.get(self._get_url('import')), 200) # Test POST with permission - initial_count = self.model.objects.count() - request = { - 'path': self._get_url('import'), - 'data': { - 'csv': '\n'.join(self.csv_data) - } - } - response = self.client.post(**request) - self.assertHttpStatus(response, 200) - - # Validate import of new objects + self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_bulk_import_objects_with_object_permission(self): + initial_count = self.model.objects.count() + data = { + 'csv': self._get_csv_data(), + } + + # Assign object-level permission + obj_perm = ObjectPermission( + constraints={'pk__gt': 0}, # Dummy permission to allow all + actions=['add'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + # Test import with object-level permission + self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) + self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1) + + # TODO: Test importing non-permitted objects + class BulkEditObjectsViewTestCase(ModelViewTestCase): """ Edit multiple instances. @@ -374,75 +599,145 @@ class ViewTestCases: bulk_edit_data = {} @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_bulk_edit_objects(self): - # Bulk edit the first three objects only + def test_bulk_edit_objects_without_permission(self): pk_list = self.model.objects.values_list('pk', flat=True)[:3] + data = { + 'pk': pk_list, + '_apply': True, # Form button + } - request = { - 'path': self._get_url('bulk_edit'), - 'data': { - 'pk': pk_list, - '_apply': True, # Form button - }, - 'follow': False, # Do not follow 302 redirects + # Test GET without permission + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.get(self._get_url('bulk_edit')), 403) + + # Try POST without permission + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 403) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_bulk_edit_objects_with_model_permission(self): + pk_list = self.model.objects.values_list('pk', flat=True)[:3] + data = { + 'pk': pk_list, + '_apply': True, # Form button } # Append the form data to the request - request['data'].update(post_data(self.bulk_edit_data)) + data.update(post_data(self.bulk_edit_data)) - # Attempt to make the request without required permissions - with disable_warnings('django.request'): - self.assertHttpStatus(self.client.post(**request), 403) - - # Assign the required permission and submit again - self.add_permissions( - '{}.change_{}'.format(self.model._meta.app_label, self.model._meta.model_name) + # Assign model-level permission + obj_perm = ObjectPermission( + actions=['change'] ) - response = self.client.post(**request) - self.assertHttpStatus(response, 302) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + # Try POST with model-level permission + self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302) for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)): self.assertInstanceEqual(instance, self.bulk_edit_data) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_bulk_edit_objects_with_object_permission(self): + pk_list = self.model.objects.values_list('pk', flat=True)[:3] + data = { + 'pk': pk_list, + '_apply': True, # Form button + } + + # Append the form data to the request + data.update(post_data(self.bulk_edit_data)) + + # Assign object-level permission + obj_perm = ObjectPermission( + constraints={'pk__in': list(pk_list)}, + actions=['change'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + # Try POST with model-level permission + self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302) + for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)): + self.assertInstanceEqual(instance, self.bulk_edit_data) + + # TODO: Test editing non-permitted objects + class BulkDeleteObjectsViewTestCase(ModelViewTestCase): """ Delete multiple instances. """ @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_bulk_delete_objects(self): - pk_list = self.model.objects.values_list('pk', flat=True) - - request = { - 'path': self._get_url('bulk_delete'), - 'data': { - 'pk': pk_list, - 'confirm': True, - '_confirm': True, # Form button - }, - 'follow': False, # Do not follow 302 redirects + def test_bulk_delete_objects_without_permission(self): + pk_list = self.model.objects.values_list('pk', flat=True)[:3] + data = { + 'pk': pk_list, + 'confirm': True, + '_confirm': True, # Form button } - # Attempt to make the request without required permissions + # Test GET without permission with disable_warnings('django.request'): - self.assertHttpStatus(self.client.post(**request), 403) + self.assertHttpStatus(self.client.get(self._get_url('bulk_delete')), 403) - # Assign the required permission and submit again - self.add_permissions( - '{}.delete_{}'.format(self.model._meta.app_label, self.model._meta.model_name) + # Try POST without permission + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 403) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_bulk_delete_objects_with_model_permission(self): + pk_list = self.model.objects.values_list('pk', flat=True) + data = { + 'pk': pk_list, + 'confirm': True, + '_confirm': True, # Form button + } + + # Assign model-level permission + obj_perm = ObjectPermission( + actions=['delete'] ) - response = self.client.post(**request) - self.assertHttpStatus(response, 302) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) - # Check that all objects were deleted + # Try POST with model-level permission + self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302) self.assertEqual(self.model.objects.count(), 0) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_bulk_delete_objects_with_object_permission(self): + pk_list = self.model.objects.values_list('pk', flat=True) + data = { + 'pk': pk_list, + 'confirm': True, + '_confirm': True, # Form button + } + + # Assign object-level permission + obj_perm = ObjectPermission( + constraints={'pk__in': list(pk_list)}, + actions=['delete'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + # Try POST with object-level permission + self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302) + self.assertEqual(self.model.objects.count(), 0) + + # TODO: Test deleting non-permitted objects + class PrimaryObjectViewTestCase( GetObjectViewTestCase, CreateObjectViewTestCase, EditObjectViewTestCase, DeleteObjectViewTestCase, ListObjectsViewTestCase, - ImportObjectsViewTestCase, + BulkImportObjectsViewTestCase, BulkEditObjectsViewTestCase, BulkDeleteObjectsViewTestCase, ): @@ -455,7 +750,7 @@ class ViewTestCases: CreateObjectViewTestCase, EditObjectViewTestCase, ListObjectsViewTestCase, - ImportObjectsViewTestCase, + BulkImportObjectsViewTestCase, BulkDeleteObjectsViewTestCase, ): """ @@ -480,7 +775,7 @@ class ViewTestCases: DeleteObjectViewTestCase, ListObjectsViewTestCase, BulkCreateObjectsViewTestCase, - ImportObjectsViewTestCase, + BulkImportObjectsViewTestCase, BulkEditObjectsViewTestCase, BulkDeleteObjectsViewTestCase, ): diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 8600b5897..9271e1c64 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -5,7 +5,8 @@ from copy import deepcopy from django.contrib import messages from django.contrib.auth.decorators import login_required from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import FieldDoesNotExist, ValidationError +from django.contrib.auth.mixins import AccessMixin +from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured, ObjectDoesNotExist, ValidationError from django.db import transaction, IntegrityError from django.db.models import ManyToManyField, ProtectedError from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea @@ -27,12 +28,63 @@ from extras.models import CustomField, CustomFieldValue, ExportTemplate from extras.querysets import CustomFieldQueryset from utilities.exceptions import AbortTransaction from utilities.forms import BootstrapMixin, CSVDataField, TableConfigForm +from utilities.permissions import get_permission_for_model, resolve_permission from utilities.utils import csv_format, prepare_cloned_fields from .error_handlers import handle_protectederror from .forms import ConfirmationForm, ImportForm from .paginator import EnhancedPaginator, get_paginate_count +# +# Mixins +# + +class ObjectPermissionRequiredMixin(AccessMixin): + """ + Similar to Django's built-in PermissionRequiredMixin, but extended to check for both model-level and object-level + permission assignments. If the user has only object-level permissions assigned, the view's queryset is filtered + to return only those objects on which the user is permitted to perform the specified action. + + additional_permissions: An optional iterable of statically declared permissions to evaluate in addition to those + derived from the object type + """ + additional_permissions = list() + + def get_required_permission(self): + """ + Return the specific permission necessary to perform the requested action on an object. + """ + raise NotImplementedError(f"{self.__class__.__name__} must implement get_required_permission()") + + def has_permission(self): + user = self.request.user + permission_required = self.get_required_permission() + + # Check that the user has been granted the required permission(s). + if user.has_perms((permission_required, *self.additional_permissions)): + + # Update the view's QuerySet to filter only the permitted objects + action = resolve_permission(permission_required)[1] + self.queryset = self.queryset.restrict(user, action) + + return True + + return False + + def dispatch(self, request, *args, **kwargs): + + if not hasattr(self, 'queryset'): + raise ImproperlyConfigured( + '{} has no queryset defined. ObjectPermissionRequiredMixin may only be used on views which define ' + 'a base queryset'.format(self.__class__.__name__) + ) + + if not self.has_permission(): + return self.handle_no_permission() + + return super().dispatch(request, *args, **kwargs) + + class GetReturnURLMixin(object): """ Provides logic for determining where a user should be redirected after processing a form. @@ -59,7 +111,23 @@ class GetReturnURLMixin(object): return reverse('home') -class ObjectListView(View): +# +# Generic views +# + +class ObjectView(ObjectPermissionRequiredMixin, View): + """ + Retrieve a single object for display. + + queryset: The base queryset for retrieving the object. + """ + queryset = None + + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'view') + + +class ObjectListView(ObjectPermissionRequiredMixin, View): """ List a series of objects. @@ -76,6 +144,9 @@ class ObjectListView(View): template_name = 'utilities/obj_list.html' action_buttons = ('add', 'import', 'export') + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'view') + def queryset_to_yaml(self): """ Export the queryset of objects as concatenated YAML documents. @@ -162,7 +233,7 @@ class ObjectListView(View): # Compile a dictionary indicating which permissions are available to the current user for this model permissions = {} for action in ('add', 'change', 'delete', 'view'): - perm_name = '{}.{}_{}'.format(model._meta.app_label, action, model._meta.model_name) + perm_name = get_permission_for_model(model, action) permissions[action] = request.user.has_perm(perm_name) # Construct the table based on the user's permissions @@ -218,7 +289,7 @@ class ObjectListView(View): return {} -class ObjectEditView(GetReturnURLMixin, View): +class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Create or edit a single object. @@ -230,6 +301,11 @@ class ObjectEditView(GetReturnURLMixin, View): model_form = None template_name = 'utilities/obj_edit.html' + def get_required_permission(self): + # self._permission_action is set by dispatch() to either "add" or "change" depending on whether + # we are modifying an existing object or creating a new one. + return get_permission_for_model(self.queryset.model, self._permission_action) + def get_object(self, kwargs): # Look up an existing object by slug or PK, if provided. if 'slug' in kwargs: @@ -245,68 +321,87 @@ class ObjectEditView(GetReturnURLMixin, View): return obj def dispatch(self, request, *args, **kwargs): - self.obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs) + # Determine required permission based on whether we are editing an existing object + self._permission_action = 'change' if kwargs else 'add' return super().dispatch(request, *args, **kwargs) def get(self, request, *args, **kwargs): + obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs) + # Parse initial data manually to avoid setting field values as lists initial_data = {k: request.GET[k] for k in request.GET} - form = self.model_form(instance=self.obj, initial=initial_data) + form = self.model_form(instance=obj, initial=initial_data) return render(request, self.template_name, { - 'obj': self.obj, + 'obj': obj, 'obj_type': self.queryset.model._meta.verbose_name, 'form': form, - 'return_url': self.get_return_url(request, self.obj), + 'return_url': self.get_return_url(request, obj), }) def post(self, request, *args, **kwargs): logger = logging.getLogger('netbox.views.ObjectEditView') - form = self.model_form(request.POST, request.FILES, instance=self.obj) + obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs) + form = self.model_form( + data=request.POST, + files=request.FILES, + instance=obj + ) if form.is_valid(): logger.debug("Form validation was successful") - obj = form.save() - msg = '{} {}'.format( - 'Created' if not form.instance.pk else 'Modified', - self.queryset.model._meta.verbose_name - ) - logger.info(f"{msg} {obj} (PK: {obj.pk})") - if hasattr(obj, 'get_absolute_url'): - msg = '{} {}'.format(msg, obj.get_absolute_url(), escape(obj)) - else: - msg = '{} {}'.format(msg, escape(obj)) - messages.success(request, mark_safe(msg)) + try: + with transaction.atomic(): + obj = form.save() - if '_addanother' in request.POST: + # Check that the new object conforms with any assigned object-level permissions + self.queryset.get(pk=obj.pk) - # If the object has clone_fields, pre-populate a new instance of the form - if hasattr(obj, 'clone_fields'): - url = '{}?{}'.format(request.path, prepare_cloned_fields(obj)) - return redirect(url) + msg = '{} {}'.format( + 'Created' if not form.instance.pk else 'Modified', + self.queryset.model._meta.verbose_name + ) + logger.info(f"{msg} {obj} (PK: {obj.pk})") + if hasattr(obj, 'get_absolute_url'): + msg = '{} {}'.format(msg, obj.get_absolute_url(), escape(obj)) + else: + msg = '{} {}'.format(msg, escape(obj)) + messages.success(request, mark_safe(msg)) - return redirect(request.get_full_path()) + if '_addanother' in request.POST: - return_url = form.cleaned_data.get('return_url') - if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()): - return redirect(return_url) - else: - return redirect(self.get_return_url(request, obj)) + # If the object has clone_fields, pre-populate a new instance of the form + if hasattr(obj, 'clone_fields'): + url = '{}?{}'.format(request.path, prepare_cloned_fields(obj)) + return redirect(url) + + return redirect(request.get_full_path()) + + return_url = form.cleaned_data.get('return_url') + if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()): + return redirect(return_url) + else: + return redirect(self.get_return_url(request, obj)) + + except ObjectDoesNotExist: + msg = "Object save failed due to object-level permissions violation" + logger.debug(msg) + form.add_error(None, msg) else: logger.debug("Form validation failed") return render(request, self.template_name, { - 'obj': self.obj, + 'obj': obj, 'obj_type': self.queryset.model._meta.verbose_name, 'form': form, - 'return_url': self.get_return_url(request, self.obj), + 'return_url': self.get_return_url(request, obj), }) -class ObjectDeleteView(GetReturnURLMixin, View): +class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Delete a single object. @@ -316,6 +411,9 @@ class ObjectDeleteView(GetReturnURLMixin, View): queryset = None template_name = 'utilities/obj_delete.html' + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'delete') + def get_object(self, kwargs): # Look up object by slug if one has been provided. Otherwise, use PK. if 'slug' in kwargs: @@ -370,20 +468,25 @@ class ObjectDeleteView(GetReturnURLMixin, View): }) -class BulkCreateView(GetReturnURLMixin, View): +class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Create new objects in bulk. + queryset: Base queryset for the objects being created form: Form class which provides the `pattern` field model_form: The ModelForm used to create individual objects pattern_target: Name of the field to be evaluated as a pattern (if any) template_name: The name of the template """ + queryset = None form = None model_form = None pattern_target = '' template_name = None + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'add') + def get(self, request): # Set initial values for visible form fields from query args initial = {} @@ -403,7 +506,7 @@ class BulkCreateView(GetReturnURLMixin, View): def post(self, request): logger = logging.getLogger('netbox.views.BulkCreateView') - model = self.model_form._meta.model + model = self.queryset.model form = self.form(request.POST) model_form = self.model_form(request.POST) @@ -436,6 +539,10 @@ class BulkCreateView(GetReturnURLMixin, View): # Raise an IntegrityError to break the for loop and abort the transaction. raise IntegrityError() + # Enforce object-level permissions + if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs): + raise ObjectDoesNotExist + # If we make it to this point, validation has succeeded on all new objects. msg = "Added {} {}".format(len(new_objs), model._meta.verbose_name_plural) logger.info(msg) @@ -448,6 +555,11 @@ class BulkCreateView(GetReturnURLMixin, View): except IntegrityError: pass + except ObjectDoesNotExist: + msg = "Object creation failed due to object-level permissions violation" + logger.debug(msg) + form.add_error(None, msg) + else: logger.debug("Form validation failed") @@ -459,21 +571,29 @@ class BulkCreateView(GetReturnURLMixin, View): }) -class ObjectImportView(GetReturnURLMixin, View): +class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Import a single object (YAML or JSON format). + + queryset: Base queryset for the objects being created + model_form: The ModelForm used to create individual objects + related_object_forms: A dictionary mapping of forms to be used for the creation of related (child) objects + template_name: The name of the template """ - model = None + queryset = None model_form = None related_object_forms = dict() template_name = 'utilities/obj_import.html' + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'add') + def get(self, request): form = ImportForm() return render(request, self.template_name, { 'form': form, - 'obj_type': self.model._meta.verbose_name, + 'obj_type': self.queryset.model._meta.verbose_name, 'return_url': self.get_return_url(request), }) @@ -503,12 +623,17 @@ class ObjectImportView(GetReturnURLMixin, View): # Save the primary object obj = model_form.save() + + # Enforce object-level permissions + self.queryset.get(pk=obj.pk) + logger.debug(f"Created {obj} (PK: {obj.pk})") # Iterate through the related object forms (if any), validating and saving each instance. for field_name, related_object_form in self.related_object_forms.items(): logger.debug("Processing form for related objects: {related_object_form}") + related_obj_pks = [] for i, rel_obj_data in enumerate(data.get(field_name, list())): f = related_object_form(obj, rel_obj_data) @@ -518,7 +643,8 @@ class ObjectImportView(GetReturnURLMixin, View): f.data[subfield_name] = field.initial if f.is_valid(): - f.save() + related_obj = f.save() + related_obj_pks.append(related_obj.pk) else: # Replicate errors on the related object form to the primary form for display for subfield_name, errors in f.errors.items(): @@ -527,9 +653,19 @@ class ObjectImportView(GetReturnURLMixin, View): model_form.add_error(None, err_msg) raise AbortTransaction() + # Enforce object-level permissions on related objects + model = related_object_form.Meta.model + if model.objects.filter(pk__in=related_obj_pks).count() != len(related_obj_pks): + raise ObjectDoesNotExist + except AbortTransaction: pass + except ObjectDoesNotExist: + msg = "Object creation failed due to object-level permissions violation" + logger.debug(msg) + form.add_error(None, msg) + if not model_form.errors: logger.info(f"Import object {obj} (PK: {obj.pk})") messages.success(request, mark_safe('Imported object: {}'.format( @@ -561,20 +697,22 @@ class ObjectImportView(GetReturnURLMixin, View): return render(request, self.template_name, { 'form': form, - 'obj_type': self.model._meta.verbose_name, + 'obj_type': self.queryset.model._meta.verbose_name, 'return_url': self.get_return_url(request), }) -class BulkImportView(GetReturnURLMixin, View): +class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Import objects in bulk (CSV format). + queryset: Base queryset for the model model_form: The form used to create each imported object table: The django-tables2 Table used to render the list of imported objects template_name: The name of the template widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key) """ + queryset = None model_form = None table = None template_name = 'utilities/obj_bulk_import.html' @@ -596,6 +734,9 @@ class BulkImportView(GetReturnURLMixin, View): """ return obj_form.save() + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'add') + def get(self, request): return render(request, self.template_name, { @@ -628,6 +769,10 @@ class BulkImportView(GetReturnURLMixin, View): form.add_error('csv', "Row {} {}: {}".format(row, field, err[0])) raise ValidationError("") + # Enforce object-level permissions + if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs): + raise ObjectDoesNotExist + # Compile a table containing the imported objects obj_table = self.table(new_objs) @@ -644,6 +789,11 @@ class BulkImportView(GetReturnURLMixin, View): except ValidationError: pass + except ObjectDoesNotExist: + msg = "Object import failed due to object-level permissions violation" + logger.debug(msg) + form.add_error(None, msg) + else: logger.debug("Form validation failed") @@ -655,7 +805,7 @@ class BulkImportView(GetReturnURLMixin, View): }) -class BulkEditView(GetReturnURLMixin, View): +class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Edit objects in bulk. @@ -671,6 +821,9 @@ class BulkEditView(GetReturnURLMixin, View): form = None template_name = 'utilities/obj_bulk_edit.html' + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'change') + def get(self, request): return redirect(self.get_return_url(request)) @@ -681,7 +834,7 @@ class BulkEditView(GetReturnURLMixin, View): # If we are editing *all* objects in the queryset, replace the PK list with all matched objects. if request.POST.get('_all') and self.filterset is not None: pk_list = [ - obj.pk for obj in self.filterset(request.GET, model.objects.only('pk')).qs + obj.pk for obj in self.filterset(request.GET, self.queryset.only('pk')).qs ] else: pk_list = request.POST.getlist('pk') @@ -701,8 +854,8 @@ class BulkEditView(GetReturnURLMixin, View): with transaction.atomic(): - updated_count = 0 - for obj in model.objects.filter(pk__in=form.cleaned_data['pk']): + updated_objects = [] + for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']): # Update standard fields. If a field is listed in _nullify, delete its value. for name in standard_fields: @@ -730,6 +883,7 @@ class BulkEditView(GetReturnURLMixin, View): obj.full_clean() obj.save() + updated_objects.append(obj) logger.debug(f"Saved {obj} (PK: {obj.pk})") # Update custom fields @@ -759,10 +913,12 @@ class BulkEditView(GetReturnURLMixin, View): if form.cleaned_data.get('remove_tags', None): obj.tags.remove(*form.cleaned_data['remove_tags']) - updated_count += 1 + # Enforce object-level permissions + if self.queryset.filter(pk__in=[obj.pk for obj in updated_objects]).count() != len(updated_objects): + raise ObjectDoesNotExist - if updated_count: - msg = 'Updated {} {}'.format(updated_count, model._meta.verbose_name_plural) + if updated_objects: + msg = 'Updated {} {}'.format(len(updated_objects), model._meta.verbose_name_plural) logger.info(msg) messages.success(self.request, msg) @@ -771,6 +927,11 @@ class BulkEditView(GetReturnURLMixin, View): except ValidationError as e: messages.error(self.request, "{} failed validation: {}".format(obj, e)) + except ObjectDoesNotExist: + msg = "Object update failed due to object-level permissions violation" + logger.debug(msg) + form.add_error(None, msg) + else: logger.debug("Form validation failed") @@ -800,7 +961,7 @@ class BulkEditView(GetReturnURLMixin, View): }) -class BulkDeleteView(GetReturnURLMixin, View): +class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Delete objects in bulk. @@ -816,6 +977,9 @@ class BulkDeleteView(GetReturnURLMixin, View): form = None template_name = 'utilities/obj_bulk_delete.html' + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'delete') + def get(self, request): return redirect(self.get_return_url(request)) @@ -893,28 +1057,32 @@ class BulkDeleteView(GetReturnURLMixin, View): # # TODO: Replace with BulkCreateView -class ComponentCreateView(GetReturnURLMixin, View): +class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine. """ - model = None + queryset = None form = None model_form = None template_name = None + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'add') + def get(self, request): form = self.form(initial=request.GET) return render(request, self.template_name, { - 'component_type': self.model._meta.verbose_name, + 'component_type': self.queryset.model._meta.verbose_name, 'form': form, 'return_url': self.get_return_url(request), }) def post(self, request): - + logger = logging.getLogger('netbox.views.ComponentCreateView') form = self.form(request.POST, initial=request.GET) + if form.is_valid(): new_components = [] @@ -940,33 +1108,48 @@ class ComponentCreateView(GetReturnURLMixin, View): if not form.errors: - # Create the new components - for component_form in new_components: - component_form.save() + try: - messages.success(request, "Added {} {}".format( - len(new_components), self.model._meta.verbose_name_plural - )) - if '_addanother' in request.POST: - return redirect(request.get_full_path()) - else: - return redirect(self.get_return_url(request)) + with transaction.atomic(): + + # Create the new components + new_objs = [] + for component_form in new_components: + obj = component_form.save() + new_objs.append(obj) + + # Enforce object-level permissions + if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs): + raise ObjectDoesNotExist + + messages.success(request, "Added {} {}".format( + len(new_components), self.queryset.model._meta.verbose_name_plural + )) + if '_addanother' in request.POST: + return redirect(request.get_full_path()) + else: + return redirect(self.get_return_url(request)) + + except ObjectDoesNotExist: + msg = "Component creation failed due to object-level permissions violation" + logger.debug(msg) + form.add_error(None, msg) return render(request, self.template_name, { - 'component_type': self.model._meta.verbose_name, + 'component_type': self.queryset.model._meta.verbose_name, 'form': form, 'return_url': self.get_return_url(request), }) -class BulkComponentCreateView(GetReturnURLMixin, View): +class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): """ Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines. """ parent_model = None parent_field = None form = None - model = None + queryset = None model_form = None filterset = None table = None @@ -975,7 +1158,7 @@ class BulkComponentCreateView(GetReturnURLMixin, View): def post(self, request): logger = logging.getLogger('netbox.views.BulkComponentCreateView') parent_model_name = self.parent_model._meta.verbose_name_plural - model_name = self.model._meta.verbose_name_plural + model_name = self.queryset.model._meta.verbose_name_plural # Are we editing *all* objects in the queryset or just a selected subset? if request.POST.get('_all') and self.filterset is not None: @@ -1020,9 +1203,18 @@ class BulkComponentCreateView(GetReturnURLMixin, View): for e in errors: form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e))) + # Enforce object-level permissions + if self.queryset.filter(pk__in=[obj.pk for obj in new_components]).count() != len(new_components): + raise ObjectDoesNotExist + except IntegrityError: pass + except ObjectDoesNotExist: + msg = "Component creation failed due to object-level permissions violation" + logger.debug(msg) + form.add_error(None, msg) + if not form.errors: msg = "Added {} {} to {} {}.".format( len(new_components), diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 3daeff013..8ad40bab7 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -9,6 +9,7 @@ from dcim.models import Device from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem from extras.utils import extras_features from utilities.models import ChangeLoggedModel +from utilities.querysets import RestrictedQuerySet from .choices import * @@ -40,6 +41,8 @@ class ClusterType(ChangeLoggedModel): blank=True ) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'description'] class Meta: @@ -79,6 +82,8 @@ class ClusterGroup(ChangeLoggedModel): blank=True ) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'description'] class Meta: @@ -145,9 +150,10 @@ class Cluster(ChangeLoggedModel, CustomFieldModel): content_type_field='obj_type', object_id_field='obj_id' ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'type', 'group', 'site', 'comments'] clone_fields = [ 'type', 'group', 'tenant', 'site', @@ -269,9 +275,10 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): content_type_field='obj_type', object_id_field='obj_id' ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = [ 'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', ] diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index e7bb19285..5cd19381f 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -185,16 +185,16 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): } +# TODO: Update base class to DeviceComponentViewTestCase class InterfaceTestCase( - ViewTestCases.GetObjectViewTestCase, - ViewTestCases.DeviceComponentViewTestCase, + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.BulkCreateObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase, ): model = Interface - # Disable inapplicable tests - test_list_objects = None - test_import_objects = None - def _get_base_url(self): # Interface belongs to the DCIM app, so we have to override the base URL return 'virtualization:interface_{}' diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index 557f8a9ca..38ad1a8b1 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -1,7 +1,7 @@ from django.urls import path from extras.views import ObjectChangeLogView -from ipam.views import ServiceCreateView +from ipam.views import ServiceEditView from . import views from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -10,7 +10,7 @@ urlpatterns = [ # Cluster types path('cluster-types/', views.ClusterTypeListView.as_view(), name='clustertype_list'), - path('cluster-types/add/', views.ClusterTypeCreateView.as_view(), name='clustertype_add'), + path('cluster-types/add/', views.ClusterTypeEditView.as_view(), name='clustertype_add'), path('cluster-types/import/', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'), path('cluster-types/delete/', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'), path('cluster-types//edit/', views.ClusterTypeEditView.as_view(), name='clustertype_edit'), @@ -18,7 +18,7 @@ urlpatterns = [ # Cluster groups path('cluster-groups/', views.ClusterGroupListView.as_view(), name='clustergroup_list'), - path('cluster-groups/add/', views.ClusterGroupCreateView.as_view(), name='clustergroup_add'), + path('cluster-groups/add/', views.ClusterGroupEditView.as_view(), name='clustergroup_add'), path('cluster-groups/import/', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'), path('cluster-groups/delete/', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'), path('cluster-groups//edit/', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'), @@ -26,7 +26,7 @@ urlpatterns = [ # Clusters path('clusters/', views.ClusterListView.as_view(), name='cluster_list'), - path('clusters/add/', views.ClusterCreateView.as_view(), name='cluster_add'), + path('clusters/add/', views.ClusterEditView.as_view(), name='cluster_add'), path('clusters/import/', views.ClusterBulkImportView.as_view(), name='cluster_import'), path('clusters/edit/', views.ClusterBulkEditView.as_view(), name='cluster_bulk_edit'), path('clusters/delete/', views.ClusterBulkDeleteView.as_view(), name='cluster_bulk_delete'), @@ -39,7 +39,7 @@ urlpatterns = [ # Virtual machines path('virtual-machines/', views.VirtualMachineListView.as_view(), name='virtualmachine_list'), - path('virtual-machines/add/', views.VirtualMachineCreateView.as_view(), name='virtualmachine_add'), + path('virtual-machines/add/', views.VirtualMachineEditView.as_view(), name='virtualmachine_add'), path('virtual-machines/import/', views.VirtualMachineBulkImportView.as_view(), name='virtualmachine_import'), path('virtual-machines/edit/', views.VirtualMachineBulkEditView.as_view(), name='virtualmachine_bulk_edit'), path('virtual-machines/delete/', views.VirtualMachineBulkDeleteView.as_view(), name='virtualmachine_bulk_delete'), @@ -48,7 +48,7 @@ urlpatterns = [ path('virtual-machines//delete/', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'), path('virtual-machines//config-context/', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'), path('virtual-machines//changelog/', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}), - path('virtual-machines//services/assign/', ServiceCreateView.as_view(), name='virtualmachine_service_assign'), + path('virtual-machines//services/assign/', ServiceEditView.as_view(), name='virtualmachine_service_assign'), # VM interfaces path('interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'), diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 68a2443ae..aea4d0556 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -1,18 +1,16 @@ from django.contrib import messages -from django.contrib.auth.mixins import PermissionRequiredMixin from django.db import transaction from django.db.models import Count from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse -from django.views.generic import View from dcim.models import Device, Interface from dcim.tables import DeviceTable from extras.views import ObjectConfigContextView from ipam.models import Service from utilities.views import ( - BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView, - ObjectEditView, ObjectListView, + BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectView, + ObjectDeleteView, ObjectEditView, ObjectListView, ) from . import filters, forms, tables from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -22,32 +20,25 @@ from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine # Cluster types # -class ClusterTypeListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'virtualization.view_clustertype' +class ClusterTypeListView(ObjectListView): queryset = ClusterType.objects.annotate(cluster_count=Count('clusters')) table = tables.ClusterTypeTable -class ClusterTypeCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'virtualization.add_clustertype' +class ClusterTypeEditView(ObjectEditView): queryset = ClusterType.objects.all() model_form = forms.ClusterTypeForm default_return_url = 'virtualization:clustertype_list' -class ClusterTypeEditView(ClusterTypeCreateView): - permission_required = 'virtualization.change_clustertype' - - -class ClusterTypeBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'virtualization.add_clustertype' +class ClusterTypeBulkImportView(BulkImportView): + queryset = ClusterType.objects.all() model_form = forms.ClusterTypeCSVForm table = tables.ClusterTypeTable default_return_url = 'virtualization:clustertype_list' -class ClusterTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'virtualization.delete_clustertype' +class ClusterTypeBulkDeleteView(BulkDeleteView): queryset = ClusterType.objects.annotate(cluster_count=Count('clusters')) table = tables.ClusterTypeTable default_return_url = 'virtualization:clustertype_list' @@ -57,32 +48,25 @@ class ClusterTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Cluster groups # -class ClusterGroupListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'virtualization.view_clustergroup' +class ClusterGroupListView(ObjectListView): queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters')) table = tables.ClusterGroupTable -class ClusterGroupCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'virtualization.add_clustergroup' +class ClusterGroupEditView(ObjectEditView): queryset = ClusterGroup.objects.all() model_form = forms.ClusterGroupForm default_return_url = 'virtualization:clustergroup_list' -class ClusterGroupEditView(ClusterGroupCreateView): - permission_required = 'virtualization.change_clustergroup' - - -class ClusterGroupBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'virtualization.add_clustergroup' +class ClusterGroupBulkImportView(BulkImportView): + queryset = ClusterGroup.objects.all() model_form = forms.ClusterGroupCSVForm table = tables.ClusterGroupTable default_return_url = 'virtualization:clustergroup_list' -class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'virtualization.delete_clustergroup' +class ClusterGroupBulkDeleteView(BulkDeleteView): queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters')) table = tables.ClusterGroupTable default_return_url = 'virtualization:clustergroup_list' @@ -92,21 +76,20 @@ class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Clusters # -class ClusterListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'virtualization.view_cluster' +class ClusterListView(ObjectListView): queryset = Cluster.objects.prefetch_related('type', 'group', 'site', 'tenant') table = tables.ClusterTable filterset = filters.ClusterFilterSet filterset_form = forms.ClusterFilterForm -class ClusterView(PermissionRequiredMixin, View): - permission_required = 'virtualization.view_cluster' +class ClusterView(ObjectView): + queryset = Cluster.objects.all() def get(self, request, pk): - cluster = get_object_or_404(Cluster, pk=pk) - devices = Device.objects.filter(cluster=cluster).prefetch_related( + cluster = get_object_or_404(self.queryset, pk=pk) + devices = Device.objects.restrict(request.user, 'view').filter(cluster=cluster).prefetch_related( 'site', 'rack', 'tenant', 'device_type__manufacturer' ) device_table = DeviceTable(list(devices), orderable=False) @@ -119,32 +102,25 @@ class ClusterView(PermissionRequiredMixin, View): }) -class ClusterCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'virtualization.add_cluster' +class ClusterEditView(ObjectEditView): template_name = 'virtualization/cluster_edit.html' queryset = Cluster.objects.all() model_form = forms.ClusterForm -class ClusterEditView(ClusterCreateView): - permission_required = 'virtualization.change_cluster' - - -class ClusterDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'virtualization.delete_cluster' +class ClusterDeleteView(ObjectDeleteView): queryset = Cluster.objects.all() default_return_url = 'virtualization:cluster_list' -class ClusterBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'virtualization.add_cluster' +class ClusterBulkImportView(BulkImportView): + queryset = Cluster.objects.all() model_form = forms.ClusterCSVForm table = tables.ClusterTable default_return_url = 'virtualization:cluster_list' -class ClusterBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'virtualization.change_cluster' +class ClusterBulkEditView(BulkEditView): queryset = Cluster.objects.prefetch_related('type', 'group', 'site') filterset = filters.ClusterFilterSet table = tables.ClusterTable @@ -152,22 +128,20 @@ class ClusterBulkEditView(PermissionRequiredMixin, BulkEditView): default_return_url = 'virtualization:cluster_list' -class ClusterBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'virtualization.delete_cluster' +class ClusterBulkDeleteView(BulkDeleteView): queryset = Cluster.objects.prefetch_related('type', 'group', 'site') filterset = filters.ClusterFilterSet table = tables.ClusterTable default_return_url = 'virtualization:cluster_list' -class ClusterAddDevicesView(PermissionRequiredMixin, View): - permission_required = 'virtualization.change_cluster' +class ClusterAddDevicesView(ObjectEditView): + queryset = Cluster.objects.all() form = forms.ClusterAddDevicesForm template_name = 'virtualization/cluster_add_devices.html' def get(self, request, pk): - - cluster = get_object_or_404(Cluster, pk=pk) + cluster = get_object_or_404(self.queryset, pk=pk) form = self.form(cluster, initial=request.GET) return render(request, self.template_name, { @@ -177,8 +151,7 @@ class ClusterAddDevicesView(PermissionRequiredMixin, View): }) def post(self, request, pk): - - cluster = get_object_or_404(Cluster, pk=pk) + cluster = get_object_or_404(self.queryset, pk=pk) form = self.form(cluster, request.POST) if form.is_valid(): @@ -203,14 +176,14 @@ class ClusterAddDevicesView(PermissionRequiredMixin, View): }) -class ClusterRemoveDevicesView(PermissionRequiredMixin, View): - permission_required = 'virtualization.change_cluster' +class ClusterRemoveDevicesView(ObjectEditView): + queryset = Cluster.objects.all() form = forms.ClusterRemoveDevicesForm template_name = 'utilities/obj_bulk_remove.html' def post(self, request, pk): - cluster = get_object_or_404(Cluster, pk=pk) + cluster = get_object_or_404(self.queryset, pk=pk) if '_confirm' in request.POST: form = self.form(request.POST) @@ -248,8 +221,7 @@ class ClusterRemoveDevicesView(PermissionRequiredMixin, View): # Virtual machines # -class VirtualMachineListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'virtualization.view_virtualmachine' +class VirtualMachineListView(ObjectListView): queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role', 'primary_ip4', 'primary_ip6') filterset = filters.VirtualMachineFilterSet filterset_form = forms.VirtualMachineFilterForm @@ -257,14 +229,14 @@ class VirtualMachineListView(PermissionRequiredMixin, ObjectListView): template_name = 'virtualization/virtualmachine_list.html' -class VirtualMachineView(PermissionRequiredMixin, View): - permission_required = 'virtualization.view_virtualmachine' +class VirtualMachineView(ObjectView): + queryset = VirtualMachine.objects.prefetch_related('tenant__group') def get(self, request, pk): - virtualmachine = get_object_or_404(VirtualMachine.objects.prefetch_related('tenant__group'), pk=pk) - interfaces = Interface.objects.filter(virtual_machine=virtualmachine) - services = Service.objects.filter(virtual_machine=virtualmachine) + virtualmachine = get_object_or_404(self.queryset, pk=pk) + interfaces = Interface.objects.restrict(request.user, 'view').filter(virtual_machine=virtualmachine) + services = Service.objects.restrict(request.user, 'view').filter(virtual_machine=virtualmachine) return render(request, 'virtualization/virtualmachine.html', { 'virtualmachine': virtualmachine, @@ -273,39 +245,31 @@ class VirtualMachineView(PermissionRequiredMixin, View): }) -class VirtualMachineConfigContextView(PermissionRequiredMixin, ObjectConfigContextView): - permission_required = 'virtualization.view_virtualmachine' - object_class = VirtualMachine +class VirtualMachineConfigContextView(ObjectConfigContextView): + queryset = VirtualMachine.objects.all() base_template = 'virtualization/virtualmachine.html' -class VirtualMachineCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'virtualization.add_virtualmachine' +class VirtualMachineEditView(ObjectEditView): queryset = VirtualMachine.objects.all() model_form = forms.VirtualMachineForm template_name = 'virtualization/virtualmachine_edit.html' default_return_url = 'virtualization:virtualmachine_list' -class VirtualMachineEditView(VirtualMachineCreateView): - permission_required = 'virtualization.change_virtualmachine' - - -class VirtualMachineDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'virtualization.delete_virtualmachine' +class VirtualMachineDeleteView(ObjectDeleteView): queryset = VirtualMachine.objects.all() default_return_url = 'virtualization:virtualmachine_list' -class VirtualMachineBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'virtualization.add_virtualmachine' +class VirtualMachineBulkImportView(BulkImportView): + queryset = VirtualMachine.objects.all() model_form = forms.VirtualMachineCSVForm table = tables.VirtualMachineTable default_return_url = 'virtualization:virtualmachine_list' -class VirtualMachineBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'virtualization.change_virtualmachine' +class VirtualMachineBulkEditView(BulkEditView): queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role') filterset = filters.VirtualMachineFilterSet table = tables.VirtualMachineTable @@ -313,8 +277,7 @@ class VirtualMachineBulkEditView(PermissionRequiredMixin, BulkEditView): default_return_url = 'virtualization:virtualmachine_list' -class VirtualMachineBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'virtualization.delete_virtualmachine' +class VirtualMachineBulkDeleteView(BulkDeleteView): queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role') filterset = filters.VirtualMachineFilterSet table = tables.VirtualMachineTable @@ -325,35 +288,30 @@ class VirtualMachineBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # VM interfaces # -class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): - permission_required = 'dcim.add_interface' - model = Interface +class InterfaceCreateView(ComponentCreateView): + queryset = Interface.objects.all() form = forms.InterfaceCreateForm model_form = forms.InterfaceForm template_name = 'virtualization/virtualmachine_component_add.html' -class InterfaceEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_interface' +class InterfaceEditView(ObjectEditView): queryset = Interface.objects.all() model_form = forms.InterfaceForm template_name = 'virtualization/interface_edit.html' -class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'dcim.delete_interface' +class InterfaceDeleteView(ObjectDeleteView): queryset = Interface.objects.all() -class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.change_interface' +class InterfaceBulkEditView(BulkEditView): queryset = Interface.objects.all() table = tables.InterfaceTable form = forms.InterfaceBulkEditForm -class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'dcim.delete_interface' +class InterfaceBulkDeleteView(BulkDeleteView): queryset = Interface.objects.all() table = tables.InterfaceTable @@ -362,12 +320,11 @@ class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Bulk Device component creation # -class VirtualMachineBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateView): - permission_required = 'dcim.add_interface' +class VirtualMachineBulkAddInterfaceView(BulkComponentCreateView): parent_model = VirtualMachine parent_field = 'virtual_machine' form = forms.InterfaceBulkCreateForm - model = Interface + queryset = Interface.objects.all() model_form = forms.InterfaceForm filterset = filters.VirtualMachineFilterSet table = tables.VirtualMachineTable