Merge pull request #4705 from netbox-community/554-object-permissions

Closes #554: Implement object-based permissions
This commit is contained in:
Jeremy Stretch 2020-06-03 13:29:16 -04:00 committed by GitHub
commit 05c851301e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
76 changed files with 3415 additions and 1810 deletions

View File

@ -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.

View File

@ -384,7 +384,7 @@ NetBox can be configured to support remote user authentication by inferring user
## REMOTE_AUTH_BACKEND ## 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`.) 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 ## 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`.)
--- ---

View File

@ -4,6 +4,10 @@ Utility views are reusable views that handle common CRUD tasks, such as listing
## Individual Views ## Individual Views
### ObjectView
Retrieve and display a single object.
### ObjectListView ### ObjectListView
Generates a paginated table of objects from a given queryset, which may optionally be filtered. Generates a paginated table of objects from a given queryset, which may optionally be filtered.

View File

@ -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.

View File

@ -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.

View File

@ -58,6 +58,7 @@ nav:
- Using Plugins: 'plugins/index.md' - Using Plugins: 'plugins/index.md'
- Developing Plugins: 'plugins/development.md' - Developing Plugins: 'plugins/development.md'
- Administration: - Administration:
- Permissions: 'administration/permissions.md'
- Replicating NetBox: 'administration/replicating-netbox.md' - Replicating NetBox: 'administration/replicating-netbox.md'
- NetBox Shell: 'administration/netbox-shell.md' - NetBox Shell: 'administration/netbox-shell.md'
- API: - API:

View File

@ -8,6 +8,7 @@ from dcim.fields import ASNField
from dcim.models import CableTermination from dcim.models import CableTermination
from extras.models import CustomFieldModel, ObjectChange, TaggedItem from extras.models import CustomFieldModel, ObjectChange, TaggedItem
from extras.utils import extras_features from extras.utils import extras_features
from utilities.querysets import RestrictedQuerySet
from utilities.models import ChangeLoggedModel from utilities.models import ChangeLoggedModel
from utilities.utils import serialize_object from utilities.utils import serialize_object
from .choices import * from .choices import *
@ -66,9 +67,10 @@ class Provider(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type', content_type_field='obj_type',
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [ csv_headers = [
'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
] ]
@ -115,6 +117,8 @@ class CircuitType(ChangeLoggedModel):
blank=True, blank=True,
) )
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'description'] csv_headers = ['name', 'slug', 'description']
class Meta: class Meta:
@ -300,6 +304,8 @@ class CircuitTermination(CableTermination):
blank=True blank=True
) )
objects = RestrictedQuerySet.as_manager()
class Meta: class Meta:
ordering = ['circuit', 'term_side'] ordering = ['circuit', 'term_side']
unique_together = ['circuit', 'term_side'] unique_together = ['circuit', 'term_side']

View File

@ -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): def annotate_sites(self):
""" """

View File

@ -10,7 +10,7 @@ urlpatterns = [
# Providers # Providers
path('providers/', views.ProviderListView.as_view(), name='provider_list'), 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/import/', views.ProviderBulkImportView.as_view(), name='provider_import'),
path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'), path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'), path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
@ -21,7 +21,7 @@ urlpatterns = [
# Circuit types # Circuit types
path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'), 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/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
path('circuit-types/<slug:slug>/edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), path('circuit-types/<slug:slug>/edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
@ -29,7 +29,7 @@ urlpatterns = [
# Circuits # Circuits
path('circuits/', views.CircuitListView.as_view(), name='circuit_list'), 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/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'),
path('circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'), path('circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
path('circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'), path('circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
@ -37,11 +37,10 @@ urlpatterns = [
path('circuits/<int:pk>/edit/', views.CircuitEditView.as_view(), name='circuit_edit'), path('circuits/<int:pk>/edit/', views.CircuitEditView.as_view(), name='circuit_edit'),
path('circuits/<int:pk>/delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'), path('circuits/<int:pk>/delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'),
path('circuits/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}), path('circuits/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
path('circuits/<int:pk>/terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'), path('circuits/<int:pk>/terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'),
# Circuit terminations # Circuit terminations
path('circuits/<int:circuit>/terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
path('circuits/<int:circuit>/terminations/add/', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'),
path('circuit-terminations/<int:pk>/edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), path('circuit-terminations/<int:pk>/edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
path('circuit-terminations/<int:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), path('circuit-terminations/<int:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
path('circuit-terminations/<int:termination_a_id>/connect/<str:termination_b_type>/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}), path('circuit-terminations/<int:termination_a_id>/connect/<str:termination_b_type>/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),

View File

@ -1,18 +1,15 @@
from django.conf import settings from django.conf import settings
from django.contrib import messages 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 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.shortcuts import get_object_or_404, redirect, render
from django.views.generic import View
from django_tables2 import RequestConfig from django_tables2 import RequestConfig
from extras.models import Graph from extras.models import Graph
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator from utilities.paginator import EnhancedPaginator
from utilities.views import ( from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
) )
from . import filters, forms, tables from . import filters, forms, tables
from .choices import CircuitTerminationSideChoices from .choices import CircuitTerminationSideChoices
@ -23,21 +20,20 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
# Providers # Providers
# #
class ProviderListView(PermissionRequiredMixin, ObjectListView): class ProviderListView(ObjectListView):
permission_required = 'circuits.view_provider'
queryset = Provider.objects.annotate(count_circuits=Count('circuits')) queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
filterset = filters.ProviderFilterSet filterset = filters.ProviderFilterSet
filterset_form = forms.ProviderFilterForm filterset_form = forms.ProviderFilterForm
table = tables.ProviderTable table = tables.ProviderTable
class ProviderView(PermissionRequiredMixin, View): class ProviderView(ObjectView):
permission_required = 'circuits.view_provider' queryset = Provider.objects.all()
def get(self, request, slug): def get(self, request, slug):
provider = get_object_or_404(Provider, slug=slug) provider = get_object_or_404(self.queryset, slug=slug)
circuits = Circuit.objects.filter( circuits = Circuit.objects.restrict(request.user, 'view').filter(
provider=provider provider=provider
).prefetch_related( ).prefetch_related(
'type', 'tenant', 'terminations__site' 'type', 'tenant', 'terminations__site'
@ -60,33 +56,26 @@ class ProviderView(PermissionRequiredMixin, View):
}) })
class ProviderCreateView(PermissionRequiredMixin, ObjectEditView): class ProviderEditView(ObjectEditView):
permission_required = 'circuits.add_provider'
queryset = Provider.objects.all() queryset = Provider.objects.all()
model_form = forms.ProviderForm model_form = forms.ProviderForm
template_name = 'circuits/provider_edit.html' template_name = 'circuits/provider_edit.html'
default_return_url = 'circuits:provider_list' default_return_url = 'circuits:provider_list'
class ProviderEditView(ProviderCreateView): class ProviderDeleteView(ObjectDeleteView):
permission_required = 'circuits.change_provider'
class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_provider'
queryset = Provider.objects.all() queryset = Provider.objects.all()
default_return_url = 'circuits:provider_list' default_return_url = 'circuits:provider_list'
class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView): class ProviderBulkImportView(BulkImportView):
permission_required = 'circuits.add_provider' queryset = Provider.objects.all()
model_form = forms.ProviderCSVForm model_form = forms.ProviderCSVForm
table = tables.ProviderTable table = tables.ProviderTable
default_return_url = 'circuits:provider_list' default_return_url = 'circuits:provider_list'
class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView): class ProviderBulkEditView(BulkEditView):
permission_required = 'circuits.change_provider'
queryset = Provider.objects.annotate(count_circuits=Count('circuits')) queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
filterset = filters.ProviderFilterSet filterset = filters.ProviderFilterSet
table = tables.ProviderTable table = tables.ProviderTable
@ -94,8 +83,7 @@ class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView):
default_return_url = 'circuits:provider_list' default_return_url = 'circuits:provider_list'
class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class ProviderBulkDeleteView(BulkDeleteView):
permission_required = 'circuits.delete_provider'
queryset = Provider.objects.annotate(count_circuits=Count('circuits')) queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
filterset = filters.ProviderFilterSet filterset = filters.ProviderFilterSet
table = tables.ProviderTable table = tables.ProviderTable
@ -106,32 +94,25 @@ class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Circuit Types # Circuit Types
# #
class CircuitTypeListView(PermissionRequiredMixin, ObjectListView): class CircuitTypeListView(ObjectListView):
permission_required = 'circuits.view_circuittype'
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')) queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
table = tables.CircuitTypeTable table = tables.CircuitTypeTable
class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView): class CircuitTypeEditView(ObjectEditView):
permission_required = 'circuits.add_circuittype'
queryset = CircuitType.objects.all() queryset = CircuitType.objects.all()
model_form = forms.CircuitTypeForm model_form = forms.CircuitTypeForm
default_return_url = 'circuits:circuittype_list' default_return_url = 'circuits:circuittype_list'
class CircuitTypeEditView(CircuitTypeCreateView): class CircuitTypeBulkImportView(BulkImportView):
permission_required = 'circuits.change_circuittype' queryset = CircuitType.objects.all()
class CircuitTypeBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'circuits.add_circuittype'
model_form = forms.CircuitTypeCSVForm model_form = forms.CircuitTypeCSVForm
table = tables.CircuitTypeTable table = tables.CircuitTypeTable
default_return_url = 'circuits:circuittype_list' default_return_url = 'circuits:circuittype_list'
class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class CircuitTypeBulkDeleteView(BulkDeleteView):
permission_required = 'circuits.delete_circuittype'
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')) queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
table = tables.CircuitTypeTable table = tables.CircuitTypeTable
default_return_url = 'circuits:circuittype_list' default_return_url = 'circuits:circuittype_list'
@ -141,8 +122,7 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Circuits # Circuits
# #
class CircuitListView(PermissionRequiredMixin, ObjectListView): class CircuitListView(ObjectListView):
permission_required = 'circuits.view_circuit'
_terminations = CircuitTermination.objects.filter(circuit=OuterRef('pk')) _terminations = CircuitTermination.objects.filter(circuit=OuterRef('pk'))
queryset = Circuit.objects.prefetch_related( queryset = Circuit.objects.prefetch_related(
'provider', 'type', 'tenant', 'terminations__site' 'provider', 'type', 'tenant', 'terminations__site'
@ -152,18 +132,18 @@ class CircuitListView(PermissionRequiredMixin, ObjectListView):
table = tables.CircuitTable table = tables.CircuitTable
class CircuitView(PermissionRequiredMixin, View): class CircuitView(ObjectView):
permission_required = 'circuits.view_circuit' queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant__group')
def get(self, request, pk): def get(self, request, pk):
circuit = get_object_or_404(Circuit.objects.prefetch_related('provider', 'type', 'tenant__group'), pk=pk) circuit = get_object_or_404(self.queryset, pk=pk)
termination_a = CircuitTermination.objects.prefetch_related( termination_a = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related(
'site__region', 'connected_endpoint__device' 'site__region', 'connected_endpoint__device'
).filter( ).filter(
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A
).first() ).first()
termination_z = CircuitTermination.objects.prefetch_related( termination_z = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related(
'site__region', 'connected_endpoint__device' 'site__region', 'connected_endpoint__device'
).filter( ).filter(
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z
@ -176,33 +156,26 @@ class CircuitView(PermissionRequiredMixin, View):
}) })
class CircuitCreateView(PermissionRequiredMixin, ObjectEditView): class CircuitEditView(ObjectEditView):
permission_required = 'circuits.add_circuit'
queryset = Circuit.objects.all() queryset = Circuit.objects.all()
model_form = forms.CircuitForm model_form = forms.CircuitForm
template_name = 'circuits/circuit_edit.html' template_name = 'circuits/circuit_edit.html'
default_return_url = 'circuits:circuit_list' default_return_url = 'circuits:circuit_list'
class CircuitEditView(CircuitCreateView): class CircuitDeleteView(ObjectDeleteView):
permission_required = 'circuits.change_circuit'
class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_circuit'
queryset = Circuit.objects.all() queryset = Circuit.objects.all()
default_return_url = 'circuits:circuit_list' default_return_url = 'circuits:circuit_list'
class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView): class CircuitBulkImportView(BulkImportView):
permission_required = 'circuits.add_circuit' queryset = Circuit.objects.all()
model_form = forms.CircuitCSVForm model_form = forms.CircuitCSVForm
table = tables.CircuitTable table = tables.CircuitTable
default_return_url = 'circuits:circuit_list' default_return_url = 'circuits:circuit_list'
class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView): class CircuitBulkEditView(BulkEditView):
permission_required = 'circuits.change_circuit'
queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site') queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
filterset = filters.CircuitFilterSet filterset = filters.CircuitFilterSet
table = tables.CircuitTable table = tables.CircuitTable
@ -210,33 +183,54 @@ class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
default_return_url = 'circuits:circuit_list' default_return_url = 'circuits:circuit_list'
class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class CircuitBulkDeleteView(BulkDeleteView):
permission_required = 'circuits.delete_circuit'
queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site') queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
filterset = filters.CircuitFilterSet filterset = filters.CircuitFilterSet
table = tables.CircuitTable table = tables.CircuitTable
default_return_url = 'circuits:circuit_list' default_return_url = 'circuits:circuit_list'
@permission_required('circuits.change_circuittermination') class CircuitSwapTerminations(ObjectEditView):
def circuit_terminations_swap(request, pk): """
Swap the A and Z terminations of a circuit.
"""
queryset = Circuit.objects.all()
circuit = get_object_or_404(Circuit, pk=pk) def get(self, request, pk):
termination_a = CircuitTermination.objects.filter( circuit = get_object_or_404(self.queryset, pk=pk)
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A form = ConfirmationForm()
).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)
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) form = ConfirmationForm(request.POST)
if form.is_valid(): 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: if termination_a and termination_z:
# Use a placeholder to avoid an IntegrityError on the (circuit, term_side) unique constraint # Use a placeholder to avoid an IntegrityError on the (circuit, term_side) unique constraint
print('swapping')
with transaction.atomic(): with transaction.atomic():
termination_a.term_side = '_' termination_a.term_side = '_'
termination_a.save() termination_a.save()
@ -250,29 +244,26 @@ def circuit_terminations_swap(request, pk):
else: else:
termination_z.term_side = 'A' termination_z.term_side = 'A'
termination_z.save() termination_z.save()
messages.success(request, "Swapped terminations for circuit {}.".format(circuit)) messages.success(request, "Swapped terminations for circuit {}.".format(circuit))
return redirect('circuits:circuit', pk=circuit.pk) return redirect('circuits:circuit', pk=circuit.pk)
else: return render(request, 'circuits/circuit_terminations_swap.html', {
form = ConfirmationForm() 'circuit': circuit,
'termination_a': circuit.termination_a,
return render(request, 'circuits/circuit_terminations_swap.html', { 'termination_z': circuit.termination_z,
'circuit': circuit, 'form': form,
'termination_a': termination_a, 'panel_class': 'default',
'termination_z': termination_z, 'button_class': 'primary',
'form': form, 'return_url': circuit.get_absolute_url(),
'panel_class': 'default', })
'button_class': 'primary',
'return_url': circuit.get_absolute_url(),
})
# #
# Circuit terminations # Circuit terminations
# #
class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView): class CircuitTerminationEditView(ObjectEditView):
permission_required = 'circuits.add_circuittermination'
queryset = CircuitTermination.objects.all() queryset = CircuitTermination.objects.all()
model_form = forms.CircuitTerminationForm model_form = forms.CircuitTerminationForm
template_name = 'circuits/circuittermination_edit.html' template_name = 'circuits/circuittermination_edit.html'
@ -286,10 +277,5 @@ class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView):
return obj.circuit.get_absolute_url() return obj.circuit.get_absolute_url()
class CircuitTerminationEditView(CircuitTerminationCreateView): class CircuitTerminationDeleteView(ObjectDeleteView):
permission_required = 'circuits.change_circuittermination'
class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_circuittermination'
queryset = CircuitTermination.objects.all() queryset = CircuitTermination.objects.all()

View File

@ -395,7 +395,7 @@ class DeviceViewSet(CustomFieldModelViewSet):
)) ))
# Verify user permission # Verify user permission
if not request.user.has_perm('dcim.napalm_read'): if not request.user.has_perm('dcim.napalm_read_device'):
return HttpResponseForbidden() return HttpResponseForbidden()
# Connect to the device # Connect to the device

View File

@ -22,7 +22,7 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='device', 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( migrations.AddField(
model_name='platform', model_name='platform',

View File

@ -12,7 +12,7 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='device', 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( migrations.AlterModelOptions(
name='rack', name='rack',

View File

@ -30,7 +30,7 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='device', 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( migrations.AlterModelOptions(
name='rack', name='rack',

View File

@ -25,7 +25,9 @@ from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, Ta
from extras.utils import extras_features from extras.utils import extras_features
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField, NaturalOrderingField
from utilities.querysets import RestrictedQuerySet
from utilities.models import ChangeLoggedModel from utilities.models import ChangeLoggedModel
from utilities.mptt import TreeManager
from utilities.utils import serialize_object, to_meters from utilities.utils import serialize_object, to_meters
from utilities.validators import ExclusionValidator from utilities.validators import ExclusionValidator
from .device_component_templates import ( from .device_component_templates import (
@ -103,6 +105,8 @@ class Region(MPTTModel, ChangeLoggedModel):
blank=True blank=True
) )
objects = TreeManager()
csv_headers = ['name', 'slug', 'parent', 'description'] csv_headers = ['name', 'slug', 'parent', 'description']
class MPTTMeta: class MPTTMeta:
@ -244,6 +248,8 @@ class Site(ChangeLoggedModel, CustomFieldModel):
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [ csv_headers = [
'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments',
@ -326,6 +332,8 @@ class RackGroup(MPTTModel, ChangeLoggedModel):
blank=True blank=True
) )
objects = TreeManager()
csv_headers = ['site', 'parent', 'name', 'slug', 'description'] csv_headers = ['site', 'parent', 'name', 'slug', 'description']
class Meta: class Meta:
@ -388,6 +396,8 @@ class RackRole(ChangeLoggedModel):
blank=True, blank=True,
) )
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'color', 'description'] csv_headers = ['name', 'slug', 'color', 'description']
class Meta: class Meta:
@ -526,6 +536,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [ csv_headers = [
'site', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width', 'site', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width',
'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
@ -821,6 +833,8 @@ class RackReservation(ChangeLoggedModel):
max_length=200 max_length=200
) )
objects = RestrictedQuerySet.as_manager()
csv_headers = ['site', 'rack_group', 'rack', 'units', 'tenant', 'user', 'description'] csv_headers = ['site', 'rack_group', 'rack', 'units', 'tenant', 'user', 'description']
class Meta: class Meta:
@ -900,6 +914,8 @@ class Manufacturer(ChangeLoggedModel):
blank=True blank=True
) )
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'description'] csv_headers = ['name', 'slug', 'description']
class Meta: class Meta:
@ -982,9 +998,10 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type', content_type_field='obj_type',
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
clone_fields = [ clone_fields = [
'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role',
] ]
@ -1206,6 +1223,8 @@ class DeviceRole(ChangeLoggedModel):
blank=True, blank=True,
) )
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'color', 'vm_role', 'description'] csv_headers = ['name', 'slug', 'color', 'vm_role', 'description']
class Meta: class Meta:
@ -1263,6 +1282,8 @@ class Platform(ChangeLoggedModel):
blank=True blank=True
) )
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description'] csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description']
class Meta: class Meta:
@ -1429,6 +1450,8 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [ csv_headers = [
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
'site', 'rack_group', 'rack_name', 'position', 'face', 'comments', 'site', 'rack_group', 'rack_name', 'position', 'face', 'comments',
@ -1454,10 +1477,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
('rack', 'position', 'face'), ('rack', 'position', 'face'),
('virtual_chassis', 'vc_position'), ('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): def __str__(self):
return self.display_name or super().__str__() return self.display_name or super().__str__()
@ -1741,9 +1760,10 @@ class VirtualChassis(ChangeLoggedModel):
max_length=30, max_length=30,
blank=True blank=True
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['master', 'domain'] csv_headers = ['master', 'domain']
class Meta: class Meta:
@ -1813,6 +1833,8 @@ class PowerPanel(ChangeLoggedModel):
max_length=50 max_length=50
) )
objects = RestrictedQuerySet.as_manager()
csv_headers = ['site', 'rack_group', 'name'] csv_headers = ['site', 'rack_group', 'name']
class Meta: class Meta:
@ -1916,9 +1938,10 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
content_type_field='obj_type', content_type_field='obj_type',
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [ csv_headers = [
'site', 'power_panel', 'rack_group', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'site', 'power_panel', 'rack_group', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
'amperage', 'max_utilization', 'comments', 'amperage', 'max_utilization', 'comments',
@ -2084,6 +2107,8 @@ class Cable(ChangeLoggedModel):
null=True null=True
) )
objects = RestrictedQuerySet.as_manager()
csv_headers = [ csv_headers = [
'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label', 'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label',
'color', 'length', 'length_unit', 'color', 'length', 'length_unit',

View File

@ -6,6 +6,7 @@ from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from extras.models import ObjectChange from extras.models import ObjectChange
from utilities.fields import NaturalOrderingField from utilities.fields import NaturalOrderingField
from utilities.querysets import RestrictedQuerySet
from utilities.ordering import naturalize_interface from utilities.ordering import naturalize_interface
from utilities.utils import serialize_object from utilities.utils import serialize_object
from .device_components import ( from .device_components import (
@ -26,6 +27,7 @@ __all__ = (
class ComponentTemplateModel(models.Model): class ComponentTemplateModel(models.Model):
objects = RestrictedQuerySet.as_manager()
class Meta: class Meta:
abstract = True abstract = True

View File

@ -16,6 +16,7 @@ from extras.models import ObjectChange, TaggedItem
from extras.utils import extras_features from extras.utils import extras_features
from utilities.fields import NaturalOrderingField from utilities.fields import NaturalOrderingField
from utilities.ordering import naturalize_interface from utilities.ordering import naturalize_interface
from utilities.querysets import RestrictedQuerySet
from utilities.query_functions import CollateAsChar from utilities.query_functions import CollateAsChar
from utilities.utils import serialize_object from utilities.utils import serialize_object
from virtualization.choices import VMInterfaceTypeChoices from virtualization.choices import VMInterfaceTypeChoices
@ -41,6 +42,8 @@ class ComponentModel(models.Model):
blank=True blank=True
) )
objects = RestrictedQuerySet.as_manager()
class Meta: class Meta:
abstract = True abstract = True

View File

@ -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 model = DeviceType
@classmethod @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 model = DeviceBayTemplate
# Disable inapplicable views
test_bulk_edit_objects = None
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') 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 model = Cable
# TODO: Creation URL needs termination context
test_create_object = None
@classmethod @classmethod
def setUpTestData(cls): 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 model = VirtualChassis
# Disable inapplicable tests
test_import_objects = None
# TODO: Requires special form handling
test_create_object = None
test_edit_object = None
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):

View File

@ -1,7 +1,7 @@
from django.urls import path from django.urls import path
from extras.views import ObjectChangeLogView, ImageAttachmentEditView from extras.views import ObjectChangeLogView, ImageAttachmentEditView
from ipam.views import ServiceCreateView from ipam.views import ServiceEditView
from . import views from . import views
from .models import ( from .models import (
Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform, Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform,
@ -14,7 +14,7 @@ urlpatterns = [
# Regions # Regions
path('regions/', views.RegionListView.as_view(), name='region_list'), 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/import/', views.RegionBulkImportView.as_view(), name='region_import'),
path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
path('regions/<int:pk>/edit/', views.RegionEditView.as_view(), name='region_edit'), path('regions/<int:pk>/edit/', views.RegionEditView.as_view(), name='region_edit'),
@ -22,7 +22,7 @@ urlpatterns = [
# Sites # Sites
path('sites/', views.SiteListView.as_view(), name='site_list'), 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/import/', views.SiteBulkImportView.as_view(), name='site_import'),
path('sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'), path('sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
path('sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'), path('sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'),
@ -34,7 +34,7 @@ urlpatterns = [
# Rack groups # Rack groups
path('rack-groups/', views.RackGroupListView.as_view(), name='rackgroup_list'), 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/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'),
path('rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'), path('rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
path('rack-groups/<int:pk>/edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'), path('rack-groups/<int:pk>/edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
@ -42,7 +42,7 @@ urlpatterns = [
# Rack roles # Rack roles
path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'), 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/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
path('rack-roles/<int:pk>/edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'), path('rack-roles/<int:pk>/edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'),
@ -50,7 +50,7 @@ urlpatterns = [
# Rack reservations # Rack reservations
path('rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'), 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/import/', views.RackReservationImportView.as_view(), name='rackreservation_import'),
path('rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'), path('rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'),
path('rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'), path('rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
@ -62,7 +62,7 @@ urlpatterns = [
# Racks # Racks
path('racks/', views.RackListView.as_view(), name='rack_list'), path('racks/', views.RackListView.as_view(), name='rack_list'),
path('rack-elevations/', views.RackElevationListView.as_view(), name='rack_elevation_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/import/', views.RackBulkImportView.as_view(), name='rack_import'),
path('racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'), path('racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'),
path('racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'), path('racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'),
@ -74,7 +74,7 @@ urlpatterns = [
# Manufacturers # Manufacturers
path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'), 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/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'), path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
path('manufacturers/<slug:slug>/edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), path('manufacturers/<slug:slug>/edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
@ -82,7 +82,7 @@ urlpatterns = [
# Device types # Device types
path('device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'), 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/import/', views.DeviceTypeImportView.as_view(), name='devicetype_import'),
path('device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'), path('device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
path('device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'), path('device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
@ -149,7 +149,7 @@ urlpatterns = [
# Device roles # Device roles
path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'), 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/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'), path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
path('device-roles/<slug:slug>/edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), path('device-roles/<slug:slug>/edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
@ -157,7 +157,7 @@ urlpatterns = [
# Platforms # Platforms
path('platforms/', views.PlatformListView.as_view(), name='platform_list'), 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/import/', views.PlatformBulkImportView.as_view(), name='platform_import'),
path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'), path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
path('platforms/<slug:slug>/edit/', views.PlatformEditView.as_view(), name='platform_edit'), path('platforms/<slug:slug>/edit/', views.PlatformEditView.as_view(), name='platform_edit'),
@ -165,7 +165,7 @@ urlpatterns = [
# Devices # Devices
path('devices/', views.DeviceListView.as_view(), name='device_list'), 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/', views.DeviceBulkImportView.as_view(), name='device_import'),
path('devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'), path('devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
path('devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'), path('devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
@ -179,7 +179,7 @@ urlpatterns = [
path('devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'), path('devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'), path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
path('devices/<int:device>/services/assign/', ServiceCreateView.as_view(), name='device_service_assign'), path('devices/<int:device>/services/assign/', ServiceEditView.as_view(), name='device_service_assign'),
path('devices/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}), path('devices/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
# Console ports # Console ports
@ -332,7 +332,7 @@ urlpatterns = [
# Power panels # Power panels
path('power-panels/', views.PowerPanelListView.as_view(), name='powerpanel_list'), 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/import/', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'),
path('power-panels/edit/', views.PowerPanelBulkEditView.as_view(), name='powerpanel_bulk_edit'), path('power-panels/edit/', views.PowerPanelBulkEditView.as_view(), name='powerpanel_bulk_edit'),
path('power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'), path('power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'),
@ -343,7 +343,7 @@ urlpatterns = [
# Power feeds # Power feeds
path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'), 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/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'),
path('power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'), path('power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'),
path('power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'), path('power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'),

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,6 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
], ],
options={ options={
'permissions': (('run_script', 'Can run script'),),
'managed': False, 'managed': False,
}, },
), ),

View File

@ -12,6 +12,7 @@ from django.template import Template, Context
from django.urls import reverse from django.urls import reverse
from rest_framework.utils.encoders import JSONEncoder from rest_framework.utils.encoders import JSONEncoder
from utilities.querysets import RestrictedQuerySet
from utilities.utils import deepmerge, render_jinja2 from utilities.utils import deepmerge, render_jinja2
from extras.choices import * from extras.choices import *
from extras.constants import * from extras.constants import *
@ -563,9 +564,6 @@ class Script(models.Model):
""" """
class Meta: class Meta:
managed = False managed = False
permissions = (
('run_script', 'Can run script'),
)
# #
@ -670,6 +668,8 @@ class ObjectChange(models.Model):
editable=False editable=False
) )
objects = RestrictedQuerySet.as_manager()
csv_headers = [ csv_headers = [
'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id',
'related_object_type', 'related_object_id', 'object_repr', 'object_data', 'related_object_type', 'related_object_id', 'object_repr', 'object_data',

View File

@ -6,6 +6,7 @@ from taggit.models import TagBase, GenericTaggedItemBase
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
from utilities.fields import ColorField from utilities.fields import ColorField
from utilities.models import ChangeLoggedModel from utilities.models import ChangeLoggedModel
from utilities.querysets import RestrictedQuerySet
# #
@ -21,6 +22,8 @@ class Tag(TagBase, ChangeLoggedModel):
blank=True, blank=True,
) )
objects = RestrictedQuerySet.as_manager()
def get_absolute_url(self): def get_absolute_url(self):
return reverse('extras:tag', args=[self.slug]) return reverse('extras:tag', args=[self.slug])

View File

@ -2,6 +2,8 @@ from collections import OrderedDict
from django.db.models import Q, QuerySet from django.db.models import Q, QuerySet
from utilities.querysets import RestrictedQuerySet
class CustomFieldQueryset: class CustomFieldQueryset:
""" """
@ -19,7 +21,7 @@ class CustomFieldQueryset:
yield obj yield obj
class ConfigContextQuerySet(QuerySet): class ConfigContextQuerySet(RestrictedQuerySet):
def get_for_object(self, obj): def get_for_object(self, obj):
""" """

View File

@ -1,7 +1,6 @@
from datetime import date from datetime import date
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from rest_framework import status from rest_framework import status
@ -9,7 +8,7 @@ from dcim.forms import SiteCSVForm
from dcim.models import Site from dcim.models import Site
from extras.choices import * from extras.choices import *
from extras.models import CustomField, CustomFieldValue, CustomFieldChoice 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 from virtualization.models import VirtualMachine
@ -470,17 +469,10 @@ class CustomFieldChoiceAPITest(APITestCase):
class CustomFieldImportTest(TestCase): class CustomFieldImportTest(TestCase):
user_permissions = (
def setUp(self): 'dcim.view_site',
'dcim.add_site',
user = create_test_user( )
permissions=[
'dcim.view_site',
'dcim.add_site',
]
)
self.client = Client()
self.client.force_login(user)
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):

View File

@ -10,13 +10,17 @@ from extras.models import ConfigContext, ObjectChange, Tag
from utilities.testing import ViewTestCases, TestCase 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 model = Tag
# Disable inapplicable tests
test_create_object = None
test_import_objects = None
@classmethod @classmethod
def setUpTestData(cls): 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 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 @classmethod
def setUpTestData(cls): def setUpTestData(cls):

View File

@ -18,7 +18,7 @@ urlpatterns = [
# Config contexts # Config contexts
path('config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'), 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/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
path('config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'), path('config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
path('config-contexts/<int:pk>/', views.ConfigContextView.as_view(), name='configcontext'), path('config-contexts/<int:pk>/', views.ConfigContextView.as_view(), name='configcontext'),

View File

@ -1,7 +1,6 @@
from django import template from django import template
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, Q from django.db.models import Count, Q
from django.http import Http404, HttpResponseForbidden from django.http import Http404, HttpResponseForbidden
@ -13,7 +12,10 @@ from django_tables2 import RequestConfig
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator from utilities.paginator import EnhancedPaginator
from utilities.utils import shallow_compare_dict 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 . import filters, forms
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
from .reports import get_report, get_reports from .reports import get_report, get_reports
@ -25,8 +27,7 @@ from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemT
# Tags # Tags
# #
class TagListView(PermissionRequiredMixin, ObjectListView): class TagListView(ObjectListView):
permission_required = 'extras.view_tag'
queryset = Tag.objects.annotate( queryset = Tag.objects.annotate(
items=Count('extras_taggeditem_items', distinct=True) items=Count('extras_taggeditem_items', distinct=True)
).order_by( ).order_by(
@ -38,12 +39,12 @@ class TagListView(PermissionRequiredMixin, ObjectListView):
action_buttons = () action_buttons = ()
class TagView(PermissionRequiredMixin, View): class TagView(ObjectView):
permission_required = 'extras.view_tag' queryset = Tag.objects.all()
def get(self, request, slug): 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( tagged_items = TaggedItem.objects.filter(
tag=tag tag=tag
).prefetch_related( ).prefetch_related(
@ -65,22 +66,19 @@ class TagView(PermissionRequiredMixin, View):
}) })
class TagEditView(PermissionRequiredMixin, ObjectEditView): class TagEditView(ObjectEditView):
permission_required = 'extras.change_tag'
queryset = Tag.objects.all() queryset = Tag.objects.all()
model_form = forms.TagForm model_form = forms.TagForm
default_return_url = 'extras:tag_list' default_return_url = 'extras:tag_list'
template_name = 'extras/tag_edit.html' template_name = 'extras/tag_edit.html'
class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView): class TagDeleteView(ObjectDeleteView):
permission_required = 'extras.delete_tag'
queryset = Tag.objects.all() queryset = Tag.objects.all()
default_return_url = 'extras:tag_list' default_return_url = 'extras:tag_list'
class TagBulkEditView(PermissionRequiredMixin, BulkEditView): class TagBulkEditView(BulkEditView):
permission_required = 'extras.change_tag'
queryset = Tag.objects.annotate( queryset = Tag.objects.annotate(
items=Count('extras_taggeditem_items', distinct=True) items=Count('extras_taggeditem_items', distinct=True)
).order_by( ).order_by(
@ -91,8 +89,7 @@ class TagBulkEditView(PermissionRequiredMixin, BulkEditView):
default_return_url = 'extras:tag_list' default_return_url = 'extras:tag_list'
class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class TagBulkDeleteView(BulkDeleteView):
permission_required = 'extras.delete_tag'
queryset = Tag.objects.annotate( queryset = Tag.objects.annotate(
items=Count('extras_taggeditem_items') items=Count('extras_taggeditem_items')
).order_by( ).order_by(
@ -106,8 +103,7 @@ class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Config contexts # Config contexts
# #
class ConfigContextListView(PermissionRequiredMixin, ObjectListView): class ConfigContextListView(ObjectListView):
permission_required = 'extras.view_configcontext'
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
filterset = filters.ConfigContextFilterSet filterset = filters.ConfigContextFilterSet
filterset_form = forms.ConfigContextFilterForm filterset_form = forms.ConfigContextFilterForm
@ -115,11 +111,11 @@ class ConfigContextListView(PermissionRequiredMixin, ObjectListView):
action_buttons = ('add',) action_buttons = ('add',)
class ConfigContextView(PermissionRequiredMixin, View): class ConfigContextView(ObjectView):
permission_required = 'extras.view_configcontext' queryset = ConfigContext.objects.all()
def get(self, request, pk): 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 # Determine user's preferred output format
if request.GET.get('format') in ['json', 'yaml']: if request.GET.get('format') in ['json', 'yaml']:
@ -137,20 +133,14 @@ class ConfigContextView(PermissionRequiredMixin, View):
}) })
class ConfigContextCreateView(PermissionRequiredMixin, ObjectEditView): class ConfigContextEditView(ObjectEditView):
permission_required = 'extras.add_configcontext'
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
model_form = forms.ConfigContextForm model_form = forms.ConfigContextForm
default_return_url = 'extras:configcontext_list' default_return_url = 'extras:configcontext_list'
template_name = 'extras/configcontext_edit.html' template_name = 'extras/configcontext_edit.html'
class ConfigContextEditView(ConfigContextCreateView): class ConfigContextBulkEditView(BulkEditView):
permission_required = 'extras.change_configcontext'
class ConfigContextBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'extras.change_configcontext'
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
filterset = filters.ConfigContextFilterSet filterset = filters.ConfigContextFilterSet
table = ConfigContextTable table = ConfigContextTable
@ -158,28 +148,25 @@ class ConfigContextBulkEditView(PermissionRequiredMixin, BulkEditView):
default_return_url = 'extras:configcontext_list' default_return_url = 'extras:configcontext_list'
class ConfigContextDeleteView(PermissionRequiredMixin, ObjectDeleteView): class ConfigContextDeleteView(ObjectDeleteView):
permission_required = 'extras.delete_configcontext'
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
default_return_url = 'extras:configcontext_list' default_return_url = 'extras:configcontext_list'
class ConfigContextBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class ConfigContextBulkDeleteView(BulkDeleteView):
permission_required = 'extras.delete_configcontext'
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
table = ConfigContextTable table = ConfigContextTable
default_return_url = 'extras:configcontext_list' default_return_url = 'extras:configcontext_list'
class ObjectConfigContextView(View): class ObjectConfigContextView(ObjectView):
object_class = None
base_template = None base_template = None
def get(self, request, pk): def get(self, request, pk):
obj = get_object_or_404(self.object_class, pk=pk) obj = get_object_or_404(self.queryset, pk=pk)
source_contexts = ConfigContext.objects.get_for_object(obj) source_contexts = ConfigContext.objects.restrict(request.user, 'view').get_for_object(obj)
model_name = self.object_class._meta.model_name model_name = self.queryset.model._meta.model_name
# Determine user's preferred output format # Determine user's preferred output format
if request.GET.get('format') in ['json', 'yaml']: if request.GET.get('format') in ['json', 'yaml']:
@ -206,8 +193,7 @@ class ObjectConfigContextView(View):
# Change logging # Change logging
# #
class ObjectChangeListView(PermissionRequiredMixin, ObjectListView): class ObjectChangeListView(ObjectListView):
permission_required = 'extras.view_objectchange'
queryset = ObjectChange.objects.prefetch_related('user', 'changed_object_type') queryset = ObjectChange.objects.prefetch_related('user', 'changed_object_type')
filterset = filters.ObjectChangeFilterSet filterset = filters.ObjectChangeFilterSet
filterset_form = forms.ObjectChangeFilterForm filterset_form = forms.ObjectChangeFilterForm
@ -216,20 +202,24 @@ class ObjectChangeListView(PermissionRequiredMixin, ObjectListView):
action_buttons = ('export',) action_buttons = ('export',)
class ObjectChangeView(PermissionRequiredMixin, View): class ObjectChangeView(ObjectView):
permission_required = 'extras.view_objectchange' queryset = ObjectChange.objects.all()
def get(self, request, pk): 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( related_changes_table = ObjectChangeTable(
data=related_changes[:50], data=related_changes[:50],
orderable=False orderable=False
) )
objectchanges = ObjectChange.objects.filter( objectchanges = ObjectChange.objects.restrict(request.user, 'view').filter(
changed_object_type=objectchange.changed_object_type, changed_object_type=objectchange.changed_object_type,
changed_object_id=objectchange.changed_object_id, changed_object_id=objectchange.changed_object_id,
) )
@ -271,7 +261,7 @@ class ObjectChangeLogView(View):
# Gather all changes for this object (and its related objects) # Gather all changes for this object (and its related objects)
content_type = ContentType.objects.get_for_model(model) 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' 'user', 'changed_object_type'
).filter( ).filter(
Q(changed_object_type=content_type, changed_object_id=obj.pk) | Q(changed_object_type=content_type, changed_object_id=obj.pk) |
@ -310,8 +300,7 @@ class ObjectChangeLogView(View):
# Image attachments # Image attachments
# #
class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView): class ImageAttachmentEditView(ObjectEditView):
permission_required = 'extras.change_imageattachment'
queryset = ImageAttachment.objects.all() queryset = ImageAttachment.objects.all()
model_form = forms.ImageAttachmentForm model_form = forms.ImageAttachmentForm
@ -326,8 +315,7 @@ class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView):
return imageattachment.parent.get_absolute_url() return imageattachment.parent.get_absolute_url()
class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView): class ImageAttachmentDeleteView(ObjectDeleteView):
permission_required = 'extras.delete_imageattachment'
queryset = ImageAttachment.objects.all() queryset = ImageAttachment.objects.all()
def get_return_url(self, request, imageattachment): def get_return_url(self, request, imageattachment):
@ -338,11 +326,12 @@ class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView):
# Reports # 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. 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): 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). 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): 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. 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): def post(self, request, name):
@ -415,8 +406,10 @@ class ReportRunView(PermissionRequiredMixin, View):
# Scripts # Scripts
# #
class ScriptListView(PermissionRequiredMixin, View): class ScriptListView(ObjectPermissionRequiredMixin, View):
permission_required = 'extras.view_script'
def get_required_permission(self):
return 'extras.view_script'
def get(self, request): def get(self, request):
@ -425,8 +418,10 @@ class ScriptListView(PermissionRequiredMixin, View):
}) })
class ScriptView(PermissionRequiredMixin, View): class ScriptView(ObjectPermissionRequiredMixin, View):
permission_required = 'extras.view_script'
def get_required_permission(self):
return 'extras.view_script'
def _get_script(self, module, name): def _get_script(self, module, name):
scripts = get_scripts() scripts = get_scripts()

View File

@ -1,9 +1,10 @@
from django.db import models from django.db.models import Manager
from ipam.lookups import Host, Inet 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): 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 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. IP address as a /32 or /128.
""" """
qs = super().get_queryset() return super().get_queryset().order_by(Inet(Host('address')))
return qs.order_by(Inet(Host('address')))

View File

@ -12,6 +12,7 @@ from dcim.models import Device, Interface
from extras.models import CustomFieldModel, ObjectChange, TaggedItem from extras.models import CustomFieldModel, ObjectChange, TaggedItem
from extras.utils import extras_features from extras.utils import extras_features
from utilities.models import ChangeLoggedModel from utilities.models import ChangeLoggedModel
from utilities.querysets import RestrictedQuerySet
from utilities.utils import serialize_object from utilities.utils import serialize_object
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from .choices import * from .choices import *
@ -74,9 +75,10 @@ class VRF(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type', content_type_field='obj_type',
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
clone_fields = [ clone_fields = [
'tenant', 'enforce_unique', 'description', 'tenant', 'enforce_unique', 'description',
@ -131,6 +133,8 @@ class RIR(ChangeLoggedModel):
blank=True blank=True
) )
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'is_private', 'description'] csv_headers = ['name', 'slug', 'is_private', 'description']
class Meta: class Meta:
@ -179,9 +183,10 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type', content_type_field='obj_type',
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['prefix', 'rir', 'date_added', 'description'] csv_headers = ['prefix', 'rir', 'date_added', 'description']
clone_fields = [ clone_fields = [
'rir', 'date_added', 'description', 'rir', 'date_added', 'description',
@ -274,6 +279,8 @@ class Role(ChangeLoggedModel):
blank=True, blank=True,
) )
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'weight', 'description'] csv_headers = ['name', 'slug', 'weight', 'description']
class Meta: class Meta:
@ -360,9 +367,9 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type', content_type_field='obj_type',
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager(through=TaggedItem)
objects = PrefixQuerySet.as_manager() objects = PrefixQuerySet.as_manager()
tags = TaggableManager(through=TaggedItem)
csv_headers = [ csv_headers = [
'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'description', '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', content_type_field='obj_type',
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager(through=TaggedItem)
objects = IPAddressManager() objects = IPAddressManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = [ csv_headers = [
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary', 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary',
@ -828,6 +835,8 @@ class VLANGroup(ChangeLoggedModel):
blank=True blank=True
) )
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'site', 'description'] csv_headers = ['name', 'slug', 'site', 'description']
class Meta: class Meta:
@ -923,9 +932,10 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type', content_type_field='obj_type',
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description'] csv_headers = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description']
clone_fields = [ clone_fields = [
'site', 'group', 'tenant', 'status', 'role', 'description', 'site', 'group', 'tenant', 'status', 'role', 'description',
@ -1039,9 +1049,10 @@ class Service(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type', content_type_field='obj_type',
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'port', 'description'] csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'port', 'description']
class Meta: class Meta:

View File

@ -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): def annotate_depth(self, limit=None):
""" """

View File

@ -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 model = Service
# TODO: Resolve URL for Service creation
test_create_object = None
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):

View File

@ -9,7 +9,7 @@ urlpatterns = [
# VRFs # VRFs
path('vrfs/', views.VRFListView.as_view(), name='vrf_list'), 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/import/', views.VRFBulkImportView.as_view(), name='vrf_import'),
path('vrfs/edit/', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'), path('vrfs/edit/', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'),
path('vrfs/delete/', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'), path('vrfs/delete/', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'),
@ -20,7 +20,7 @@ urlpatterns = [
# RIRs # RIRs
path('rirs/', views.RIRListView.as_view(), name='rir_list'), 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/import/', views.RIRBulkImportView.as_view(), name='rir_import'),
path('rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'), path('rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'),
path('rirs/<slug:slug>/edit/', views.RIREditView.as_view(), name='rir_edit'), path('rirs/<slug:slug>/edit/', views.RIREditView.as_view(), name='rir_edit'),
@ -28,7 +28,7 @@ urlpatterns = [
# Aggregates # Aggregates
path('aggregates/', views.AggregateListView.as_view(), name='aggregate_list'), 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/import/', views.AggregateBulkImportView.as_view(), name='aggregate_import'),
path('aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'), path('aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'),
path('aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'), path('aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'),
@ -39,7 +39,7 @@ urlpatterns = [
# Roles # Roles
path('roles/', views.RoleListView.as_view(), name='role_list'), 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/import/', views.RoleBulkImportView.as_view(), name='role_import'),
path('roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'), path('roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'),
path('roles/<slug:slug>/edit/', views.RoleEditView.as_view(), name='role_edit'), path('roles/<slug:slug>/edit/', views.RoleEditView.as_view(), name='role_edit'),
@ -47,7 +47,7 @@ urlpatterns = [
# Prefixes # Prefixes
path('prefixes/', views.PrefixListView.as_view(), name='prefix_list'), 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/import/', views.PrefixBulkImportView.as_view(), name='prefix_import'),
path('prefixes/edit/', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'), path('prefixes/edit/', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'),
path('prefixes/delete/', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'), path('prefixes/delete/', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'),
@ -60,7 +60,7 @@ urlpatterns = [
# IP addresses # IP addresses
path('ip-addresses/', views.IPAddressListView.as_view(), name='ipaddress_list'), 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/bulk-add/', views.IPAddressBulkCreateView.as_view(), name='ipaddress_bulk_add'),
path('ip-addresses/import/', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'), path('ip-addresses/import/', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'),
path('ip-addresses/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'), path('ip-addresses/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
@ -73,7 +73,7 @@ urlpatterns = [
# VLAN groups # VLAN groups
path('vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'), 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/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'),
path('vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'), path('vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'),
path('vlan-groups/<int:pk>/edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), path('vlan-groups/<int:pk>/edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
@ -82,7 +82,7 @@ urlpatterns = [
# VLANs # VLANs
path('vlans/', views.VLANListView.as_view(), name='vlan_list'), 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/import/', views.VLANBulkImportView.as_view(), name='vlan_import'),
path('vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'), path('vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'), path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),

View File

@ -1,16 +1,15 @@
import netaddr import netaddr
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Count, Q from django.db.models import Count, Q
from django.db.models.expressions import RawSQL from django.db.models.expressions import RawSQL
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.views.generic import View
from django_tables2 import RequestConfig from django_tables2 import RequestConfig
from dcim.models import Device, Interface from dcim.models import Device, Interface
from utilities.paginator import EnhancedPaginator from utilities.paginator import EnhancedPaginator
from utilities.views import ( 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 virtualization.models import VirtualMachine
from . import filters, forms, tables from . import filters, forms, tables
@ -112,21 +111,20 @@ def add_available_vlans(vlan_group, vlans):
# VRFs # VRFs
# #
class VRFListView(PermissionRequiredMixin, ObjectListView): class VRFListView(ObjectListView):
permission_required = 'ipam.view_vrf'
queryset = VRF.objects.prefetch_related('tenant') queryset = VRF.objects.prefetch_related('tenant')
filterset = filters.VRFFilterSet filterset = filters.VRFFilterSet
filterset_form = forms.VRFFilterForm filterset_form = forms.VRFFilterForm
table = tables.VRFTable table = tables.VRFTable
class VRFView(PermissionRequiredMixin, View): class VRFView(ObjectView):
permission_required = 'ipam.view_vrf' queryset = VRF.objects.all()
def get(self, request, pk): def get(self, request, pk):
vrf = get_object_or_404(VRF.objects.all(), pk=pk) vrf = get_object_or_404(self.queryset, pk=pk)
prefix_count = Prefix.objects.filter(vrf=vrf).count() prefix_count = Prefix.objects.restrict(request.user, 'view').filter(vrf=vrf).count()
return render(request, 'ipam/vrf.html', { return render(request, 'ipam/vrf.html', {
'vrf': vrf, 'vrf': vrf,
@ -134,33 +132,26 @@ class VRFView(PermissionRequiredMixin, View):
}) })
class VRFCreateView(PermissionRequiredMixin, ObjectEditView): class VRFEditView(ObjectEditView):
permission_required = 'ipam.add_vrf'
queryset = VRF.objects.all() queryset = VRF.objects.all()
model_form = forms.VRFForm model_form = forms.VRFForm
template_name = 'ipam/vrf_edit.html' template_name = 'ipam/vrf_edit.html'
default_return_url = 'ipam:vrf_list' default_return_url = 'ipam:vrf_list'
class VRFEditView(VRFCreateView): class VRFDeleteView(ObjectDeleteView):
permission_required = 'ipam.change_vrf'
class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_vrf'
queryset = VRF.objects.all() queryset = VRF.objects.all()
default_return_url = 'ipam:vrf_list' default_return_url = 'ipam:vrf_list'
class VRFBulkImportView(PermissionRequiredMixin, BulkImportView): class VRFBulkImportView(BulkImportView):
permission_required = 'ipam.add_vrf' queryset = VRF.objects.all()
model_form = forms.VRFCSVForm model_form = forms.VRFCSVForm
table = tables.VRFTable table = tables.VRFTable
default_return_url = 'ipam:vrf_list' default_return_url = 'ipam:vrf_list'
class VRFBulkEditView(PermissionRequiredMixin, BulkEditView): class VRFBulkEditView(BulkEditView):
permission_required = 'ipam.change_vrf'
queryset = VRF.objects.prefetch_related('tenant') queryset = VRF.objects.prefetch_related('tenant')
filterset = filters.VRFFilterSet filterset = filters.VRFFilterSet
table = tables.VRFTable table = tables.VRFTable
@ -168,8 +159,7 @@ class VRFBulkEditView(PermissionRequiredMixin, BulkEditView):
default_return_url = 'ipam:vrf_list' default_return_url = 'ipam:vrf_list'
class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class VRFBulkDeleteView(BulkDeleteView):
permission_required = 'ipam.delete_vrf'
queryset = VRF.objects.prefetch_related('tenant') queryset = VRF.objects.prefetch_related('tenant')
filterset = filters.VRFFilterSet filterset = filters.VRFFilterSet
table = tables.VRFTable table = tables.VRFTable
@ -180,8 +170,7 @@ class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# RIRs # RIRs
# #
class RIRListView(PermissionRequiredMixin, ObjectListView): class RIRListView(ObjectListView):
permission_required = 'ipam.view_rir'
queryset = RIR.objects.annotate(aggregate_count=Count('aggregates')) queryset = RIR.objects.annotate(aggregate_count=Count('aggregates'))
filterset = filters.RIRFilterSet filterset = filters.RIRFilterSet
filterset_form = forms.RIRFilterForm filterset_form = forms.RIRFilterForm
@ -257,26 +246,20 @@ class RIRListView(PermissionRequiredMixin, ObjectListView):
return rirs return rirs
class RIRCreateView(PermissionRequiredMixin, ObjectEditView): class RIREditView(ObjectEditView):
permission_required = 'ipam.add_rir'
queryset = RIR.objects.all() queryset = RIR.objects.all()
model_form = forms.RIRForm model_form = forms.RIRForm
default_return_url = 'ipam:rir_list' default_return_url = 'ipam:rir_list'
class RIREditView(RIRCreateView): class RIRBulkImportView(BulkImportView):
permission_required = 'ipam.change_rir' queryset = RIR.objects.all()
class RIRBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_rir'
model_form = forms.RIRCSVForm model_form = forms.RIRCSVForm
table = tables.RIRTable table = tables.RIRTable
default_return_url = 'ipam:rir_list' default_return_url = 'ipam:rir_list'
class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class RIRBulkDeleteView(BulkDeleteView):
permission_required = 'ipam.delete_rir'
queryset = RIR.objects.annotate(aggregate_count=Count('aggregates')) queryset = RIR.objects.annotate(aggregate_count=Count('aggregates'))
filterset = filters.RIRFilterSet filterset = filters.RIRFilterSet
table = tables.RIRTable table = tables.RIRTable
@ -287,8 +270,7 @@ class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Aggregates # Aggregates
# #
class AggregateListView(PermissionRequiredMixin, ObjectListView): class AggregateListView(ObjectListView):
permission_required = 'ipam.view_aggregate'
queryset = Aggregate.objects.prefetch_related('rir').annotate( queryset = Aggregate.objects.prefetch_related('rir').annotate(
child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ()) 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): class AggregateView(ObjectView):
permission_required = 'ipam.view_aggregate' queryset = Aggregate.objects.all()
def get(self, request, pk): 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 # 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) prefix__net_contained_or_equal=str(aggregate.prefix)
).prefetch_related( ).prefetch_related(
'site', 'role' 'site', 'role'
@ -359,33 +341,26 @@ class AggregateView(PermissionRequiredMixin, View):
}) })
class AggregateCreateView(PermissionRequiredMixin, ObjectEditView): class AggregateEditView(ObjectEditView):
permission_required = 'ipam.add_aggregate'
queryset = Aggregate.objects.all() queryset = Aggregate.objects.all()
model_form = forms.AggregateForm model_form = forms.AggregateForm
template_name = 'ipam/aggregate_edit.html' template_name = 'ipam/aggregate_edit.html'
default_return_url = 'ipam:aggregate_list' default_return_url = 'ipam:aggregate_list'
class AggregateEditView(AggregateCreateView): class AggregateDeleteView(ObjectDeleteView):
permission_required = 'ipam.change_aggregate'
class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_aggregate'
queryset = Aggregate.objects.all() queryset = Aggregate.objects.all()
default_return_url = 'ipam:aggregate_list' default_return_url = 'ipam:aggregate_list'
class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView): class AggregateBulkImportView(BulkImportView):
permission_required = 'ipam.add_aggregate' queryset = Aggregate.objects.all()
model_form = forms.AggregateCSVForm model_form = forms.AggregateCSVForm
table = tables.AggregateTable table = tables.AggregateTable
default_return_url = 'ipam:aggregate_list' default_return_url = 'ipam:aggregate_list'
class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView): class AggregateBulkEditView(BulkEditView):
permission_required = 'ipam.change_aggregate'
queryset = Aggregate.objects.prefetch_related('rir') queryset = Aggregate.objects.prefetch_related('rir')
filterset = filters.AggregateFilterSet filterset = filters.AggregateFilterSet
table = tables.AggregateTable table = tables.AggregateTable
@ -393,8 +368,7 @@ class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView):
default_return_url = 'ipam:aggregate_list' default_return_url = 'ipam:aggregate_list'
class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class AggregateBulkDeleteView(BulkDeleteView):
permission_required = 'ipam.delete_aggregate'
queryset = Aggregate.objects.prefetch_related('rir') queryset = Aggregate.objects.prefetch_related('rir')
filterset = filters.AggregateFilterSet filterset = filters.AggregateFilterSet
table = tables.AggregateTable table = tables.AggregateTable
@ -405,32 +379,25 @@ class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Prefix/VLAN roles # Prefix/VLAN roles
# #
class RoleListView(PermissionRequiredMixin, ObjectListView): class RoleListView(ObjectListView):
permission_required = 'ipam.view_role'
queryset = Role.objects.all() queryset = Role.objects.all()
table = tables.RoleTable table = tables.RoleTable
class RoleCreateView(PermissionRequiredMixin, ObjectEditView): class RoleEditView(ObjectEditView):
permission_required = 'ipam.add_role'
queryset = Role.objects.all() queryset = Role.objects.all()
model_form = forms.RoleForm model_form = forms.RoleForm
default_return_url = 'ipam:role_list' default_return_url = 'ipam:role_list'
class RoleEditView(RoleCreateView): class RoleBulkImportView(BulkImportView):
permission_required = 'ipam.change_role' queryset = Role.objects.all()
class RoleBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_role'
model_form = forms.RoleCSVForm model_form = forms.RoleCSVForm
table = tables.RoleTable table = tables.RoleTable
default_return_url = 'ipam:role_list' default_return_url = 'ipam:role_list'
class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class RoleBulkDeleteView(BulkDeleteView):
permission_required = 'ipam.delete_role'
queryset = Role.objects.all() queryset = Role.objects.all()
table = tables.RoleTable table = tables.RoleTable
default_return_url = 'ipam:role_list' default_return_url = 'ipam:role_list'
@ -440,8 +407,7 @@ class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Prefixes # Prefixes
# #
class PrefixListView(PermissionRequiredMixin, ObjectListView): class PrefixListView(ObjectListView):
permission_required = 'ipam.view_prefix'
queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
filterset = filters.PrefixFilterSet filterset = filters.PrefixFilterSet
filterset_form = forms.PrefixFilterForm filterset_form = forms.PrefixFilterForm
@ -454,22 +420,22 @@ class PrefixListView(PermissionRequiredMixin, ObjectListView):
return self.queryset.annotate_depth(limit=limit) return self.queryset.annotate_depth(limit=limit)
class PrefixView(PermissionRequiredMixin, View): class PrefixView(ObjectView):
permission_required = 'ipam.view_prefix' queryset = Prefix.objects.prefetch_related('vrf', 'site__region', 'tenant__group', 'vlan__group', 'role')
def get(self, request, pk): def get(self, request, pk):
prefix = get_object_or_404(Prefix.objects.prefetch_related( prefix = get_object_or_404(self.queryset, pk=pk)
'vrf', 'site__region', 'tenant__group', 'vlan__group', 'role'
), pk=pk)
try: 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: except Aggregate.DoesNotExist:
aggregate = None aggregate = None
# Parent prefixes table # 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) Q(vrf=prefix.vrf) | Q(vrf__isnull=True)
).filter( ).filter(
prefix__net_contains=str(prefix.prefix) prefix__net_contains=str(prefix.prefix)
@ -480,7 +446,7 @@ class PrefixView(PermissionRequiredMixin, View):
parent_prefix_table.exclude = ('vrf',) parent_prefix_table.exclude = ('vrf',)
# Duplicate prefixes table # Duplicate prefixes table
duplicate_prefixes = Prefix.objects.filter( duplicate_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
vrf=prefix.vrf, prefix=str(prefix.prefix) vrf=prefix.vrf, prefix=str(prefix.prefix)
).exclude( ).exclude(
pk=prefix.pk pk=prefix.pk
@ -498,15 +464,15 @@ class PrefixView(PermissionRequiredMixin, View):
}) })
class PrefixPrefixesView(PermissionRequiredMixin, View): class PrefixPrefixesView(ObjectView):
permission_required = 'ipam.view_prefix' queryset = Prefix.objects.all()
def get(self, request, pk): 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 table
child_prefixes = prefix.get_child_prefixes().prefetch_related( child_prefixes = prefix.get_child_prefixes().restrict(request.user, 'view').prefetch_related(
'site', 'vlan', 'role', 'site', 'vlan', 'role',
).annotate_depth(limit=0) ).annotate_depth(limit=0)
@ -542,15 +508,15 @@ class PrefixPrefixesView(PermissionRequiredMixin, View):
}) })
class PrefixIPAddressesView(PermissionRequiredMixin, View): class PrefixIPAddressesView(ObjectView):
permission_required = 'ipam.view_prefix' queryset = Prefix.objects.all()
def get(self, request, pk): 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 # 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' 'vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for'
) )
@ -586,34 +552,27 @@ class PrefixIPAddressesView(PermissionRequiredMixin, View):
}) })
class PrefixCreateView(PermissionRequiredMixin, ObjectEditView): class PrefixEditView(ObjectEditView):
permission_required = 'ipam.add_prefix'
queryset = Prefix.objects.all() queryset = Prefix.objects.all()
model_form = forms.PrefixForm model_form = forms.PrefixForm
template_name = 'ipam/prefix_edit.html' template_name = 'ipam/prefix_edit.html'
default_return_url = 'ipam:prefix_list' default_return_url = 'ipam:prefix_list'
class PrefixEditView(PrefixCreateView): class PrefixDeleteView(ObjectDeleteView):
permission_required = 'ipam.change_prefix'
class PrefixDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_prefix'
queryset = Prefix.objects.all() queryset = Prefix.objects.all()
template_name = 'ipam/prefix_delete.html' template_name = 'ipam/prefix_delete.html'
default_return_url = 'ipam:prefix_list' default_return_url = 'ipam:prefix_list'
class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView): class PrefixBulkImportView(BulkImportView):
permission_required = 'ipam.add_prefix' queryset = Prefix.objects.all()
model_form = forms.PrefixCSVForm model_form = forms.PrefixCSVForm
table = tables.PrefixTable table = tables.PrefixTable
default_return_url = 'ipam:prefix_list' default_return_url = 'ipam:prefix_list'
class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView): class PrefixBulkEditView(BulkEditView):
permission_required = 'ipam.change_prefix'
queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
filterset = filters.PrefixFilterSet filterset = filters.PrefixFilterSet
table = tables.PrefixTable table = tables.PrefixTable
@ -621,8 +580,7 @@ class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView):
default_return_url = 'ipam:prefix_list' default_return_url = 'ipam:prefix_list'
class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class PrefixBulkDeleteView(BulkDeleteView):
permission_required = 'ipam.delete_prefix'
queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
filterset = filters.PrefixFilterSet filterset = filters.PrefixFilterSet
table = tables.PrefixTable table = tables.PrefixTable
@ -633,8 +591,7 @@ class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# IP addresses # IP addresses
# #
class IPAddressListView(PermissionRequiredMixin, ObjectListView): class IPAddressListView(ObjectListView):
permission_required = 'ipam.view_ipaddress'
queryset = IPAddress.objects.prefetch_related( queryset = IPAddress.objects.prefetch_related(
'vrf__tenant', 'tenant', 'nat_inside', 'interface__device', 'interface__virtual_machine' 'vrf__tenant', 'tenant', 'nat_inside', 'interface__device', 'interface__virtual_machine'
) )
@ -643,15 +600,15 @@ class IPAddressListView(PermissionRequiredMixin, ObjectListView):
table = tables.IPAddressDetailTable table = tables.IPAddressDetailTable
class IPAddressView(PermissionRequiredMixin, View): class IPAddressView(ObjectView):
permission_required = 'ipam.view_ipaddress' queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
def get(self, request, pk): 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 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) vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip)
).prefetch_related( ).prefetch_related(
'site', 'role' 'site', 'role'
@ -660,7 +617,7 @@ class IPAddressView(PermissionRequiredMixin, View):
parent_prefixes_table.exclude = ('vrf',) parent_prefixes_table.exclude = ('vrf',)
# Duplicate IPs table # Duplicate IPs table
duplicate_ips = IPAddress.objects.filter( duplicate_ips = IPAddress.objects.restrict(request.user, 'view').filter(
vrf=ipaddress.vrf, address=str(ipaddress.address) vrf=ipaddress.vrf, address=str(ipaddress.address)
).exclude( ).exclude(
pk=ipaddress.pk pk=ipaddress.pk
@ -673,14 +630,13 @@ class IPAddressView(PermissionRequiredMixin, View):
duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False) duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False)
# Related IP table # Related IP table
related_ips = IPAddress.objects.prefetch_related( related_ips = IPAddress.objects.restrict(request.user, 'view').prefetch_related(
'interface__device' 'interface__device'
).exclude( ).exclude(
address=str(ipaddress.address) address=str(ipaddress.address)
).filter( ).filter(
vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address) vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address)
) )
related_ips_table = tables.IPAddressTable(related_ips, orderable=False) related_ips_table = tables.IPAddressTable(related_ips, orderable=False)
paginate = { paginate = {
@ -697,8 +653,7 @@ class IPAddressView(PermissionRequiredMixin, View):
}) })
class IPAddressCreateView(PermissionRequiredMixin, ObjectEditView): class IPAddressEditView(ObjectEditView):
permission_required = 'ipam.add_ipaddress'
queryset = IPAddress.objects.all() queryset = IPAddress.objects.all()
model_form = forms.IPAddressForm model_form = forms.IPAddressForm
template_name = 'ipam/ipaddress_edit.html' template_name = 'ipam/ipaddress_edit.html'
@ -716,15 +671,11 @@ class IPAddressCreateView(PermissionRequiredMixin, ObjectEditView):
return obj return obj
class IPAddressEditView(IPAddressCreateView): class IPAddressAssignView(ObjectView):
permission_required = 'ipam.change_ipaddress'
class IPAddressAssignView(PermissionRequiredMixin, View):
""" """
Search for IPAddresses to be assigned to an Interface. Search for IPAddresses to be assigned to an Interface.
""" """
permission_required = 'ipam.change_ipaddress' queryset = IPAddress.objects.all()
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
@ -735,7 +686,6 @@ class IPAddressAssignView(PermissionRequiredMixin, View):
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get(self, request): def get(self, request):
form = forms.IPAddressAssignForm() form = forms.IPAddressAssignForm()
return render(request, 'ipam/ipaddress_assign.html', { return render(request, 'ipam/ipaddress_assign.html', {
@ -744,13 +694,12 @@ class IPAddressAssignView(PermissionRequiredMixin, View):
}) })
def post(self, request): def post(self, request):
form = forms.IPAddressAssignForm(request.POST) form = forms.IPAddressAssignForm(request.POST)
table = None table = None
if form.is_valid(): if form.is_valid():
addresses = IPAddress.objects.prefetch_related( addresses = self.queryset.prefetch_related(
'vrf', 'tenant', 'interface__device', 'interface__virtual_machine' 'vrf', 'tenant', 'interface__device', 'interface__virtual_machine'
) )
# Limit to 100 results # Limit to 100 results
@ -764,14 +713,12 @@ class IPAddressAssignView(PermissionRequiredMixin, View):
}) })
class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView): class IPAddressDeleteView(ObjectDeleteView):
permission_required = 'ipam.delete_ipaddress'
queryset = IPAddress.objects.all() queryset = IPAddress.objects.all()
default_return_url = 'ipam:ipaddress_list' default_return_url = 'ipam:ipaddress_list'
class IPAddressBulkCreateView(PermissionRequiredMixin, BulkCreateView): class IPAddressBulkCreateView(BulkCreateView):
permission_required = 'ipam.add_ipaddress'
form = forms.IPAddressBulkCreateForm form = forms.IPAddressBulkCreateForm
model_form = forms.IPAddressBulkAddForm model_form = forms.IPAddressBulkAddForm
pattern_target = 'address' pattern_target = 'address'
@ -779,15 +726,14 @@ class IPAddressBulkCreateView(PermissionRequiredMixin, BulkCreateView):
default_return_url = 'ipam:ipaddress_list' default_return_url = 'ipam:ipaddress_list'
class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView): class IPAddressBulkImportView(BulkImportView):
permission_required = 'ipam.add_ipaddress' queryset = IPAddress.objects.all()
model_form = forms.IPAddressCSVForm model_form = forms.IPAddressCSVForm
table = tables.IPAddressTable table = tables.IPAddressTable
default_return_url = 'ipam:ipaddress_list' default_return_url = 'ipam:ipaddress_list'
class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView): class IPAddressBulkEditView(BulkEditView):
permission_required = 'ipam.change_ipaddress'
queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant').prefetch_related('interface__device') queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
filterset = filters.IPAddressFilterSet filterset = filters.IPAddressFilterSet
table = tables.IPAddressTable table = tables.IPAddressTable
@ -795,8 +741,7 @@ class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
default_return_url = 'ipam:ipaddress_list' default_return_url = 'ipam:ipaddress_list'
class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class IPAddressBulkDeleteView(BulkDeleteView):
permission_required = 'ipam.delete_ipaddress'
queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant').prefetch_related('interface__device') queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
filterset = filters.IPAddressFilterSet filterset = filters.IPAddressFilterSet
table = tables.IPAddressTable table = tables.IPAddressTable
@ -807,48 +752,40 @@ class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# VLAN groups # VLAN groups
# #
class VLANGroupListView(PermissionRequiredMixin, ObjectListView): class VLANGroupListView(ObjectListView):
permission_required = 'ipam.view_vlangroup'
queryset = VLANGroup.objects.prefetch_related('site').annotate(vlan_count=Count('vlans')) queryset = VLANGroup.objects.prefetch_related('site').annotate(vlan_count=Count('vlans'))
filterset = filters.VLANGroupFilterSet filterset = filters.VLANGroupFilterSet
filterset_form = forms.VLANGroupFilterForm filterset_form = forms.VLANGroupFilterForm
table = tables.VLANGroupTable table = tables.VLANGroupTable
class VLANGroupCreateView(PermissionRequiredMixin, ObjectEditView): class VLANGroupEditView(ObjectEditView):
permission_required = 'ipam.add_vlangroup'
queryset = VLANGroup.objects.all() queryset = VLANGroup.objects.all()
model_form = forms.VLANGroupForm model_form = forms.VLANGroupForm
default_return_url = 'ipam:vlangroup_list' default_return_url = 'ipam:vlangroup_list'
class VLANGroupEditView(VLANGroupCreateView): class VLANGroupBulkImportView(BulkImportView):
permission_required = 'ipam.change_vlangroup' queryset = VLANGroup.objects.all()
class VLANGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_vlangroup'
model_form = forms.VLANGroupCSVForm model_form = forms.VLANGroupCSVForm
table = tables.VLANGroupTable table = tables.VLANGroupTable
default_return_url = 'ipam:vlangroup_list' default_return_url = 'ipam:vlangroup_list'
class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class VLANGroupBulkDeleteView(BulkDeleteView):
permission_required = 'ipam.delete_vlangroup'
queryset = VLANGroup.objects.prefetch_related('site').annotate(vlan_count=Count('vlans')) queryset = VLANGroup.objects.prefetch_related('site').annotate(vlan_count=Count('vlans'))
filterset = filters.VLANGroupFilterSet filterset = filters.VLANGroupFilterSet
table = tables.VLANGroupTable table = tables.VLANGroupTable
default_return_url = 'ipam:vlangroup_list' default_return_url = 'ipam:vlangroup_list'
class VLANGroupVLANsView(PermissionRequiredMixin, View): class VLANGroupVLANsView(ObjectView):
permission_required = 'ipam.view_vlangroup' queryset = VLANGroup.objects.all()
def get(self, request, pk): 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.restrict(request.user, 'view').filter(group_id=pk)
vlans = VLAN.objects.filter(group_id=pk)
vlans = add_available_vlans(vlan_group, vlans) vlans = add_available_vlans(vlan_group, vlans)
vlan_table = tables.VLANDetailTable(vlans) vlan_table = tables.VLANDetailTable(vlans)
@ -882,23 +819,22 @@ class VLANGroupVLANsView(PermissionRequiredMixin, View):
# VLANs # VLANs
# #
class VLANListView(PermissionRequiredMixin, ObjectListView): class VLANListView(ObjectListView):
permission_required = 'ipam.view_vlan'
queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role').prefetch_related('prefixes') queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role').prefetch_related('prefixes')
filterset = filters.VLANFilterSet filterset = filters.VLANFilterSet
filterset_form = forms.VLANFilterForm filterset_form = forms.VLANFilterForm
table = tables.VLANDetailTable table = tables.VLANDetailTable
class VLANView(PermissionRequiredMixin, View): class VLANView(ObjectView):
permission_required = 'ipam.view_vlan' queryset = VLAN.objects.prefetch_related('site__region', 'tenant__group', 'role')
def get(self, request, pk): def get(self, request, pk):
vlan = get_object_or_404(VLAN.objects.prefetch_related( vlan = get_object_or_404(self.queryset, pk=pk)
'site__region', 'tenant__group', 'role' prefixes = Prefix.objects.restrict(request.user, 'view').filter(vlan=vlan).prefetch_related(
), pk=pk) 'vrf', 'site', 'role'
prefixes = Prefix.objects.filter(vlan=vlan).prefetch_related('vrf', 'site', 'role') )
prefix_table = tables.PrefixTable(list(prefixes), orderable=False) prefix_table = tables.PrefixTable(list(prefixes), orderable=False)
prefix_table.exclude = ('vlan',) prefix_table.exclude = ('vlan',)
@ -908,13 +844,13 @@ class VLANView(PermissionRequiredMixin, View):
}) })
class VLANMembersView(PermissionRequiredMixin, View): class VLANMembersView(ObjectView):
permission_required = 'ipam.view_vlan' queryset = VLAN.objects.all()
def get(self, request, pk): def get(self, request, pk):
vlan = get_object_or_404(VLAN.objects.all(), pk=pk) vlan = get_object_or_404(self.queryset, pk=pk)
members = vlan.get_members().prefetch_related('device', 'virtual_machine') members = vlan.get_members().restrict(request.user, 'view').prefetch_related('device', 'virtual_machine')
members_table = tables.VLANMemberTable(members) members_table = tables.VLANMemberTable(members)
@ -931,33 +867,26 @@ class VLANMembersView(PermissionRequiredMixin, View):
}) })
class VLANCreateView(PermissionRequiredMixin, ObjectEditView): class VLANEditView(ObjectEditView):
permission_required = 'ipam.add_vlan'
queryset = VLAN.objects.all() queryset = VLAN.objects.all()
model_form = forms.VLANForm model_form = forms.VLANForm
template_name = 'ipam/vlan_edit.html' template_name = 'ipam/vlan_edit.html'
default_return_url = 'ipam:vlan_list' default_return_url = 'ipam:vlan_list'
class VLANEditView(VLANCreateView): class VLANDeleteView(ObjectDeleteView):
permission_required = 'ipam.change_vlan'
class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_vlan'
queryset = VLAN.objects.all() queryset = VLAN.objects.all()
default_return_url = 'ipam:vlan_list' default_return_url = 'ipam:vlan_list'
class VLANBulkImportView(PermissionRequiredMixin, BulkImportView): class VLANBulkImportView(BulkImportView):
permission_required = 'ipam.add_vlan' queryset = VLAN.objects.all()
model_form = forms.VLANCSVForm model_form = forms.VLANCSVForm
table = tables.VLANTable table = tables.VLANTable
default_return_url = 'ipam:vlan_list' default_return_url = 'ipam:vlan_list'
class VLANBulkEditView(PermissionRequiredMixin, BulkEditView): class VLANBulkEditView(BulkEditView):
permission_required = 'ipam.change_vlan'
queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role') queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role')
filterset = filters.VLANFilterSet filterset = filters.VLANFilterSet
table = tables.VLANTable table = tables.VLANTable
@ -965,8 +894,7 @@ class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
default_return_url = 'ipam:vlan_list' default_return_url = 'ipam:vlan_list'
class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class VLANBulkDeleteView(BulkDeleteView):
permission_required = 'ipam.delete_vlan'
queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role') queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role')
filterset = filters.VLANFilterSet filterset = filters.VLANFilterSet
table = tables.VLANTable table = tables.VLANTable
@ -977,8 +905,7 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Services # Services
# #
class ServiceListView(PermissionRequiredMixin, ObjectListView): class ServiceListView(ObjectListView):
permission_required = 'ipam.view_service'
queryset = Service.objects.prefetch_related('device', 'virtual_machine') queryset = Service.objects.prefetch_related('device', 'virtual_machine')
filterset = filters.ServiceFilterSet filterset = filters.ServiceFilterSet
filterset_form = forms.ServiceFilterForm filterset_form = forms.ServiceFilterForm
@ -986,20 +913,19 @@ class ServiceListView(PermissionRequiredMixin, ObjectListView):
action_buttons = ('export',) action_buttons = ('export',)
class ServiceView(PermissionRequiredMixin, View): class ServiceView(ObjectView):
permission_required = 'ipam.view_service' queryset = Service.objects.all()
def get(self, request, pk): 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', { return render(request, 'ipam/service.html', {
'service': service, 'service': service,
}) })
class ServiceCreateView(PermissionRequiredMixin, ObjectEditView): class ServiceEditView(ObjectEditView):
permission_required = 'ipam.add_service'
queryset = Service.objects.all() queryset = Service.objects.all()
model_form = forms.ServiceForm model_form = forms.ServiceForm
template_name = 'ipam/service_edit.html' template_name = 'ipam/service_edit.html'
@ -1015,24 +941,18 @@ class ServiceCreateView(PermissionRequiredMixin, ObjectEditView):
return service.parent.get_absolute_url() return service.parent.get_absolute_url()
class ServiceBulkImportView(PermissionRequiredMixin, BulkImportView): class ServiceBulkImportView(BulkImportView):
permission_required = 'ipam.add_service' queryset = Service.objects.all()
model_form = forms.ServiceCSVForm model_form = forms.ServiceCSVForm
table = tables.ServiceTable table = tables.ServiceTable
default_return_url = 'ipam:service_list' default_return_url = 'ipam:service_list'
class ServiceEditView(ServiceCreateView): class ServiceDeleteView(ObjectDeleteView):
permission_required = 'ipam.change_service'
class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_service'
queryset = Service.objects.all() queryset = Service.objects.all()
class ServiceBulkEditView(PermissionRequiredMixin, BulkEditView): class ServiceBulkEditView(BulkEditView):
permission_required = 'ipam.change_service'
queryset = Service.objects.prefetch_related('device', 'virtual_machine') queryset = Service.objects.prefetch_related('device', 'virtual_machine')
filterset = filters.ServiceFilterSet filterset = filters.ServiceFilterSet
table = tables.ServiceTable table = tables.ServiceTable
@ -1040,8 +960,7 @@ class ServiceBulkEditView(PermissionRequiredMixin, BulkEditView):
default_return_url = 'ipam:service_list' default_return_url = 'ipam:service_list'
class ServiceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class ServiceBulkDeleteView(BulkDeleteView):
permission_required = 'ipam.delete_service'
queryset = Service.objects.prefetch_related('device', 'virtual_machine') queryset = Service.objects.prefetch_related('device', 'virtual_machine')
filterset = filters.ServiceFilterSet filterset = filters.ServiceFilterSet
table = tables.ServiceTable table = tables.ServiceTable

View File

@ -2,7 +2,7 @@ from django.conf import settings
from django.db.models import QuerySet from django.db.models import QuerySet
from rest_framework import authentication, exceptions from rest_framework import authentication, exceptions
from rest_framework.pagination import LimitOffsetPagination 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.renderers import BrowsableAPIRenderer
from rest_framework.utils import formatting from rest_framework.utils import formatting
@ -51,7 +51,7 @@ class TokenAuthentication(authentication.TokenAuthentication):
return token.user, token 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 Custom permissions handler which extends the built-in DjangoModelPermissions to validate a Token's write ability
for unsafe requests (POST/PUT/PATCH/DELETE). for unsafe requests (POST/PUT/PATCH/DELETE).
@ -74,15 +74,29 @@ class TokenPermissions(DjangoModelPermissions):
super().__init__() 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): def has_permission(self, request, view):
# If token authentication is in use, verify that the token allows write operations (for unsafe methods). # Enforce Token write ability
if request.method not in SAFE_METHODS and isinstance(request.auth, Token): if not self._verify_write_permission(request):
if not request.auth.write_enabled: return False
return False
return super().has_permission(request, view) 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 # Pagination

View File

@ -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 "
"<app>.<action>_<model>. (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

View File

@ -205,11 +205,11 @@ PREFER_IPV4 = False
# Remote authentication support # Remote authentication support
REMOTE_AUTH_ENABLED = False 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_HEADER = 'HTTP_REMOTE_USER'
REMOTE_AUTH_AUTO_CREATE_USER = True REMOTE_AUTH_AUTO_CREATE_USER = True
REMOTE_AUTH_DEFAULT_GROUPS = [] 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. # 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 RELEASE_CHECK_TIMEOUT = 24 * 3600

View File

@ -97,9 +97,9 @@ PLUGINS = getattr(configuration, 'PLUGINS', [])
PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {}) PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False) PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', 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_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_ENABLED = getattr(configuration, 'REMOTE_AUTH_ENABLED', False)
REMOTE_AUTH_HEADER = getattr(configuration, 'REMOTE_AUTH_HEADER', 'HTTP_REMOTE_USER') REMOTE_AUTH_HEADER = getattr(configuration, 'REMOTE_AUTH_HEADER', 'HTTP_REMOTE_USER')
RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None) RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
@ -127,6 +127,17 @@ if RELEASE_CHECK_URL:
if RELEASE_CHECK_TIMEOUT < 3600: if RELEASE_CHECK_TIMEOUT < 3600:
raise ImproperlyConfigured("RELEASE_CHECK_TIMEOUT has to be at least 3600 seconds (1 hour)") 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 # Database
@ -339,7 +350,7 @@ TEMPLATES = [
# Set up authentication backends # Set up authentication backends
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
REMOTE_AUTH_BACKEND, REMOTE_AUTH_BACKEND,
'utilities.auth_backends.ViewExemptModelBackend', 'netbox.authentication.ObjectPermissionBackend',
] ]
# Internationalization # Internationalization

View File

@ -1,8 +1,17 @@
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Group, User 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.test.utils import override_settings
from django.urls import reverse 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): class ExternalAuthenticationTestCase(TestCase):
@ -135,7 +144,7 @@ class ExternalAuthenticationTestCase(TestCase):
@override_settings( @override_settings(
REMOTE_AUTH_ENABLED=True, REMOTE_AUTH_ENABLED=True,
REMOTE_AUTH_AUTO_CREATE_USER=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 LOGIN_REQUIRED=True
) )
def test_remote_auth_default_permissions(self): 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_ENABLED)
self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER) self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER)
self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_REMOTE_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) response = self.client.get(reverse('home'), follow=True, **headers)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@ -157,3 +166,533 @@ class ExternalAuthenticationTestCase(TestCase):
new_user = User.objects.get(username='remoteuser2') new_user = User.objects.get(username='remoteuser2')
self.assertEqual(int(self.client.session.get('_auth_user_id')), new_user.pk, msg='Authentication failed') 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'])) 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)

View File

@ -65,6 +65,7 @@ _patterns = [
path('api/ipam/', include('ipam.api.urls')), path('api/ipam/', include('ipam.api.urls')),
path('api/secrets/', include('secrets.api.urls')), path('api/secrets/', include('secrets.api.urls')),
path('api/tenancy/', include('tenancy.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/virtualization/', include('virtualization.api.urls')),
path('api/docs/', schema_view.with_ui('swagger'), name='api_docs'), path('api/docs/', schema_view.with_ui('swagger'), name='api_docs'),
path('api/redoc/', schema_view.with_ui('redoc'), name='api_redocs'), path('api/redoc/', schema_view.with_ui('redoc'), name='api_redocs'),

View File

@ -194,52 +194,51 @@ class HomeView(View):
def get(self, request): def get(self, request):
connected_consoleports = ConsolePort.objects.filter( connected_consoleports = ConsolePort.objects.restrict(request.user, 'view').filter(
connected_endpoint__isnull=False connected_endpoint__isnull=False
) )
connected_powerports = PowerPort.objects.filter( connected_powerports = PowerPort.objects.restrict(request.user, 'view').filter(
_connected_poweroutlet__isnull=False _connected_poweroutlet__isnull=False
) )
connected_interfaces = Interface.objects.filter( connected_interfaces = Interface.objects.restrict(request.user, 'view').filter(
_connected_interface__isnull=False, _connected_interface__isnull=False,
pk__lt=F('_connected_interface') pk__lt=F('_connected_interface')
) )
cables = Cable.objects.all()
stats = { stats = {
# Organization # Organization
'site_count': Site.objects.count(), 'site_count': Site.objects.restrict(request.user, 'view').count(),
'tenant_count': Tenant.objects.count(), 'tenant_count': Tenant.objects.restrict(request.user, 'view').count(),
# DCIM # DCIM
'rack_count': Rack.objects.count(), 'rack_count': Rack.objects.restrict(request.user, 'view').count(),
'devicetype_count': DeviceType.objects.count(), 'devicetype_count': DeviceType.objects.restrict(request.user, 'view').count(),
'device_count': Device.objects.count(), 'device_count': Device.objects.restrict(request.user, 'view').count(),
'interface_connections_count': connected_interfaces.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(), 'console_connections_count': connected_consoleports.count(),
'power_connections_count': connected_powerports.count(), 'power_connections_count': connected_powerports.count(),
'powerpanel_count': PowerPanel.objects.count(), 'powerpanel_count': PowerPanel.objects.restrict(request.user, 'view').count(),
'powerfeed_count': PowerFeed.objects.count(), 'powerfeed_count': PowerFeed.objects.restrict(request.user, 'view').count(),
# IPAM # IPAM
'vrf_count': VRF.objects.count(), 'vrf_count': VRF.objects.restrict(request.user, 'view').count(),
'aggregate_count': Aggregate.objects.count(), 'aggregate_count': Aggregate.objects.restrict(request.user, 'view').count(),
'prefix_count': Prefix.objects.count(), 'prefix_count': Prefix.objects.restrict(request.user, 'view').count(),
'ipaddress_count': IPAddress.objects.count(), 'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').count(),
'vlan_count': VLAN.objects.count(), 'vlan_count': VLAN.objects.restrict(request.user, 'view').count(),
# Circuits # Circuits
'provider_count': Provider.objects.count(), 'provider_count': Provider.objects.restrict(request.user, 'view').count(),
'circuit_count': Circuit.objects.count(), 'circuit_count': Circuit.objects.restrict(request.user, 'view').count(),
# Secrets # Secrets
'secret_count': Secret.objects.count(), 'secret_count': Secret.objects.restrict(request.user, 'view').count(),
# Virtualization # Virtualization
'cluster_count': Cluster.objects.count(), 'cluster_count': Cluster.objects.restrict(request.user, 'view').count(),
'virtualmachine_count': VirtualMachine.objects.count(), 'virtualmachine_count': VirtualMachine.objects.restrict(request.user, 'view').count(),
} }
@ -293,7 +292,7 @@ class SearchView(View):
for obj_type in obj_types: 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'] filterset = SEARCH_TYPES[obj_type]['filterset']
table = SEARCH_TYPES[obj_type]['table'] table = SEARCH_TYPES[obj_type]['table']
url = SEARCH_TYPES[obj_type]['url'] url = SEARCH_TYPES[obj_type]['url']
@ -344,5 +343,6 @@ class APIRootView(APIView):
('plugins', reverse('plugins-api:api-root', request=request, format=format)), ('plugins', reverse('plugins-api:api-root', request=request, format=format)),
('secrets', reverse('secrets-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)), ('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)), ('virtualization', reverse('virtualization-api:api-root', request=request, format=format)),
))) )))

View File

@ -23,7 +23,7 @@ class UserKeyAdmin(admin.ModelAdmin):
actions = super().get_actions(request) actions = super().get_actions(request)
if 'delete_selected' in actions: if 'delete_selected' in actions:
del actions['delete_selected'] 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'] del actions['activate_selected']
return actions return actions

View File

@ -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

View File

@ -56,7 +56,6 @@ class Migration(migrations.Migration):
], ],
options={ options={
'ordering': ['user__username'], 'ordering': ['user__username'],
'permissions': (('activate_userkey', 'Can activate user keys for decryption'),),
}, },
), ),
migrations.AddField( migrations.AddField(

View File

@ -1,5 +1,4 @@
import os import os
import sys
from Crypto.Cipher import AES from Crypto.Cipher import AES
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
@ -18,6 +17,7 @@ from dcim.models import Device
from extras.models import CustomFieldModel, TaggedItem from extras.models import CustomFieldModel, TaggedItem
from extras.utils import extras_features from extras.utils import extras_features
from utilities.models import ChangeLoggedModel from utilities.models import ChangeLoggedModel
from utilities.querysets import RestrictedQuerySet
from .exceptions import InvalidKey from .exceptions import InvalidKey
from .hashers import SecretValidationHasher from .hashers import SecretValidationHasher
from .querysets import UserKeyQuerySet from .querysets import UserKeyQuerySet
@ -64,9 +64,6 @@ class UserKey(models.Model):
class Meta: class Meta:
ordering = ['user__username'] ordering = ['user__username']
permissions = (
('activate_userkey', "Can activate user keys for decryption"),
)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -269,6 +266,8 @@ class SecretRole(ChangeLoggedModel):
blank=True blank=True
) )
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'description'] csv_headers = ['name', 'slug', 'description']
class Meta: class Meta:
@ -334,9 +333,10 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type', content_type_field='obj_type',
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
plaintext = None plaintext = None
csv_headers = ['device', 'role', 'name', 'plaintext'] csv_headers = ['device', 'role', 'name', 'plaintext']

View File

@ -5,8 +5,7 @@ from rest_framework import status
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from secrets.models import Secret, SecretRole, SessionKey, UserKey from secrets.models import Secret, SecretRole, SessionKey, UserKey
from users.models import Token from utilities.testing import APITestCase
from utilities.testing import APITestCase, create_test_user
from .constants import PRIVATE_KEY, PUBLIC_KEY from .constants import PRIVATE_KEY, PUBLIC_KEY
@ -124,16 +123,16 @@ class SecretRoleTest(APITestCase):
class SecretTest(APITestCase): class SecretTest(APITestCase):
def setUp(self): def setUp(self):
super().setUp()
# Create a non-superuser test user self.user.is_superuser = False
self.user = create_test_user('testuser', permissions=( self.user.save()
self.add_permissions(
'secrets.add_secret', 'secrets.add_secret',
'secrets.change_secret', 'secrets.change_secret',
'secrets.delete_secret', 'secrets.delete_secret',
'secrets.view_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 = UserKey(user=self.user, public_key=PUBLIC_KEY)
userkey.save() userkey.save()
@ -178,24 +177,25 @@ class SecretTest(APITestCase):
self.secret3.save() self.secret3.save()
def test_get_secret(self): def test_get_secret(self):
url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk}) 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) response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertIsNone(response.data['plaintext']) self.assertIsNone(response.data['plaintext'])
# The plaintext should be present once the user has been assigned to the role # The plaintext should be present once the user has been assigned to the role
self.secretrole1.users.add(self.user) self.secretrole1.users.add(self.user)
response = self.client.get(url, **self.header) response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['plaintext'], self.plaintexts[0]) self.assertEqual(response.data['plaintext'], self.plaintexts[0])
def test_list_secrets(self): def test_list_secrets(self):
url = reverse('secrets-api:secret-list') 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) response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 3) self.assertEqual(response.data['count'], 3)
for secret in response.data['results']: for secret in response.data['results']:
self.assertIsNone(secret['plaintext']) 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 # The plaintext should be present once the user has been assigned to the role
self.secretrole1.users.add(self.user) self.secretrole1.users.add(self.user)
response = self.client.get(url, **self.header) response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 3) self.assertEqual(response.data['count'], 3)
for i, secret in enumerate(response.data['results']): for i, secret in enumerate(response.data['results']):
self.assertEqual(secret['plaintext'], self.plaintexts[i]) self.assertEqual(secret['plaintext'], self.plaintexts[i])
def test_create_secret(self): def test_create_secret(self):
data = { data = {
'device': self.device.pk, 'device': self.device.pk,
'role': self.secretrole1.pk, 'role': self.secretrole1.pk,
@ -216,6 +216,9 @@ class SecretTest(APITestCase):
'plaintext': 'Secret #4 Plaintext', 'plaintext': 'Secret #4 Plaintext',
} }
# Assign test user to secret role
self.secretrole1.users.add(self.user)
url = reverse('secrets-api:secret-list') url = reverse('secrets-api:secret-list')
response = self.client.post(url, data, format='json', **self.header) response = self.client.post(url, data, format='json', **self.header)
@ -228,7 +231,6 @@ class SecretTest(APITestCase):
self.assertEqual(secret4.plaintext, data['plaintext']) self.assertEqual(secret4.plaintext, data['plaintext'])
def test_create_secret_bulk(self): def test_create_secret_bulk(self):
data = [ data = [
{ {
'device': self.device.pk, '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') url = reverse('secrets-api:secret-list')
response = self.client.post(url, data, format='json', **self.header) 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']) self.assertEqual(response.data[2]['plaintext'], data[2]['plaintext'])
def test_update_secret(self): def test_update_secret(self):
data = { data = {
'device': self.device.pk, 'device': self.device.pk,
'role': self.secretrole2.pk, 'role': self.secretrole2.pk,
'plaintext': 'NewPlaintext', '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}) url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk})
response = self.client.put(url, data, format='json', **self.header) response = self.client.put(url, data, format='json', **self.header)
@ -279,6 +286,8 @@ class SecretTest(APITestCase):
self.assertEqual(secret1.plaintext, data['plaintext']) self.assertEqual(secret1.plaintext, data['plaintext'])
def test_delete_secret(self): 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}) url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk})
response = self.client.delete(url, **self.header) response = self.client.delete(url, **self.header)

View File

@ -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 model = Secret
# Disable inapplicable tests
test_create_object = None
# TODO: Check permissions enforcement on secrets.views.secret_edit
test_edit_object = None
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):

View File

@ -9,7 +9,7 @@ urlpatterns = [
# Secret roles # Secret roles
path('secret-roles/', views.SecretRoleListView.as_view(), name='secretrole_list'), 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/import/', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'),
path('secret-roles/delete/', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'), path('secret-roles/delete/', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'),
path('secret-roles/<slug:slug>/edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'), path('secret-roles/<slug:slug>/edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'),
@ -17,12 +17,12 @@ urlpatterns = [
# Secrets # Secrets
path('secrets/', views.SecretListView.as_view(), name='secret_list'), 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/import/', views.SecretBulkImportView.as_view(), name='secret_import'),
path('secrets/edit/', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'), path('secrets/edit/', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'),
path('secrets/delete/', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'), path('secrets/delete/', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'),
path('secrets/<int:pk>/', views.SecretView.as_view(), name='secret'), path('secrets/<int:pk>/', views.SecretView.as_view(), name='secret'),
path('secrets/<int:pk>/edit/', views.secret_edit, name='secret_edit'), path('secrets/<int:pk>/edit/', views.SecretEditView.as_view(), name='secret_edit'),
path('secrets/<int:pk>/delete/', views.SecretDeleteView.as_view(), name='secret_delete'), path('secrets/<int:pk>/delete/', views.SecretDeleteView.as_view(), name='secret_delete'),
path('secrets/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}), path('secrets/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}),

View File

@ -1,19 +1,17 @@
import base64 import base64
import logging
from django.contrib import messages 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.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.utils.html import escape
from django.views.generic import View from django.utils.safestring import mark_safe
from utilities.views import ( from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, GetReturnURLMixin, ObjectDeleteView, ObjectEditView, ObjectListView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
) )
from . import filters, forms, tables from . import filters, forms, tables
from .decorators import userkey_required from .models import SecretRole, Secret, SessionKey, UserKey
from .models import SecretRole, Secret, SessionKey
def get_session_key(request): def get_session_key(request):
@ -30,32 +28,25 @@ def get_session_key(request):
# Secret roles # Secret roles
# #
class SecretRoleListView(PermissionRequiredMixin, ObjectListView): class SecretRoleListView(ObjectListView):
permission_required = 'secrets.view_secretrole'
queryset = SecretRole.objects.annotate(secret_count=Count('secrets')) queryset = SecretRole.objects.annotate(secret_count=Count('secrets'))
table = tables.SecretRoleTable table = tables.SecretRoleTable
class SecretRoleCreateView(PermissionRequiredMixin, ObjectEditView): class SecretRoleEditView(ObjectEditView):
permission_required = 'secrets.add_secretrole'
queryset = SecretRole.objects.all() queryset = SecretRole.objects.all()
model_form = forms.SecretRoleForm model_form = forms.SecretRoleForm
default_return_url = 'secrets:secretrole_list' default_return_url = 'secrets:secretrole_list'
class SecretRoleEditView(SecretRoleCreateView): class SecretRoleBulkImportView(BulkImportView):
permission_required = 'secrets.change_secretrole' queryset = SecretRole.objects.all()
class SecretRoleBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'secrets.add_secretrole'
model_form = forms.SecretRoleCSVForm model_form = forms.SecretRoleCSVForm
table = tables.SecretRoleTable table = tables.SecretRoleTable
default_return_url = 'secrets:secretrole_list' default_return_url = 'secrets:secretrole_list'
class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class SecretRoleBulkDeleteView(BulkDeleteView):
permission_required = 'secrets.delete_secretrole'
queryset = SecretRole.objects.annotate(secret_count=Count('secrets')) queryset = SecretRole.objects.annotate(secret_count=Count('secrets'))
table = tables.SecretRoleTable table = tables.SecretRoleTable
default_return_url = 'secrets:secretrole_list' default_return_url = 'secrets:secretrole_list'
@ -65,8 +56,7 @@ class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Secrets # Secrets
# #
class SecretListView(PermissionRequiredMixin, ObjectListView): class SecretListView(ObjectListView):
permission_required = 'secrets.view_secret'
queryset = Secret.objects.prefetch_related('role', 'device') queryset = Secret.objects.prefetch_related('role', 'device')
filterset = filters.SecretFilterSet filterset = filters.SecretFilterSet
filterset_form = forms.SecretFilterForm filterset_form = forms.SecretFilterForm
@ -74,129 +64,94 @@ class SecretListView(PermissionRequiredMixin, ObjectListView):
action_buttons = ('import', 'export') action_buttons = ('import', 'export')
class SecretView(PermissionRequiredMixin, View): class SecretView(ObjectView):
permission_required = 'secrets.view_secret' queryset = Secret.objects.all()
def get(self, request, pk): 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', { return render(request, 'secrets/secret.html', {
'secret': secret, 'secret': secret,
}) })
@permission_required('secrets.add_secret') class SecretEditView(ObjectEditView):
@userkey_required() queryset = Secret.objects.all()
def secret_add(request): model_form = forms.SecretForm
template_name = 'secrets/secret_edit.html'
secret = Secret() def dispatch(self, request, *args, **kwargs):
session_key = get_session_key(request)
# 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(): if form.is_valid():
logger.debug("Form validation was successful")
# We need a valid session key in order to create a Secret # We must have a session key in order to create a secret or update the plaintext of an existing secret
if session_key is None: 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.") form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.")
# Create and encrypt the new Secret
else: else:
master_key = None master_key = None
try: try:
sk = SessionKey.objects.get(userkey__user=request.user) sk = SessionKey.objects.get(userkey__user=request.user)
master_key = sk.get_master_key(session_key) master_key = sk.get_master_key(session_key)
except SessionKey.DoesNotExist: 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.") form.add_error(None, "No session key found for this user.")
if master_key is not None: if master_key is not None:
logger.debug("Successfully resolved master key for encryption")
secret = form.save(commit=False) 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.encrypt(master_key)
secret.save() secret.save()
form.save_m2m() form.save_m2m()
messages.success(request, "Added new secret: {}.".format(secret)) msg = '{} secret'.format('Created' if not form.instance.pk else 'Modified')
if '_addanother' in request.POST: logger.info(f"{msg} {secret} (PK: {secret.pk})")
return redirect('secrets:secret_add') msg = '{} <a href="{}">{}</a>'.format(msg, secret.get_absolute_url(), escape(secret))
else: messages.success(request, mark_safe(msg))
return redirect('secrets:secret', pk=secret.pk)
else: return redirect(self.get_return_url(request, secret))
initial_data = {
'device': request.GET.get('device'),
}
form = forms.SecretForm(initial=initial_data)
return render(request, 'secrets/secret_edit.html', { else:
'secret': secret, logger.debug("Form validation failed")
'form': form,
'return_url': GetReturnURLMixin().get_return_url(request, secret) 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') class SecretDeleteView(ObjectDeleteView):
@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'
queryset = Secret.objects.all() queryset = Secret.objects.all()
default_return_url = 'secrets:secret_list' default_return_url = 'secrets:secret_list'
class SecretBulkImportView(BulkImportView): class SecretBulkImportView(BulkImportView):
permission_required = 'secrets.add_secret' queryset = Secret.objects.all()
model_form = forms.SecretCSVForm model_form = forms.SecretCSVForm
table = tables.SecretTable table = tables.SecretTable
template_name = 'secrets/secret_import.html' template_name = 'secrets/secret_import.html'
@ -243,8 +198,7 @@ class SecretBulkImportView(BulkImportView):
}) })
class SecretBulkEditView(PermissionRequiredMixin, BulkEditView): class SecretBulkEditView(BulkEditView):
permission_required = 'secrets.change_secret'
queryset = Secret.objects.prefetch_related('role', 'device') queryset = Secret.objects.prefetch_related('role', 'device')
filterset = filters.SecretFilterSet filterset = filters.SecretFilterSet
table = tables.SecretTable table = tables.SecretTable
@ -252,8 +206,7 @@ class SecretBulkEditView(PermissionRequiredMixin, BulkEditView):
default_return_url = 'secrets:secret_list' default_return_url = 'secrets:secret_list'
class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class SecretBulkDeleteView(BulkDeleteView):
permission_required = 'secrets.delete_secret'
queryset = Secret.objects.prefetch_related('role', 'device') queryset = Secret.objects.prefetch_related('role', 'device')
filterset = filters.SecretFilterSet filterset = filters.SecretFilterSet
table = tables.SecretTable table = tables.SecretTable

View File

@ -101,7 +101,7 @@
Inventory <span class="badge">{{ device.inventory_items.count }}</span> Inventory <span class="badge">{{ device.inventory_items.count }}</span>
</a> </a>
</li> </li>
{% if perms.dcim.napalm_read %} {% if perms.dcim.napalm_read_device %}
{% if device.status != 'active' %} {% if device.status != 'active' %}
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='Device must be in active status' %} {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='Device must be in active status' %}
{% elif not device.platform %} {% elif not device.platform %}

View File

@ -9,7 +9,7 @@
{{ form.private_key }} {{ form.private_key }}
<div class="row"> <div class="row">
<div class="col-md-6 col-md-offset-3"> <div class="col-md-6 col-md-offset-3">
<h3>{% block title %}{% if secret.pk %}Editing {{ secret }}{% else %}Add a Secret{% endif %}{% endblock %}</h3> <h3>{% block title %}{% if obj.pk %}Editing {{ obj }}{% else %}Add a Secret{% endif %}{% endblock %}</h3>
{% if form.non_field_errors %} {% if form.non_field_errors %}
<div class="panel panel-danger"> <div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div> <div class="panel-heading"><strong>Errors</strong></div>
@ -30,17 +30,17 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Secret Data</strong></div> <div class="panel-heading"><strong>Secret Data</strong></div>
<div class="panel-body"> <div class="panel-body">
{% if secret.pk and secret|decryptable_by:request.user %} {% if obj.pk and obj|decryptable_by:request.user %}
<div class="form-group"> <div class="form-group">
<label class="col-md-3 control-label required">Current Plaintext</label> <label class="col-md-3 control-label required">Current Plaintext</label>
<div class="col-md-7"> <div class="col-md-7">
<p class="form-control-static" id="secret_{{ secret.pk }}">********</p> <p class="form-control-static" id="secret_{{ obj.pk }}">********</p>
</div> </div>
<div class="col-md-2 text-right"> <div class="col-md-2 text-right">
<button class="btn btn-xs btn-success unlock-secret" secret-id="{{ secret.pk }}"> <button class="btn btn-xs btn-success unlock-secret" secret-id="{{ obj.pk }}">
<i class="fa fa-lock"></i> Unlock <i class="fa fa-lock"></i> Unlock
</button> </button>
<button class="btn btn-xs btn-danger lock-secret collapse" secret-id="{{ secret.pk }}"> <button class="btn btn-xs btn-danger lock-secret collapse" secret-id="{{ obj.pk }}">
<i class="fa fa-unlock-alt"></i> Lock <i class="fa fa-unlock-alt"></i> Lock
</button> </button>
</div> </div>
@ -69,9 +69,9 @@
<div class="row"> <div class="row">
<div class="form-group"> <div class="form-group">
<div class="col-md-12 text-center"> <div class="col-md-12 text-center">
{% if secret.pk %} {% if obj.pk %}
<button type="submit" name="_update" class="btn btn-primary">Update</button> <button type="submit" name="_update" class="btn btn-primary">Update</button>
<a href="{% url 'secrets:secret' pk=secret.pk %}" class="btn btn-default">Cancel</a> <a href="{% url 'secrets:secret' pk=obj.pk %}" class="btn btn-default">Cancel</a>
{% else %} {% else %}
<button type="submit" name="_create" class="btn btn-primary">Create</button> <button type="submit" name="_create" class="btn btn-primary">Create</button>
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button> <button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>

View File

@ -7,6 +7,8 @@ from taggit.managers import TaggableManager
from extras.models import CustomFieldModel, ObjectChange, TaggedItem from extras.models import CustomFieldModel, ObjectChange, TaggedItem
from extras.utils import extras_features from extras.utils import extras_features
from utilities.models import ChangeLoggedModel from utilities.models import ChangeLoggedModel
from utilities.mptt import TreeManager
from utilities.querysets import RestrictedQuerySet
from utilities.utils import serialize_object from utilities.utils import serialize_object
@ -40,6 +42,8 @@ class TenantGroup(MPTTModel, ChangeLoggedModel):
blank=True blank=True
) )
objects = TreeManager()
csv_headers = ['name', 'slug', 'parent', 'description'] csv_headers = ['name', 'slug', 'parent', 'description']
class Meta: class Meta:
@ -104,9 +108,10 @@ class Tenant(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type', content_type_field='obj_type',
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'group', 'description', 'comments'] csv_headers = ['name', 'slug', 'group', 'description', 'comments']
clone_fields = [ clone_fields = [
'group', 'description', 'group', 'description',

View File

@ -9,7 +9,7 @@ urlpatterns = [
# Tenant groups # Tenant groups
path('tenant-groups/', views.TenantGroupListView.as_view(), name='tenantgroup_list'), 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/import/', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'),
path('tenant-groups/delete/', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'), path('tenant-groups/delete/', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'),
path('tenant-groups/<slug:slug>/edit/', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'), path('tenant-groups/<slug:slug>/edit/', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'),
@ -17,7 +17,7 @@ urlpatterns = [
# Tenants # Tenants
path('tenants/', views.TenantListView.as_view(), name='tenant_list'), 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/import/', views.TenantBulkImportView.as_view(), name='tenant_import'),
path('tenants/edit/', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'), path('tenants/edit/', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'),
path('tenants/delete/', views.TenantBulkDeleteView.as_view(), name='tenant_bulk_delete'), path('tenants/delete/', views.TenantBulkDeleteView.as_view(), name='tenant_bulk_delete'),

View File

@ -1,13 +1,11 @@
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Count from django.db.models import Count
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, render
from django.views.generic import View
from circuits.models import Circuit from circuits.models import Circuit
from dcim.models import Site, Rack, Device, RackReservation from dcim.models import Site, Rack, Device, RackReservation
from ipam.models import IPAddress, Prefix, VLAN, VRF from ipam.models import IPAddress, Prefix, VLAN, VRF
from utilities.views import ( from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
) )
from virtualization.models import VirtualMachine, Cluster from virtualization.models import VirtualMachine, Cluster
from . import filters, forms, tables from . import filters, forms, tables
@ -18,8 +16,7 @@ from .models import Tenant, TenantGroup
# Tenant groups # Tenant groups
# #
class TenantGroupListView(PermissionRequiredMixin, ObjectListView): class TenantGroupListView(ObjectListView):
permission_required = 'tenancy.view_tenantgroup'
queryset = TenantGroup.objects.add_related_count( queryset = TenantGroup.objects.add_related_count(
TenantGroup.objects.all(), TenantGroup.objects.all(),
Tenant, Tenant,
@ -30,26 +27,20 @@ class TenantGroupListView(PermissionRequiredMixin, ObjectListView):
table = tables.TenantGroupTable table = tables.TenantGroupTable
class TenantGroupCreateView(PermissionRequiredMixin, ObjectEditView): class TenantGroupEditView(ObjectEditView):
permission_required = 'tenancy.add_tenantgroup'
queryset = TenantGroup.objects.all() queryset = TenantGroup.objects.all()
model_form = forms.TenantGroupForm model_form = forms.TenantGroupForm
default_return_url = 'tenancy:tenantgroup_list' default_return_url = 'tenancy:tenantgroup_list'
class TenantGroupEditView(TenantGroupCreateView): class TenantGroupBulkImportView(BulkImportView):
permission_required = 'tenancy.change_tenantgroup' queryset = TenantGroup.objects.all()
class TenantGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'tenancy.add_tenantgroup'
model_form = forms.TenantGroupCSVForm model_form = forms.TenantGroupCSVForm
table = tables.TenantGroupTable table = tables.TenantGroupTable
default_return_url = 'tenancy:tenantgroup_list' default_return_url = 'tenancy:tenantgroup_list'
class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class TenantGroupBulkDeleteView(BulkDeleteView):
permission_required = 'tenancy.delete_tenantgroup'
queryset = TenantGroup.objects.annotate(tenant_count=Count('tenants')) queryset = TenantGroup.objects.annotate(tenant_count=Count('tenants'))
table = tables.TenantGroupTable table = tables.TenantGroupTable
default_return_url = 'tenancy:tenantgroup_list' default_return_url = 'tenancy:tenantgroup_list'
@ -59,32 +50,31 @@ class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Tenants # Tenants
# #
class TenantListView(PermissionRequiredMixin, ObjectListView): class TenantListView(ObjectListView):
permission_required = 'tenancy.view_tenant'
queryset = Tenant.objects.prefetch_related('group') queryset = Tenant.objects.prefetch_related('group')
filterset = filters.TenantFilterSet filterset = filters.TenantFilterSet
filterset_form = forms.TenantFilterForm filterset_form = forms.TenantFilterForm
table = tables.TenantTable table = tables.TenantTable
class TenantView(PermissionRequiredMixin, View): class TenantView(ObjectView):
permission_required = 'tenancy.view_tenant' queryset = Tenant.objects.prefetch_related('group')
def get(self, request, slug): def get(self, request, slug):
tenant = get_object_or_404(Tenant, slug=slug) tenant = get_object_or_404(self.queryset, slug=slug)
stats = { stats = {
'site_count': Site.objects.filter(tenant=tenant).count(), 'site_count': Site.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
'rack_count': Rack.objects.filter(tenant=tenant).count(), 'rack_count': Rack.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
'rackreservation_count': RackReservation.objects.filter(tenant=tenant).count(), 'rackreservation_count': RackReservation.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
'device_count': Device.objects.filter(tenant=tenant).count(), 'device_count': Device.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
'vrf_count': VRF.objects.filter(tenant=tenant).count(), 'vrf_count': VRF.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
'prefix_count': Prefix.objects.filter(tenant=tenant).count(), 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
'ipaddress_count': IPAddress.objects.filter(tenant=tenant).count(), 'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
'vlan_count': VLAN.objects.filter(tenant=tenant).count(), 'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
'circuit_count': Circuit.objects.filter(tenant=tenant).count(), 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
'virtualmachine_count': VirtualMachine.objects.filter(tenant=tenant).count(), 'virtualmachine_count': VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
'cluster_count': Cluster.objects.filter(tenant=tenant).count(), 'cluster_count': Cluster.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
} }
return render(request, 'tenancy/tenant.html', { return render(request, 'tenancy/tenant.html', {
@ -93,33 +83,26 @@ class TenantView(PermissionRequiredMixin, View):
}) })
class TenantCreateView(PermissionRequiredMixin, ObjectEditView): class TenantEditView(ObjectEditView):
permission_required = 'tenancy.add_tenant'
queryset = Tenant.objects.all() queryset = Tenant.objects.all()
model_form = forms.TenantForm model_form = forms.TenantForm
template_name = 'tenancy/tenant_edit.html' template_name = 'tenancy/tenant_edit.html'
default_return_url = 'tenancy:tenant_list' default_return_url = 'tenancy:tenant_list'
class TenantEditView(TenantCreateView): class TenantDeleteView(ObjectDeleteView):
permission_required = 'tenancy.change_tenant'
class TenantDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'tenancy.delete_tenant'
queryset = Tenant.objects.all() queryset = Tenant.objects.all()
default_return_url = 'tenancy:tenant_list' default_return_url = 'tenancy:tenant_list'
class TenantBulkImportView(PermissionRequiredMixin, BulkImportView): class TenantBulkImportView(BulkImportView):
permission_required = 'tenancy.add_tenant' queryset = Tenant.objects.all()
model_form = forms.TenantCSVForm model_form = forms.TenantCSVForm
table = tables.TenantTable table = tables.TenantTable
default_return_url = 'tenancy:tenant_list' default_return_url = 'tenancy:tenant_list'
class TenantBulkEditView(PermissionRequiredMixin, BulkEditView): class TenantBulkEditView(BulkEditView):
permission_required = 'tenancy.change_tenant'
queryset = Tenant.objects.prefetch_related('group') queryset = Tenant.objects.prefetch_related('group')
filterset = filters.TenantFilterSet filterset = filters.TenantFilterSet
table = tables.TenantTable table = tables.TenantTable
@ -127,8 +110,7 @@ class TenantBulkEditView(PermissionRequiredMixin, BulkEditView):
default_return_url = 'tenancy:tenant_list' default_return_url = 'tenancy:tenant_list'
class TenantBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class TenantBulkDeleteView(BulkDeleteView):
permission_required = 'tenancy.delete_tenant'
queryset = Tenant.objects.prefetch_related('group') queryset = Tenant.objects.prefetch_related('group')
filterset = filters.TenantFilterSet filterset = filters.TenantFilterSet
table = tables.TenantTable table = tables.TenantTable

View File

@ -1,12 +1,31 @@
from django import forms from django import forms
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as UserAdmin_ 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): class UserConfigInline(admin.TabularInline):
@ -16,14 +35,48 @@ class UserConfigInline(admin.TabularInline):
verbose_name = 'Preferences' 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_): class UserAdmin(UserAdmin_):
list_display = [ list_display = [
'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active' '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): class TokenAdminForm(forms.ModelForm):
key = forms.CharField( key = forms.CharField(
required=False, required=False,
@ -43,3 +96,115 @@ class TokenAdmin(admin.ModelAdmin):
list_display = [ list_display = [
'key', 'user', 'created', 'expires', 'write_enabled', 'description' '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'

View File

@ -1,4 +1,4 @@
from django.contrib.auth.models import User from django.contrib.auth.models import Group, User
from utilities.api import WritableNestedSerializer 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 NestedUserSerializer(WritableNestedSerializer):
class Meta: class Meta:

View File

@ -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 * 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')

21
netbox/users/api/urls.py Normal file
View File

@ -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

14
netbox/users/api/views.py Normal file
View File

@ -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

View File

@ -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()),
],
),
]

View File

@ -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',
},
),
]

View File

@ -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
)
]

View File

@ -1,8 +1,9 @@
import binascii import binascii
import os import os
from django.contrib.auth.models import User from django.contrib.auth.models import Group, User
from django.contrib.postgres.fields import JSONField from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField, JSONField
from django.core.validators import MinLengthValidator from django.core.validators import MinLengthValidator
from django.db import models from django.db import models
from django.db.models.signals import post_save from django.db.models.signals import post_save
@ -13,11 +14,38 @@ from utilities.utils import flatten_dict
__all__ = ( __all__ = (
'ObjectPermission',
'Token', 'Token',
'UserConfig', '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): class UserConfig(models.Model):
""" """
This model stores arbitrary user-specific preferences in a JSON data structure. 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() UserConfig(user=instance).save()
#
# REST API
#
class Token(models.Model): class Token(models.Model):
""" """
An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens. 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: if self.expires is None or timezone.now() < self.expires:
return False return False
return True 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)
)

View File

@ -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)

View File

@ -3,7 +3,7 @@ import logging
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import update_last_login from django.contrib.auth.models import update_last_login
from django.contrib.auth.signals import user_logged_in from django.contrib.auth.signals import user_logged_in
from django.http import HttpResponseForbidden, HttpResponseRedirect from django.http import HttpResponseForbidden, HttpResponseRedirect
@ -320,8 +320,7 @@ class TokenEditView(LoginRequiredMixin, View):
}) })
class TokenDeleteView(PermissionRequiredMixin, View): class TokenDeleteView(LoginRequiredMixin, View):
permission_required = 'users.delete_token'
def get(self, request, pk): def get(self, request, pk):

View File

@ -4,17 +4,18 @@ from collections import OrderedDict
import pytz import pytz
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType 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.db.models import ManyToManyField, ProtectedError
from django.http import Http404
from django.urls import reverse from django.urls import reverse
from rest_framework.exceptions import APIException from rest_framework.exceptions import APIException
from rest_framework.permissions import BasePermission from rest_framework.permissions import BasePermission
from rest_framework.relations import PrimaryKeyRelatedField, RelatedField from rest_framework.relations import PrimaryKeyRelatedField, RelatedField
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import Field, ModelSerializer, ValidationError 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 from .utils import dict_to_filter_params, dynamic_import
@ -323,6 +324,27 @@ class ModelViewSet(_ModelViewSet):
logger.debug(f"Using serializer {self.serializer_class}") logger.debug(f"Using serializer {self.serializer_class}")
return 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): def dispatch(self, request, *args, **kwargs):
logger = logging.getLogger('netbox.api.views.ModelViewSet') logger = logging.getLogger('netbox.api.views.ModelViewSet')
@ -341,34 +363,49 @@ class ModelViewSet(_ModelViewSet):
**kwargs **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) if type(instance) is list:
# Check that all instances are still included in the view's queryset
def retrieve(self, *args, **kwargs): conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count()
""" if conforming_count != len(instance):
Call to super to allow for caching raise ObjectDoesNotExist
""" else:
return super().retrieve(*args, **kwargs) # Check that the instance is matched by the view's queryset
self.queryset.get(pk=instance.pk)
#
# Logging
#
def perform_create(self, serializer): 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 = logging.getLogger('netbox.api.views.ModelViewSet')
logger.info(f"Creating new {model._meta.verbose_name}") 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): def perform_update(self, serializer):
model = self.queryset.model
logger = logging.getLogger('netbox.api.views.ModelViewSet') logger = logging.getLogger('netbox.api.views.ModelViewSet')
logger.info(f"Updating {serializer.instance} (PK: {serializer.instance.pk})") logger.info(f"Updating {model._meta.verbose_name} {serializer.instance} (PK: {serializer.instance.pk})")
return super().perform_update(serializer)
# 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): def perform_destroy(self, instance):
model = self.queryset.model
logger = logging.getLogger('netbox.api.views.ModelViewSet') 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) return super().perform_destroy(instance)

View File

@ -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 "
"<app>.<action>_<model>. (Example: dcim.add_site)"
)
if permissions_list:
user.user_permissions.add(*permissions_list)
logger.debug(f"Assigned permissions to remotely-authenticated user {user}: {permissions_list}")
return user

19
netbox/utilities/mptt.py Normal file
View File

@ -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

View File

@ -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 <app_label>.<action>_<model>
"""
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 <app_label>.<action>_<model>"
)
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>
"""
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>
"""
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

View File

@ -1,3 +1,8 @@
from django.db.models import Q, QuerySet
from utilities.permissions import permission_is_exempt
class DummyQuerySet: class DummyQuerySet:
""" """
A fake QuerySet that can be used to cache relationships to objects that have been deleted. 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): def all(self):
return self._cache 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)

View File

@ -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.core.exceptions import ObjectDoesNotExist
from django.forms.models import model_to_dict from django.forms.models import model_to_dict
from django.test import Client, TestCase as _TestCase, override_settings from django.test import Client, TestCase as _TestCase, override_settings
from django.urls import reverse, NoReverseMatch from django.urls import reverse, NoReverseMatch
from rest_framework.test import APIClient 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 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 <app>.<action>_<model>. Assign a set of permissions to the test user. Accepts permission names in the form <app>.<action>_<model>.
""" """
for name in names: for name in names:
app, codename = name.split('.') ct, action = resolve_permission_ct(name)
perm = Permission.objects.get(content_type__app_label=app, codename=codename) obj_perm = ObjectPermission(actions=[action])
self.user.user_permissions.add(perm) obj_perm.save()
obj_perm.users.add(self.user)
def remove_permissions(self, *names): obj_perm.object_types.add(ct)
"""
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)
# #
# Convenience methods # Convenience methods
@ -149,28 +144,55 @@ class ViewTestCases:
""" """
Retrieve a single instance. 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=['*']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_list_objects_anonymous(self): def test_get_object_anonymous(self):
# Make the request as an unauthenticated user # Make the request as an unauthenticated user
self.client.logout() self.client.logout()
response = self.client.get(self.model.objects.first().get_absolute_url()) response = self.client.get(self.model.objects.first().get_absolute_url())
self.assertHttpStatus(response, 200) 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): class CreateObjectViewTestCase(ModelViewTestCase):
""" """
Create a single new instance. Create a single new instance.
@ -178,33 +200,82 @@ class ViewTestCases:
form_data = {} form_data = {}
@override_settings(EXEMPT_VIEW_PERMISSIONS=[]) @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_create_object(self): def test_create_object_without_permission(self):
# Try GET without permission # Try GET without permission
with disable_warnings('django.request'): with disable_warnings('django.request'):
self.assertHttpStatus(self.client.post(self._get_url('add')), 403) self.assertHttpStatus(self.client.post(self._get_url('add')), 403)
# Try GET with permission # Try POST without permission
self.add_permissions( request = {
'{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name) 'path': self._get_url('add'),
) 'data': post_data(self.form_data),
response = self.client.get(path=self._get_url('add')) }
self.assertHttpStatus(response, 200) 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() initial_count = self.model.objects.count()
request = { request = {
'path': self._get_url('add'), 'path': self._get_url('add'),
'data': post_data(self.form_data), 'data': post_data(self.form_data),
'follow': False, # Do not follow 302 redirects
} }
response = self.client.post(**request) self.assertHttpStatus(self.client.post(**request), 200)
self.assertHttpStatus(response, 302) self.assertEqual(initial_count, self.model.objects.count()) # Check that no object was created
# 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)
class EditObjectViewTestCase(ModelViewTestCase): class EditObjectViewTestCase(ModelViewTestCase):
""" """
@ -213,80 +284,189 @@ class ViewTestCases:
form_data = {} form_data = {}
@override_settings(EXEMPT_VIEW_PERMISSIONS=[]) @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_edit_object(self): def test_edit_object_without_permission(self):
instance = self.model.objects.first() instance = self.model.objects.first()
# Try GET without permission # Try GET without permission
with disable_warnings('django.request'): with disable_warnings('django.request'):
self.assertHttpStatus(self.client.post(self._get_url('edit', instance)), 403) self.assertHttpStatus(self.client.post(self._get_url('edit', instance)), 403)
# Try GET with permission # Try POST without 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
request = { request = {
'path': self._get_url('edit', instance), 'path': self._get_url('edit', instance),
'data': post_data(self.form_data), 'data': post_data(self.form_data),
'follow': False, # Do not follow 302 redirects
} }
response = self.client.post(**request) with disable_warnings('django.request'):
self.assertHttpStatus(response, 302) self.assertHttpStatus(self.client.post(**request), 403)
# Validate object modifications @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
instance = self.model.objects.get(pk=instance.pk) def test_edit_object_with_model_permission(self):
self.assertInstanceEqual(instance, self.form_data) 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): class DeleteObjectViewTestCase(ModelViewTestCase):
""" """
Delete a single instance. Delete a single instance.
""" """
@override_settings(EXEMPT_VIEW_PERMISSIONS=[]) @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_delete_object(self): def test_delete_object_without_permission(self):
instance = self.model.objects.first() instance = self.model.objects.first()
# Try GET without permissions # Try GET without permission
with disable_warnings('django.request'): with disable_warnings('django.request'):
self.assertHttpStatus(self.client.post(self._get_url('delete', instance)), 403) self.assertHttpStatus(self.client.get(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)
# Try POST without permission
request = { request = {
'path': self._get_url('delete', instance), 'path': self._get_url('delete', instance),
'data': {'confirm': True}, 'data': post_data({'confirm': True}),
'follow': False, # Do not follow 302 redirects
} }
response = self.client.post(**request) with disable_warnings('django.request'):
self.assertHttpStatus(response, 302) 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): with self.assertRaises(ObjectDoesNotExist):
self.model.objects.get(pk=instance.pk) 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): class ListObjectsViewTestCase(ModelViewTestCase):
""" """
Retrieve multiple instances. 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=[]) @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_list_objects(self): def test_list_objects_without_permission(self):
# Attempt to make the request without required permissions
# Try GET without permission
with disable_warnings('django.request'): with disable_warnings('django.request'):
self.assertHttpStatus(self.client.get(self._get_url('list')), 403) self.assertHttpStatus(self.client.get(self._get_url('list')), 403)
# Assign the required permission and submit again @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
self.add_permissions( def test_list_objects_with_model_permission(self):
'{}.view_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
# Add model-level permission
obj_perm = ObjectPermission(
actions=['view']
) )
response = self.client.get(self._get_url('list')) obj_perm.save()
self.assertHttpStatus(response, 200) 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 # Built-in CSV export
if hasattr(self.model, 'csv_headers'): if hasattr(self.model, 'csv_headers'):
@ -294,12 +474,23 @@ class ViewTestCases:
self.assertHttpStatus(response, 200) self.assertHttpStatus(response, 200)
self.assertEqual(response.get('Content-Type'), 'text/csv') self.assertEqual(response.get('Content-Type'), 'text/csv')
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_list_objects_anonymous(self): def test_list_objects_with_object_permission(self):
# Make the request as an unauthenticated user instance1, instance2 = self.model.objects.all()[:2]
self.client.logout()
response = self.client.get(self._get_url('list')) # Add object-level permission
self.assertHttpStatus(response, 200) 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): class BulkCreateObjectsViewTestCase(ModelViewTestCase):
""" """
@ -314,17 +505,18 @@ class ViewTestCases:
request = { request = {
'path': self._get_url('add'), 'path': self._get_url('add'),
'data': post_data(self.bulk_create_data), 'data': post_data(self.bulk_create_data),
'follow': False, # Do not follow 302 redirects
} }
# Attempt to make the request without required permissions # Attempt to make the request without required permissions
with disable_warnings('django.request'): with disable_warnings('django.request'):
self.assertHttpStatus(self.client.post(**request), 403) self.assertHttpStatus(self.client.post(**request), 403)
# Assign the required permission and submit again # Assign object-level permission
self.add_permissions( obj_perm = ObjectPermission(actions=['add'])
'{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name) 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) response = self.client.post(**request)
self.assertHttpStatus(response, 302) self.assertHttpStatus(response, 302)
@ -332,41 +524,74 @@ class ViewTestCases:
for instance in self.model.objects.order_by('-pk')[:self.bulk_create_count]: for instance in self.model.objects.order_by('-pk')[:self.bulk_create_count]:
self.assertInstanceEqual(instance, self.bulk_create_data) self.assertInstanceEqual(instance, self.bulk_create_data)
class ImportObjectsViewTestCase(ModelViewTestCase): class BulkImportObjectsViewTestCase(ModelViewTestCase):
""" """
Create multiple instances from imported data. Create multiple instances from imported data.
""" """
csv_data = () csv_data = ()
def _get_csv_data(self):
return '\n'.join(self.csv_data)
@override_settings(EXEMPT_VIEW_PERMISSIONS=[]) @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 # Test GET without permission
with disable_warnings('django.request'): with disable_warnings('django.request'):
self.assertHttpStatus(self.client.get(self._get_url('import')), 403) self.assertHttpStatus(self.client.get(self._get_url('import')), 403)
# Test GET with permission # Try POST without permission
self.add_permissions( response = self.client.post(self._get_url('import'), data)
'{}.view_{}'.format(self.model._meta.app_label, self.model._meta.model_name), with disable_warnings('django.request'):
'{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name) 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')) obj_perm.save()
self.assertHttpStatus(response, 200) 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 # Test POST with permission
initial_count = self.model.objects.count() self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200)
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.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1) 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): class BulkEditObjectsViewTestCase(ModelViewTestCase):
""" """
Edit multiple instances. Edit multiple instances.
@ -374,75 +599,145 @@ class ViewTestCases:
bulk_edit_data = {} bulk_edit_data = {}
@override_settings(EXEMPT_VIEW_PERMISSIONS=[]) @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_bulk_edit_objects(self): def test_bulk_edit_objects_without_permission(self):
# Bulk edit the first three objects only
pk_list = self.model.objects.values_list('pk', flat=True)[:3] pk_list = self.model.objects.values_list('pk', flat=True)[:3]
data = {
'pk': pk_list,
'_apply': True, # Form button
}
request = { # Test GET without permission
'path': self._get_url('bulk_edit'), with disable_warnings('django.request'):
'data': { self.assertHttpStatus(self.client.get(self._get_url('bulk_edit')), 403)
'pk': pk_list,
'_apply': True, # Form button # Try POST without permission
}, with disable_warnings('django.request'):
'follow': False, # Do not follow 302 redirects 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 # 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 # Assign model-level permission
with disable_warnings('django.request'): obj_perm = ObjectPermission(
self.assertHttpStatus(self.client.post(**request), 403) actions=['change']
# Assign the required permission and submit again
self.add_permissions(
'{}.change_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
) )
response = self.client.post(**request) obj_perm.save()
self.assertHttpStatus(response, 302) 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)): for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)):
self.assertInstanceEqual(instance, self.bulk_edit_data) 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): class BulkDeleteObjectsViewTestCase(ModelViewTestCase):
""" """
Delete multiple instances. Delete multiple instances.
""" """
@override_settings(EXEMPT_VIEW_PERMISSIONS=[]) @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_bulk_delete_objects(self): def test_bulk_delete_objects_without_permission(self):
pk_list = self.model.objects.values_list('pk', flat=True) pk_list = self.model.objects.values_list('pk', flat=True)[:3]
data = {
request = { 'pk': pk_list,
'path': self._get_url('bulk_delete'), 'confirm': True,
'data': { '_confirm': True, # Form button
'pk': pk_list,
'confirm': True,
'_confirm': True, # Form button
},
'follow': False, # Do not follow 302 redirects
} }
# Attempt to make the request without required permissions # Test GET without permission
with disable_warnings('django.request'): 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 # Try POST without permission
self.add_permissions( with disable_warnings('django.request'):
'{}.delete_{}'.format(self.model._meta.app_label, self.model._meta.model_name) 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) obj_perm.save()
self.assertHttpStatus(response, 302) 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) 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( class PrimaryObjectViewTestCase(
GetObjectViewTestCase, GetObjectViewTestCase,
CreateObjectViewTestCase, CreateObjectViewTestCase,
EditObjectViewTestCase, EditObjectViewTestCase,
DeleteObjectViewTestCase, DeleteObjectViewTestCase,
ListObjectsViewTestCase, ListObjectsViewTestCase,
ImportObjectsViewTestCase, BulkImportObjectsViewTestCase,
BulkEditObjectsViewTestCase, BulkEditObjectsViewTestCase,
BulkDeleteObjectsViewTestCase, BulkDeleteObjectsViewTestCase,
): ):
@ -455,7 +750,7 @@ class ViewTestCases:
CreateObjectViewTestCase, CreateObjectViewTestCase,
EditObjectViewTestCase, EditObjectViewTestCase,
ListObjectsViewTestCase, ListObjectsViewTestCase,
ImportObjectsViewTestCase, BulkImportObjectsViewTestCase,
BulkDeleteObjectsViewTestCase, BulkDeleteObjectsViewTestCase,
): ):
""" """
@ -480,7 +775,7 @@ class ViewTestCases:
DeleteObjectViewTestCase, DeleteObjectViewTestCase,
ListObjectsViewTestCase, ListObjectsViewTestCase,
BulkCreateObjectsViewTestCase, BulkCreateObjectsViewTestCase,
ImportObjectsViewTestCase, BulkImportObjectsViewTestCase,
BulkEditObjectsViewTestCase, BulkEditObjectsViewTestCase,
BulkDeleteObjectsViewTestCase, BulkDeleteObjectsViewTestCase,
): ):

View File

@ -5,7 +5,8 @@ from copy import deepcopy
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.contenttypes.models import ContentType 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 import transaction, IntegrityError
from django.db.models import ManyToManyField, ProtectedError from django.db.models import ManyToManyField, ProtectedError
from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea 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 extras.querysets import CustomFieldQueryset
from utilities.exceptions import AbortTransaction from utilities.exceptions import AbortTransaction
from utilities.forms import BootstrapMixin, CSVDataField, TableConfigForm 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 utilities.utils import csv_format, prepare_cloned_fields
from .error_handlers import handle_protectederror from .error_handlers import handle_protectederror
from .forms import ConfirmationForm, ImportForm from .forms import ConfirmationForm, ImportForm
from .paginator import EnhancedPaginator, get_paginate_count 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): class GetReturnURLMixin(object):
""" """
Provides logic for determining where a user should be redirected after processing a form. Provides logic for determining where a user should be redirected after processing a form.
@ -59,7 +111,23 @@ class GetReturnURLMixin(object):
return reverse('home') 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. List a series of objects.
@ -76,6 +144,9 @@ class ObjectListView(View):
template_name = 'utilities/obj_list.html' template_name = 'utilities/obj_list.html'
action_buttons = ('add', 'import', 'export') action_buttons = ('add', 'import', 'export')
def get_required_permission(self):
return get_permission_for_model(self.queryset.model, 'view')
def queryset_to_yaml(self): def queryset_to_yaml(self):
""" """
Export the queryset of objects as concatenated YAML documents. 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 # Compile a dictionary indicating which permissions are available to the current user for this model
permissions = {} permissions = {}
for action in ('add', 'change', 'delete', 'view'): 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) permissions[action] = request.user.has_perm(perm_name)
# Construct the table based on the user's permissions # Construct the table based on the user's permissions
@ -218,7 +289,7 @@ class ObjectListView(View):
return {} return {}
class ObjectEditView(GetReturnURLMixin, View): class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
""" """
Create or edit a single object. Create or edit a single object.
@ -230,6 +301,11 @@ class ObjectEditView(GetReturnURLMixin, View):
model_form = None model_form = None
template_name = 'utilities/obj_edit.html' 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): def get_object(self, kwargs):
# Look up an existing object by slug or PK, if provided. # Look up an existing object by slug or PK, if provided.
if 'slug' in kwargs: if 'slug' in kwargs:
@ -245,68 +321,87 @@ class ObjectEditView(GetReturnURLMixin, View):
return obj return obj
def dispatch(self, request, *args, **kwargs): 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) return super().dispatch(request, *args, **kwargs)
def get(self, 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 # Parse initial data manually to avoid setting field values as lists
initial_data = {k: request.GET[k] for k in request.GET} 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, { return render(request, self.template_name, {
'obj': self.obj, 'obj': obj,
'obj_type': self.queryset.model._meta.verbose_name, 'obj_type': self.queryset.model._meta.verbose_name,
'form': form, '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): def post(self, request, *args, **kwargs):
logger = logging.getLogger('netbox.views.ObjectEditView') 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(): if form.is_valid():
logger.debug("Form validation was successful") logger.debug("Form validation was successful")
obj = form.save() try:
msg = '{} {}'.format( with transaction.atomic():
'Created' if not form.instance.pk else 'Modified', obj = form.save()
self.queryset.model._meta.verbose_name
)
logger.info(f"{msg} {obj} (PK: {obj.pk})")
if hasattr(obj, 'get_absolute_url'):
msg = '{} <a href="{}">{}</a>'.format(msg, obj.get_absolute_url(), escape(obj))
else:
msg = '{} {}'.format(msg, escape(obj))
messages.success(request, mark_safe(msg))
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 msg = '{} {}'.format(
if hasattr(obj, 'clone_fields'): 'Created' if not form.instance.pk else 'Modified',
url = '{}?{}'.format(request.path, prepare_cloned_fields(obj)) self.queryset.model._meta.verbose_name
return redirect(url) )
logger.info(f"{msg} {obj} (PK: {obj.pk})")
if hasattr(obj, 'get_absolute_url'):
msg = '{} <a href="{}">{}</a>'.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 the object has clone_fields, pre-populate a new instance of the form
if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()): if hasattr(obj, 'clone_fields'):
return redirect(return_url) url = '{}?{}'.format(request.path, prepare_cloned_fields(obj))
else: return redirect(url)
return redirect(self.get_return_url(request, obj))
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: else:
logger.debug("Form validation failed") logger.debug("Form validation failed")
return render(request, self.template_name, { return render(request, self.template_name, {
'obj': self.obj, 'obj': obj,
'obj_type': self.queryset.model._meta.verbose_name, 'obj_type': self.queryset.model._meta.verbose_name,
'form': form, '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. Delete a single object.
@ -316,6 +411,9 @@ class ObjectDeleteView(GetReturnURLMixin, View):
queryset = None queryset = None
template_name = 'utilities/obj_delete.html' 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): def get_object(self, kwargs):
# Look up object by slug if one has been provided. Otherwise, use PK. # Look up object by slug if one has been provided. Otherwise, use PK.
if 'slug' in kwargs: 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. Create new objects in bulk.
queryset: Base queryset for the objects being created
form: Form class which provides the `pattern` field form: Form class which provides the `pattern` field
model_form: The ModelForm used to create individual objects model_form: The ModelForm used to create individual objects
pattern_target: Name of the field to be evaluated as a pattern (if any) pattern_target: Name of the field to be evaluated as a pattern (if any)
template_name: The name of the template template_name: The name of the template
""" """
queryset = None
form = None form = None
model_form = None model_form = None
pattern_target = '' pattern_target = ''
template_name = None template_name = None
def get_required_permission(self):
return get_permission_for_model(self.queryset.model, 'add')
def get(self, request): def get(self, request):
# Set initial values for visible form fields from query args # Set initial values for visible form fields from query args
initial = {} initial = {}
@ -403,7 +506,7 @@ class BulkCreateView(GetReturnURLMixin, View):
def post(self, request): def post(self, request):
logger = logging.getLogger('netbox.views.BulkCreateView') logger = logging.getLogger('netbox.views.BulkCreateView')
model = self.model_form._meta.model model = self.queryset.model
form = self.form(request.POST) form = self.form(request.POST)
model_form = self.model_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 an IntegrityError to break the for loop and abort the transaction.
raise IntegrityError() 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. # 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) msg = "Added {} {}".format(len(new_objs), model._meta.verbose_name_plural)
logger.info(msg) logger.info(msg)
@ -448,6 +555,11 @@ class BulkCreateView(GetReturnURLMixin, View):
except IntegrityError: except IntegrityError:
pass pass
except ObjectDoesNotExist:
msg = "Object creation failed due to object-level permissions violation"
logger.debug(msg)
form.add_error(None, msg)
else: else:
logger.debug("Form validation failed") 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). 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 model_form = None
related_object_forms = dict() related_object_forms = dict()
template_name = 'utilities/obj_import.html' template_name = 'utilities/obj_import.html'
def get_required_permission(self):
return get_permission_for_model(self.queryset.model, 'add')
def get(self, request): def get(self, request):
form = ImportForm() form = ImportForm()
return render(request, self.template_name, { return render(request, self.template_name, {
'form': form, 'form': form,
'obj_type': self.model._meta.verbose_name, 'obj_type': self.queryset.model._meta.verbose_name,
'return_url': self.get_return_url(request), 'return_url': self.get_return_url(request),
}) })
@ -503,12 +623,17 @@ class ObjectImportView(GetReturnURLMixin, View):
# Save the primary object # Save the primary object
obj = model_form.save() obj = model_form.save()
# Enforce object-level permissions
self.queryset.get(pk=obj.pk)
logger.debug(f"Created {obj} (PK: {obj.pk})") logger.debug(f"Created {obj} (PK: {obj.pk})")
# Iterate through the related object forms (if any), validating and saving each instance. # 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(): for field_name, related_object_form in self.related_object_forms.items():
logger.debug("Processing form for related objects: {related_object_form}") 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())): for i, rel_obj_data in enumerate(data.get(field_name, list())):
f = related_object_form(obj, rel_obj_data) f = related_object_form(obj, rel_obj_data)
@ -518,7 +643,8 @@ class ObjectImportView(GetReturnURLMixin, View):
f.data[subfield_name] = field.initial f.data[subfield_name] = field.initial
if f.is_valid(): if f.is_valid():
f.save() related_obj = f.save()
related_obj_pks.append(related_obj.pk)
else: else:
# Replicate errors on the related object form to the primary form for display # Replicate errors on the related object form to the primary form for display
for subfield_name, errors in f.errors.items(): for subfield_name, errors in f.errors.items():
@ -527,9 +653,19 @@ class ObjectImportView(GetReturnURLMixin, View):
model_form.add_error(None, err_msg) model_form.add_error(None, err_msg)
raise AbortTransaction() 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: except AbortTransaction:
pass 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: if not model_form.errors:
logger.info(f"Import object {obj} (PK: {obj.pk})") logger.info(f"Import object {obj} (PK: {obj.pk})")
messages.success(request, mark_safe('Imported object: <a href="{}">{}</a>'.format( messages.success(request, mark_safe('Imported object: <a href="{}">{}</a>'.format(
@ -561,20 +697,22 @@ class ObjectImportView(GetReturnURLMixin, View):
return render(request, self.template_name, { return render(request, self.template_name, {
'form': form, 'form': form,
'obj_type': self.model._meta.verbose_name, 'obj_type': self.queryset.model._meta.verbose_name,
'return_url': self.get_return_url(request), 'return_url': self.get_return_url(request),
}) })
class BulkImportView(GetReturnURLMixin, View): class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
""" """
Import objects in bulk (CSV format). Import objects in bulk (CSV format).
queryset: Base queryset for the model
model_form: The form used to create each imported object model_form: The form used to create each imported object
table: The django-tables2 Table used to render the list of imported objects table: The django-tables2 Table used to render the list of imported objects
template_name: The name of the template 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) widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key)
""" """
queryset = None
model_form = None model_form = None
table = None table = None
template_name = 'utilities/obj_bulk_import.html' template_name = 'utilities/obj_bulk_import.html'
@ -596,6 +734,9 @@ class BulkImportView(GetReturnURLMixin, View):
""" """
return obj_form.save() return obj_form.save()
def get_required_permission(self):
return get_permission_for_model(self.queryset.model, 'add')
def get(self, request): def get(self, request):
return render(request, self.template_name, { return render(request, self.template_name, {
@ -628,6 +769,10 @@ class BulkImportView(GetReturnURLMixin, View):
form.add_error('csv', "Row {} {}: {}".format(row, field, err[0])) form.add_error('csv', "Row {} {}: {}".format(row, field, err[0]))
raise ValidationError("") 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 # Compile a table containing the imported objects
obj_table = self.table(new_objs) obj_table = self.table(new_objs)
@ -644,6 +789,11 @@ class BulkImportView(GetReturnURLMixin, View):
except ValidationError: except ValidationError:
pass pass
except ObjectDoesNotExist:
msg = "Object import failed due to object-level permissions violation"
logger.debug(msg)
form.add_error(None, msg)
else: else:
logger.debug("Form validation failed") 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. Edit objects in bulk.
@ -671,6 +821,9 @@ class BulkEditView(GetReturnURLMixin, View):
form = None form = None
template_name = 'utilities/obj_bulk_edit.html' 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): def get(self, request):
return redirect(self.get_return_url(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 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: if request.POST.get('_all') and self.filterset is not None:
pk_list = [ 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: else:
pk_list = request.POST.getlist('pk') pk_list = request.POST.getlist('pk')
@ -701,8 +854,8 @@ class BulkEditView(GetReturnURLMixin, View):
with transaction.atomic(): with transaction.atomic():
updated_count = 0 updated_objects = []
for obj in model.objects.filter(pk__in=form.cleaned_data['pk']): for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
# Update standard fields. If a field is listed in _nullify, delete its value. # Update standard fields. If a field is listed in _nullify, delete its value.
for name in standard_fields: for name in standard_fields:
@ -730,6 +883,7 @@ class BulkEditView(GetReturnURLMixin, View):
obj.full_clean() obj.full_clean()
obj.save() obj.save()
updated_objects.append(obj)
logger.debug(f"Saved {obj} (PK: {obj.pk})") logger.debug(f"Saved {obj} (PK: {obj.pk})")
# Update custom fields # Update custom fields
@ -759,10 +913,12 @@ class BulkEditView(GetReturnURLMixin, View):
if form.cleaned_data.get('remove_tags', None): if form.cleaned_data.get('remove_tags', None):
obj.tags.remove(*form.cleaned_data['remove_tags']) 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: if updated_objects:
msg = 'Updated {} {}'.format(updated_count, model._meta.verbose_name_plural) msg = 'Updated {} {}'.format(len(updated_objects), model._meta.verbose_name_plural)
logger.info(msg) logger.info(msg)
messages.success(self.request, msg) messages.success(self.request, msg)
@ -771,6 +927,11 @@ class BulkEditView(GetReturnURLMixin, View):
except ValidationError as e: except ValidationError as e:
messages.error(self.request, "{} failed validation: {}".format(obj, 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: else:
logger.debug("Form validation failed") 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. Delete objects in bulk.
@ -816,6 +977,9 @@ class BulkDeleteView(GetReturnURLMixin, View):
form = None form = None
template_name = 'utilities/obj_bulk_delete.html' 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): def get(self, request):
return redirect(self.get_return_url(request)) return redirect(self.get_return_url(request))
@ -893,28 +1057,32 @@ class BulkDeleteView(GetReturnURLMixin, View):
# #
# TODO: Replace with BulkCreateView # 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. Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine.
""" """
model = None queryset = None
form = None form = None
model_form = None model_form = None
template_name = None template_name = None
def get_required_permission(self):
return get_permission_for_model(self.queryset.model, 'add')
def get(self, request): def get(self, request):
form = self.form(initial=request.GET) form = self.form(initial=request.GET)
return render(request, self.template_name, { return render(request, self.template_name, {
'component_type': self.model._meta.verbose_name, 'component_type': self.queryset.model._meta.verbose_name,
'form': form, 'form': form,
'return_url': self.get_return_url(request), 'return_url': self.get_return_url(request),
}) })
def post(self, request): def post(self, request):
logger = logging.getLogger('netbox.views.ComponentCreateView')
form = self.form(request.POST, initial=request.GET) form = self.form(request.POST, initial=request.GET)
if form.is_valid(): if form.is_valid():
new_components = [] new_components = []
@ -940,33 +1108,48 @@ class ComponentCreateView(GetReturnURLMixin, View):
if not form.errors: if not form.errors:
# Create the new components try:
for component_form in new_components:
component_form.save()
messages.success(request, "Added {} {}".format( with transaction.atomic():
len(new_components), self.model._meta.verbose_name_plural
)) # Create the new components
if '_addanother' in request.POST: new_objs = []
return redirect(request.get_full_path()) for component_form in new_components:
else: obj = component_form.save()
return redirect(self.get_return_url(request)) 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, { return render(request, self.template_name, {
'component_type': self.model._meta.verbose_name, 'component_type': self.queryset.model._meta.verbose_name,
'form': form, 'form': form,
'return_url': self.get_return_url(request), '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. Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines.
""" """
parent_model = None parent_model = None
parent_field = None parent_field = None
form = None form = None
model = None queryset = None
model_form = None model_form = None
filterset = None filterset = None
table = None table = None
@ -975,7 +1158,7 @@ class BulkComponentCreateView(GetReturnURLMixin, View):
def post(self, request): def post(self, request):
logger = logging.getLogger('netbox.views.BulkComponentCreateView') logger = logging.getLogger('netbox.views.BulkComponentCreateView')
parent_model_name = self.parent_model._meta.verbose_name_plural 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? # Are we editing *all* objects in the queryset or just a selected subset?
if request.POST.get('_all') and self.filterset is not None: if request.POST.get('_all') and self.filterset is not None:
@ -1020,9 +1203,18 @@ class BulkComponentCreateView(GetReturnURLMixin, View):
for e in errors: for e in errors:
form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e))) 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: except IntegrityError:
pass 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: if not form.errors:
msg = "Added {} {} to {} {}.".format( msg = "Added {} {} to {} {}.".format(
len(new_components), len(new_components),

View File

@ -9,6 +9,7 @@ from dcim.models import Device
from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem
from extras.utils import extras_features from extras.utils import extras_features
from utilities.models import ChangeLoggedModel from utilities.models import ChangeLoggedModel
from utilities.querysets import RestrictedQuerySet
from .choices import * from .choices import *
@ -40,6 +41,8 @@ class ClusterType(ChangeLoggedModel):
blank=True blank=True
) )
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'description'] csv_headers = ['name', 'slug', 'description']
class Meta: class Meta:
@ -79,6 +82,8 @@ class ClusterGroup(ChangeLoggedModel):
blank=True blank=True
) )
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'description'] csv_headers = ['name', 'slug', 'description']
class Meta: class Meta:
@ -145,9 +150,10 @@ class Cluster(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type', content_type_field='obj_type',
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'type', 'group', 'site', 'comments'] csv_headers = ['name', 'type', 'group', 'site', 'comments']
clone_fields = [ clone_fields = [
'type', 'group', 'tenant', 'site', 'type', 'group', 'tenant', 'site',
@ -269,9 +275,10 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
content_type_field='obj_type', content_type_field='obj_type',
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [ csv_headers = [
'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', 'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
] ]

View File

@ -185,16 +185,16 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
# TODO: Update base class to DeviceComponentViewTestCase
class InterfaceTestCase( class InterfaceTestCase(
ViewTestCases.GetObjectViewTestCase, ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeviceComponentViewTestCase, ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.BulkCreateObjectsViewTestCase,
ViewTestCases.BulkEditObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase,
): ):
model = Interface model = Interface
# Disable inapplicable tests
test_list_objects = None
test_import_objects = None
def _get_base_url(self): def _get_base_url(self):
# Interface belongs to the DCIM app, so we have to override the base URL # Interface belongs to the DCIM app, so we have to override the base URL
return 'virtualization:interface_{}' return 'virtualization:interface_{}'

View File

@ -1,7 +1,7 @@
from django.urls import path from django.urls import path
from extras.views import ObjectChangeLogView from extras.views import ObjectChangeLogView
from ipam.views import ServiceCreateView from ipam.views import ServiceEditView
from . import views from . import views
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@ -10,7 +10,7 @@ urlpatterns = [
# Cluster types # Cluster types
path('cluster-types/', views.ClusterTypeListView.as_view(), name='clustertype_list'), 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/import/', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'),
path('cluster-types/delete/', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'), path('cluster-types/delete/', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'),
path('cluster-types/<slug:slug>/edit/', views.ClusterTypeEditView.as_view(), name='clustertype_edit'), path('cluster-types/<slug:slug>/edit/', views.ClusterTypeEditView.as_view(), name='clustertype_edit'),
@ -18,7 +18,7 @@ urlpatterns = [
# Cluster groups # Cluster groups
path('cluster-groups/', views.ClusterGroupListView.as_view(), name='clustergroup_list'), 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/import/', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'),
path('cluster-groups/delete/', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'), path('cluster-groups/delete/', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'),
path('cluster-groups/<slug:slug>/edit/', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'), path('cluster-groups/<slug:slug>/edit/', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'),
@ -26,7 +26,7 @@ urlpatterns = [
# Clusters # Clusters
path('clusters/', views.ClusterListView.as_view(), name='cluster_list'), 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/import/', views.ClusterBulkImportView.as_view(), name='cluster_import'),
path('clusters/edit/', views.ClusterBulkEditView.as_view(), name='cluster_bulk_edit'), path('clusters/edit/', views.ClusterBulkEditView.as_view(), name='cluster_bulk_edit'),
path('clusters/delete/', views.ClusterBulkDeleteView.as_view(), name='cluster_bulk_delete'), path('clusters/delete/', views.ClusterBulkDeleteView.as_view(), name='cluster_bulk_delete'),
@ -39,7 +39,7 @@ urlpatterns = [
# Virtual machines # Virtual machines
path('virtual-machines/', views.VirtualMachineListView.as_view(), name='virtualmachine_list'), 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/import/', views.VirtualMachineBulkImportView.as_view(), name='virtualmachine_import'),
path('virtual-machines/edit/', views.VirtualMachineBulkEditView.as_view(), name='virtualmachine_bulk_edit'), path('virtual-machines/edit/', views.VirtualMachineBulkEditView.as_view(), name='virtualmachine_bulk_edit'),
path('virtual-machines/delete/', views.VirtualMachineBulkDeleteView.as_view(), name='virtualmachine_bulk_delete'), path('virtual-machines/delete/', views.VirtualMachineBulkDeleteView.as_view(), name='virtualmachine_bulk_delete'),
@ -48,7 +48,7 @@ urlpatterns = [
path('virtual-machines/<int:pk>/delete/', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'), path('virtual-machines/<int:pk>/delete/', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'),
path('virtual-machines/<int:pk>/config-context/', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'), path('virtual-machines/<int:pk>/config-context/', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'),
path('virtual-machines/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}), path('virtual-machines/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}),
path('virtual-machines/<int:virtualmachine>/services/assign/', ServiceCreateView.as_view(), name='virtualmachine_service_assign'), path('virtual-machines/<int:virtualmachine>/services/assign/', ServiceEditView.as_view(), name='virtualmachine_service_assign'),
# VM interfaces # VM interfaces
path('interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'), path('interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'),

View File

@ -1,18 +1,16 @@
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db import transaction from django.db import transaction
from django.db.models import Count from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
from django.views.generic import View
from dcim.models import Device, Interface from dcim.models import Device, Interface
from dcim.tables import DeviceTable from dcim.tables import DeviceTable
from extras.views import ObjectConfigContextView from extras.views import ObjectConfigContextView
from ipam.models import Service from ipam.models import Service
from utilities.views import ( from utilities.views import (
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView, BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectView,
ObjectEditView, ObjectListView, ObjectDeleteView, ObjectEditView, ObjectListView,
) )
from . import filters, forms, tables from . import filters, forms, tables
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@ -22,32 +20,25 @@ from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
# Cluster types # Cluster types
# #
class ClusterTypeListView(PermissionRequiredMixin, ObjectListView): class ClusterTypeListView(ObjectListView):
permission_required = 'virtualization.view_clustertype'
queryset = ClusterType.objects.annotate(cluster_count=Count('clusters')) queryset = ClusterType.objects.annotate(cluster_count=Count('clusters'))
table = tables.ClusterTypeTable table = tables.ClusterTypeTable
class ClusterTypeCreateView(PermissionRequiredMixin, ObjectEditView): class ClusterTypeEditView(ObjectEditView):
permission_required = 'virtualization.add_clustertype'
queryset = ClusterType.objects.all() queryset = ClusterType.objects.all()
model_form = forms.ClusterTypeForm model_form = forms.ClusterTypeForm
default_return_url = 'virtualization:clustertype_list' default_return_url = 'virtualization:clustertype_list'
class ClusterTypeEditView(ClusterTypeCreateView): class ClusterTypeBulkImportView(BulkImportView):
permission_required = 'virtualization.change_clustertype' queryset = ClusterType.objects.all()
class ClusterTypeBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'virtualization.add_clustertype'
model_form = forms.ClusterTypeCSVForm model_form = forms.ClusterTypeCSVForm
table = tables.ClusterTypeTable table = tables.ClusterTypeTable
default_return_url = 'virtualization:clustertype_list' default_return_url = 'virtualization:clustertype_list'
class ClusterTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class ClusterTypeBulkDeleteView(BulkDeleteView):
permission_required = 'virtualization.delete_clustertype'
queryset = ClusterType.objects.annotate(cluster_count=Count('clusters')) queryset = ClusterType.objects.annotate(cluster_count=Count('clusters'))
table = tables.ClusterTypeTable table = tables.ClusterTypeTable
default_return_url = 'virtualization:clustertype_list' default_return_url = 'virtualization:clustertype_list'
@ -57,32 +48,25 @@ class ClusterTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Cluster groups # Cluster groups
# #
class ClusterGroupListView(PermissionRequiredMixin, ObjectListView): class ClusterGroupListView(ObjectListView):
permission_required = 'virtualization.view_clustergroup'
queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters')) queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters'))
table = tables.ClusterGroupTable table = tables.ClusterGroupTable
class ClusterGroupCreateView(PermissionRequiredMixin, ObjectEditView): class ClusterGroupEditView(ObjectEditView):
permission_required = 'virtualization.add_clustergroup'
queryset = ClusterGroup.objects.all() queryset = ClusterGroup.objects.all()
model_form = forms.ClusterGroupForm model_form = forms.ClusterGroupForm
default_return_url = 'virtualization:clustergroup_list' default_return_url = 'virtualization:clustergroup_list'
class ClusterGroupEditView(ClusterGroupCreateView): class ClusterGroupBulkImportView(BulkImportView):
permission_required = 'virtualization.change_clustergroup' queryset = ClusterGroup.objects.all()
class ClusterGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'virtualization.add_clustergroup'
model_form = forms.ClusterGroupCSVForm model_form = forms.ClusterGroupCSVForm
table = tables.ClusterGroupTable table = tables.ClusterGroupTable
default_return_url = 'virtualization:clustergroup_list' default_return_url = 'virtualization:clustergroup_list'
class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class ClusterGroupBulkDeleteView(BulkDeleteView):
permission_required = 'virtualization.delete_clustergroup'
queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters')) queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters'))
table = tables.ClusterGroupTable table = tables.ClusterGroupTable
default_return_url = 'virtualization:clustergroup_list' default_return_url = 'virtualization:clustergroup_list'
@ -92,21 +76,20 @@ class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Clusters # Clusters
# #
class ClusterListView(PermissionRequiredMixin, ObjectListView): class ClusterListView(ObjectListView):
permission_required = 'virtualization.view_cluster'
queryset = Cluster.objects.prefetch_related('type', 'group', 'site', 'tenant') queryset = Cluster.objects.prefetch_related('type', 'group', 'site', 'tenant')
table = tables.ClusterTable table = tables.ClusterTable
filterset = filters.ClusterFilterSet filterset = filters.ClusterFilterSet
filterset_form = forms.ClusterFilterForm filterset_form = forms.ClusterFilterForm
class ClusterView(PermissionRequiredMixin, View): class ClusterView(ObjectView):
permission_required = 'virtualization.view_cluster' queryset = Cluster.objects.all()
def get(self, request, pk): def get(self, request, pk):
cluster = get_object_or_404(Cluster, pk=pk) cluster = get_object_or_404(self.queryset, pk=pk)
devices = Device.objects.filter(cluster=cluster).prefetch_related( devices = Device.objects.restrict(request.user, 'view').filter(cluster=cluster).prefetch_related(
'site', 'rack', 'tenant', 'device_type__manufacturer' 'site', 'rack', 'tenant', 'device_type__manufacturer'
) )
device_table = DeviceTable(list(devices), orderable=False) device_table = DeviceTable(list(devices), orderable=False)
@ -119,32 +102,25 @@ class ClusterView(PermissionRequiredMixin, View):
}) })
class ClusterCreateView(PermissionRequiredMixin, ObjectEditView): class ClusterEditView(ObjectEditView):
permission_required = 'virtualization.add_cluster'
template_name = 'virtualization/cluster_edit.html' template_name = 'virtualization/cluster_edit.html'
queryset = Cluster.objects.all() queryset = Cluster.objects.all()
model_form = forms.ClusterForm model_form = forms.ClusterForm
class ClusterEditView(ClusterCreateView): class ClusterDeleteView(ObjectDeleteView):
permission_required = 'virtualization.change_cluster'
class ClusterDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'virtualization.delete_cluster'
queryset = Cluster.objects.all() queryset = Cluster.objects.all()
default_return_url = 'virtualization:cluster_list' default_return_url = 'virtualization:cluster_list'
class ClusterBulkImportView(PermissionRequiredMixin, BulkImportView): class ClusterBulkImportView(BulkImportView):
permission_required = 'virtualization.add_cluster' queryset = Cluster.objects.all()
model_form = forms.ClusterCSVForm model_form = forms.ClusterCSVForm
table = tables.ClusterTable table = tables.ClusterTable
default_return_url = 'virtualization:cluster_list' default_return_url = 'virtualization:cluster_list'
class ClusterBulkEditView(PermissionRequiredMixin, BulkEditView): class ClusterBulkEditView(BulkEditView):
permission_required = 'virtualization.change_cluster'
queryset = Cluster.objects.prefetch_related('type', 'group', 'site') queryset = Cluster.objects.prefetch_related('type', 'group', 'site')
filterset = filters.ClusterFilterSet filterset = filters.ClusterFilterSet
table = tables.ClusterTable table = tables.ClusterTable
@ -152,22 +128,20 @@ class ClusterBulkEditView(PermissionRequiredMixin, BulkEditView):
default_return_url = 'virtualization:cluster_list' default_return_url = 'virtualization:cluster_list'
class ClusterBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class ClusterBulkDeleteView(BulkDeleteView):
permission_required = 'virtualization.delete_cluster'
queryset = Cluster.objects.prefetch_related('type', 'group', 'site') queryset = Cluster.objects.prefetch_related('type', 'group', 'site')
filterset = filters.ClusterFilterSet filterset = filters.ClusterFilterSet
table = tables.ClusterTable table = tables.ClusterTable
default_return_url = 'virtualization:cluster_list' default_return_url = 'virtualization:cluster_list'
class ClusterAddDevicesView(PermissionRequiredMixin, View): class ClusterAddDevicesView(ObjectEditView):
permission_required = 'virtualization.change_cluster' queryset = Cluster.objects.all()
form = forms.ClusterAddDevicesForm form = forms.ClusterAddDevicesForm
template_name = 'virtualization/cluster_add_devices.html' template_name = 'virtualization/cluster_add_devices.html'
def get(self, request, pk): def get(self, request, pk):
cluster = get_object_or_404(self.queryset, pk=pk)
cluster = get_object_or_404(Cluster, pk=pk)
form = self.form(cluster, initial=request.GET) form = self.form(cluster, initial=request.GET)
return render(request, self.template_name, { return render(request, self.template_name, {
@ -177,8 +151,7 @@ class ClusterAddDevicesView(PermissionRequiredMixin, View):
}) })
def post(self, request, pk): def post(self, request, pk):
cluster = get_object_or_404(self.queryset, pk=pk)
cluster = get_object_or_404(Cluster, pk=pk)
form = self.form(cluster, request.POST) form = self.form(cluster, request.POST)
if form.is_valid(): if form.is_valid():
@ -203,14 +176,14 @@ class ClusterAddDevicesView(PermissionRequiredMixin, View):
}) })
class ClusterRemoveDevicesView(PermissionRequiredMixin, View): class ClusterRemoveDevicesView(ObjectEditView):
permission_required = 'virtualization.change_cluster' queryset = Cluster.objects.all()
form = forms.ClusterRemoveDevicesForm form = forms.ClusterRemoveDevicesForm
template_name = 'utilities/obj_bulk_remove.html' template_name = 'utilities/obj_bulk_remove.html'
def post(self, request, pk): 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: if '_confirm' in request.POST:
form = self.form(request.POST) form = self.form(request.POST)
@ -248,8 +221,7 @@ class ClusterRemoveDevicesView(PermissionRequiredMixin, View):
# Virtual machines # Virtual machines
# #
class VirtualMachineListView(PermissionRequiredMixin, ObjectListView): class VirtualMachineListView(ObjectListView):
permission_required = 'virtualization.view_virtualmachine'
queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role', 'primary_ip4', 'primary_ip6') queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role', 'primary_ip4', 'primary_ip6')
filterset = filters.VirtualMachineFilterSet filterset = filters.VirtualMachineFilterSet
filterset_form = forms.VirtualMachineFilterForm filterset_form = forms.VirtualMachineFilterForm
@ -257,14 +229,14 @@ class VirtualMachineListView(PermissionRequiredMixin, ObjectListView):
template_name = 'virtualization/virtualmachine_list.html' template_name = 'virtualization/virtualmachine_list.html'
class VirtualMachineView(PermissionRequiredMixin, View): class VirtualMachineView(ObjectView):
permission_required = 'virtualization.view_virtualmachine' queryset = VirtualMachine.objects.prefetch_related('tenant__group')
def get(self, request, pk): def get(self, request, pk):
virtualmachine = get_object_or_404(VirtualMachine.objects.prefetch_related('tenant__group'), pk=pk) virtualmachine = get_object_or_404(self.queryset, pk=pk)
interfaces = Interface.objects.filter(virtual_machine=virtualmachine) interfaces = Interface.objects.restrict(request.user, 'view').filter(virtual_machine=virtualmachine)
services = Service.objects.filter(virtual_machine=virtualmachine) services = Service.objects.restrict(request.user, 'view').filter(virtual_machine=virtualmachine)
return render(request, 'virtualization/virtualmachine.html', { return render(request, 'virtualization/virtualmachine.html', {
'virtualmachine': virtualmachine, 'virtualmachine': virtualmachine,
@ -273,39 +245,31 @@ class VirtualMachineView(PermissionRequiredMixin, View):
}) })
class VirtualMachineConfigContextView(PermissionRequiredMixin, ObjectConfigContextView): class VirtualMachineConfigContextView(ObjectConfigContextView):
permission_required = 'virtualization.view_virtualmachine' queryset = VirtualMachine.objects.all()
object_class = VirtualMachine
base_template = 'virtualization/virtualmachine.html' base_template = 'virtualization/virtualmachine.html'
class VirtualMachineCreateView(PermissionRequiredMixin, ObjectEditView): class VirtualMachineEditView(ObjectEditView):
permission_required = 'virtualization.add_virtualmachine'
queryset = VirtualMachine.objects.all() queryset = VirtualMachine.objects.all()
model_form = forms.VirtualMachineForm model_form = forms.VirtualMachineForm
template_name = 'virtualization/virtualmachine_edit.html' template_name = 'virtualization/virtualmachine_edit.html'
default_return_url = 'virtualization:virtualmachine_list' default_return_url = 'virtualization:virtualmachine_list'
class VirtualMachineEditView(VirtualMachineCreateView): class VirtualMachineDeleteView(ObjectDeleteView):
permission_required = 'virtualization.change_virtualmachine'
class VirtualMachineDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'virtualization.delete_virtualmachine'
queryset = VirtualMachine.objects.all() queryset = VirtualMachine.objects.all()
default_return_url = 'virtualization:virtualmachine_list' default_return_url = 'virtualization:virtualmachine_list'
class VirtualMachineBulkImportView(PermissionRequiredMixin, BulkImportView): class VirtualMachineBulkImportView(BulkImportView):
permission_required = 'virtualization.add_virtualmachine' queryset = VirtualMachine.objects.all()
model_form = forms.VirtualMachineCSVForm model_form = forms.VirtualMachineCSVForm
table = tables.VirtualMachineTable table = tables.VirtualMachineTable
default_return_url = 'virtualization:virtualmachine_list' default_return_url = 'virtualization:virtualmachine_list'
class VirtualMachineBulkEditView(PermissionRequiredMixin, BulkEditView): class VirtualMachineBulkEditView(BulkEditView):
permission_required = 'virtualization.change_virtualmachine'
queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role') queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role')
filterset = filters.VirtualMachineFilterSet filterset = filters.VirtualMachineFilterSet
table = tables.VirtualMachineTable table = tables.VirtualMachineTable
@ -313,8 +277,7 @@ class VirtualMachineBulkEditView(PermissionRequiredMixin, BulkEditView):
default_return_url = 'virtualization:virtualmachine_list' default_return_url = 'virtualization:virtualmachine_list'
class VirtualMachineBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class VirtualMachineBulkDeleteView(BulkDeleteView):
permission_required = 'virtualization.delete_virtualmachine'
queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role') queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role')
filterset = filters.VirtualMachineFilterSet filterset = filters.VirtualMachineFilterSet
table = tables.VirtualMachineTable table = tables.VirtualMachineTable
@ -325,35 +288,30 @@ class VirtualMachineBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# VM interfaces # VM interfaces
# #
class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): class InterfaceCreateView(ComponentCreateView):
permission_required = 'dcim.add_interface' queryset = Interface.objects.all()
model = Interface
form = forms.InterfaceCreateForm form = forms.InterfaceCreateForm
model_form = forms.InterfaceForm model_form = forms.InterfaceForm
template_name = 'virtualization/virtualmachine_component_add.html' template_name = 'virtualization/virtualmachine_component_add.html'
class InterfaceEditView(PermissionRequiredMixin, ObjectEditView): class InterfaceEditView(ObjectEditView):
permission_required = 'dcim.change_interface'
queryset = Interface.objects.all() queryset = Interface.objects.all()
model_form = forms.InterfaceForm model_form = forms.InterfaceForm
template_name = 'virtualization/interface_edit.html' template_name = 'virtualization/interface_edit.html'
class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): class InterfaceDeleteView(ObjectDeleteView):
permission_required = 'dcim.delete_interface'
queryset = Interface.objects.all() queryset = Interface.objects.all()
class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): class InterfaceBulkEditView(BulkEditView):
permission_required = 'dcim.change_interface'
queryset = Interface.objects.all() queryset = Interface.objects.all()
table = tables.InterfaceTable table = tables.InterfaceTable
form = forms.InterfaceBulkEditForm form = forms.InterfaceBulkEditForm
class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class InterfaceBulkDeleteView(BulkDeleteView):
permission_required = 'dcim.delete_interface'
queryset = Interface.objects.all() queryset = Interface.objects.all()
table = tables.InterfaceTable table = tables.InterfaceTable
@ -362,12 +320,11 @@ class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Bulk Device component creation # Bulk Device component creation
# #
class VirtualMachineBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateView): class VirtualMachineBulkAddInterfaceView(BulkComponentCreateView):
permission_required = 'dcim.add_interface'
parent_model = VirtualMachine parent_model = VirtualMachine
parent_field = 'virtual_machine' parent_field = 'virtual_machine'
form = forms.InterfaceBulkCreateForm form = forms.InterfaceBulkCreateForm
model = Interface queryset = Interface.objects.all()
model_form = forms.InterfaceForm model_form = forms.InterfaceForm
filterset = filters.VirtualMachineFilterSet filterset = filters.VirtualMachineFilterSet
table = tables.VirtualMachineTable table = tables.VirtualMachineTable