From 63a5188862a9c1bd15575bb5e1d9a930bd92f1e3 Mon Sep 17 00:00:00 2001 From: Ryan Merolle Date: Thu, 28 Jul 2022 09:28:50 -0400 Subject: [PATCH] Allow ACL associations to interfaces (#43) --- .devcontainer/Dockerfile-plugin_dev | 2 +- .devcontainer/docker-compose.yml | 20 +- .devcontainer/env/netbox.env | 4 +- Makefile | 5 + netbox_access_lists/api/nested_serializers.py | 20 +- netbox_access_lists/api/serializers.py | 192 +++++++++-- netbox_access_lists/api/urls.py | 1 + netbox_access_lists/api/views.py | 11 + netbox_access_lists/choices.py | 13 + netbox_access_lists/filtersets.py | 46 ++- netbox_access_lists/forms/bulk_edit.py | 2 +- netbox_access_lists/forms/filtersets.py | 83 ++++- netbox_access_lists/forms/models.py | 315 ++++++++++++++---- netbox_access_lists/graphql.py | 15 + .../migrations/0001_initial.py | 22 +- .../models/access_list_rules.py | 22 +- netbox_access_lists/models/access_lists.py | 79 ++++- netbox_access_lists/navigation.py | 16 +- netbox_access_lists/tables.py | 48 ++- netbox_access_lists/template_content.py | 37 +- .../assigned_host/assigned_access_lists.html | 18 +- .../inc/assigned_interface/access_lists.html | 13 + .../assigned_access_lists.html | 29 ++ .../netbox_access_lists/accesslist_edit.html | 2 +- .../aclinterfaceassignment.html | 81 +++++ .../aclinterfaceassignment_edit.html | 68 ++++ netbox_access_lists/urls.py | 16 +- netbox_access_lists/views.py | 56 +++- 28 files changed, 1080 insertions(+), 156 deletions(-) create mode 100644 netbox_access_lists/templates/inc/assigned_interface/access_lists.html create mode 100644 netbox_access_lists/templates/inc/assigned_interface/assigned_access_lists.html create mode 100644 netbox_access_lists/templates/netbox_access_lists/aclinterfaceassignment.html create mode 100644 netbox_access_lists/templates/netbox_access_lists/aclinterfaceassignment_edit.html diff --git a/.devcontainer/Dockerfile-plugin_dev b/.devcontainer/Dockerfile-plugin_dev index 22c8134..442c6e7 100644 --- a/.devcontainer/Dockerfile-plugin_dev +++ b/.devcontainer/Dockerfile-plugin_dev @@ -1,6 +1,6 @@ ARG VARIANT=latest -FROM tgenannt/netbox:${VARIANT} +FROM netboxcommunity/netbox:${VARIANT} ARG DEBIAN_FRONTEND=noninteractive diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 47ed4f9..1614fcc 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -1,7 +1,7 @@ version: '3.4' services: netbox: &netbox - image: tgenannt/netbox:${VARIANT-latest} + image: netboxcommunity/netbox:${VARIANT-latest} depends_on: - postgres - redis @@ -13,15 +13,15 @@ services: - ./initializers:/opt/netbox/initializers:z,ro - ./configuration:/etc/netbox/config:z,ro #- netbox-media-files:/opt/netbox/netbox/media:z - netbox-worker: - <<: *netbox - depends_on: - - redis - - postgres - command: - - /opt/netbox/venv/bin/python - - /opt/netbox/netbox/manage.py - - rqworker + #netbox-worker: + # <<: *netbox + # depends_on: + # - redis + # - postgres + # command: + # - /opt/netbox/venv/bin/python + # - /opt/netbox/netbox/manage.py + # - rqworker #netbox-housekeeping: # <<: *netbox # depends_on: diff --git a/.devcontainer/env/netbox.env b/.devcontainer/env/netbox.env index ef8607a..65849ae 100644 --- a/.devcontainer/env/netbox.env +++ b/.devcontainer/env/netbox.env @@ -1,10 +1,12 @@ ALLOWED_HOSTS=* -CORS_ORIGIN_ALLOW_ALL=True +CORS_ORIGIN_ALLOW_ALL=true DB_HOST=postgres DB_NAME=netbox DB_PASSWORD=J5brHrAXFLQSif0K DB_USER=netbox DEBUG=true +ENFORCE_GLOBAL_UNIQUE=true +LOGIN_REQUIRED=false GRAPHQL_ENABLED=true MAX_PAGE_SIZE=1000 MEDIA_ROOT=/opt/netbox/netbox/media diff --git a/Makefile b/Makefile index 2012c6b..86ce924 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,11 @@ cleanup: ## Clean associated docker resources. # in VS Code Devcontianer +.PHONY: nbshell +nbshell: ## Run nbshell + ${VENV_PY_PATH} ${NETBOX_MANAGE_PATH} nbshell + from netbox_access_lists.models import * + .PHONY: setup setup: ## Copy plugin settings. Setup NetBox plugin. -${VENV_PY_PATH} -m pip install --disable-pip-version-check --no-cache-dir -e ${REPO_PATH} diff --git a/netbox_access_lists/api/nested_serializers.py b/netbox_access_lists/api/nested_serializers.py index e12c648..a5910da 100644 --- a/netbox_access_lists/api/nested_serializers.py +++ b/netbox_access_lists/api/nested_serializers.py @@ -6,10 +6,12 @@ while Django itself handles the database abstraction. from netbox.api.serializers import WritableNestedSerializer from rest_framework import serializers -from ..models import AccessList, ACLExtendedRule, ACLStandardRule +from ..models import (AccessList, ACLExtendedRule, ACLInterfaceAssignment, + ACLStandardRule) __all__ = [ 'NestedAccessListSerializer', + 'NestedACLInterfaceAssignmentSerializer', 'NestedACLStandardRuleSerializer', 'NestedACLExtendedRuleSerializer' ] @@ -30,6 +32,22 @@ class NestedAccessListSerializer(WritableNestedSerializer): fields = ('id', 'url', 'display', 'name') +class NestedACLInterfaceAssignmentSerializer(WritableNestedSerializer): + """ + Defines the nested serializer for the django ACLInterfaceAssignment model & associates it to a view. + """ + url = serializers.HyperlinkedIdentityField( + view_name='plugins-api:netbox_access_lists-api:aclinterfaceassignment-detail' + ) + + class Meta: + """ + Associates the django model ACLInterfaceAssignment & fields to the nested serializer. + """ + model = ACLInterfaceAssignment + fields = ('id', 'url', 'display', 'access_list') + + class NestedACLStandardRuleSerializer(WritableNestedSerializer): """ Defines the nested serializer for the django ACLStandardRule model & associates it to a view. diff --git a/netbox_access_lists/api/serializers.py b/netbox_access_lists/api/serializers.py index fcc1495..efb6adb 100644 --- a/netbox_access_lists/api/serializers.py +++ b/netbox_access_lists/api/serializers.py @@ -6,23 +6,37 @@ while Django itself handles the database abstraction. from django.contrib.contenttypes.models import ContentType from drf_yasg.utils import swagger_serializer_method from ipam.api.serializers import NestedPrefixSerializer +from django.core.exceptions import ObjectDoesNotExist from netbox.api import ContentTypeField from netbox.api.serializers import NetBoxModelSerializer from rest_framework import serializers from utilities.api import get_serializer_for_model -from ..constants import ACL_HOST_ASSIGNMENT_MODELS -from ..models import AccessList, ACLExtendedRule, ACLStandardRule +from ..constants import (ACL_HOST_ASSIGNMENT_MODELS, + ACL_INTERFACE_ASSIGNMENT_MODELS) +from ..models import (AccessList, ACLExtendedRule, ACLInterfaceAssignment, + ACLStandardRule) from .nested_serializers import (NestedAccessListSerializer, NestedACLExtendedRuleSerializer, + NestedACLInterfaceAssignmentSerializer, NestedACLStandardRuleSerializer) __all__ = [ - 'NestedAccessListSerializer', - 'NestedACLStandardRuleSerializer', - 'NestedACLExtendedRuleSerializer' + 'AccessListSerializer', + 'ACLInterfaceAssignmentSerializer', + 'ACLStandardRuleSerializer', + 'ACLExtendedRuleSerializer' ] +# Sets a standard error message for ACL rules with an action of remark, but no remark set. +error_message_no_remark = 'Action is set to remark, you MUST add a remark.' +# Sets a standard error message for ACL rules with an action of remark, but no source_prefix is set. +error_message_action_remark_source_prefix_set = 'Action is set to remark, Source Prefix CANNOT be set.' +# Sets a standard error message for ACL rules with an action not set to remark, but no remark is set. +error_message_remark_without_action_remark = 'CANNOT set remark unless action is set to remark.' +# Sets a standard error message for ACL rules no associated to an ACL of the same type. +error_message_acl_type = 'Provided parent Access List is not of right type.' + class AccessListSerializer(NetBoxModelSerializer): """ @@ -55,16 +69,95 @@ class AccessListSerializer(NetBoxModelSerializer): def validate(self, data): """ - Validate the AccessList django model model's inputs before allowing it to update the instance. + Validates api inputs before processing: + - Check that the GFK object is valid. + - Check if Access List has no existing rules before change the Access List's type. """ - if self.instance.rule_count > 0: - raise serializers.ValidationError({ - 'type': 'This ACL has ACL rules already associated, CANNOT change ACL type!!' - }) + error_message = {} + + # Check that the GFK object is valid. + if 'assigned_object_type' in data and 'assigned_object_id' in data: + try: + assigned_object = data['assigned_object_type'].get_object_for_this_type(id=data['assigned_object_id']) + except ObjectDoesNotExist: + # Sets a standard error message for invalid GFK + error_message_invalid_gfk = f"Invalid assigned_object {data['assigned_object_type']} ID {data['assigned_object_id']}" + error_message['assigned_object_type'] = [error_message_invalid_gfk] + error_message['assigned_object_id'] = [error_message_invalid_gfk] + + # Check if Access List has no existing rules before change the Access List's type. + if self.instance and self.instance.type != data.get('type') and self.instance.rule_count > 0: + error_message['type'] = ['This ACL has ACL rules associated, CANNOT change ACL type.'] + + if error_message: + raise serializers.ValidationError(error_message) return super().validate(data) +class ACLInterfaceAssignmentSerializer(NetBoxModelSerializer): + """ + Defines the serializer for the django ACLInterfaceAssignment model & associates it to a view. + """ + url = serializers.HyperlinkedIdentityField( + view_name='plugins-api:netbox_access_lists-api:aclinterfaceassignment-detail' + ) + assigned_object_type = ContentTypeField( + queryset=ContentType.objects.filter(ACL_INTERFACE_ASSIGNMENT_MODELS) + ) + assigned_object = serializers.SerializerMethodField(read_only=True) + + class Meta: + """ + Associates the django model ACLInterfaceAssignment & fields to the serializer. + """ + model = ACLInterfaceAssignment + fields = ( + 'id', 'url', 'access_list', 'direction', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'comments', 'tags', 'custom_fields', 'created', + 'last_updated' + ) + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_assigned_object(self, obj): + serializer = get_serializer_for_model(obj.assigned_object, prefix='Nested') + context = {'request': self.context['request']} + return serializer(obj.assigned_object, context=context).data + + def validate(self, data): + """ + Validate the AccessList django model model's inputs before allowing it to update the instance. + - Check that the GFK object is valid. + - Check that the associated interface's parent host has the selected ACL defined. + """ + error_message = {} + acl_host = data['access_list'].assigned_object + + # Check that the GFK object is vlaid. + if 'assigned_object_type' in data and 'assigned_object_id' in data: + try: + assigned_object = data['assigned_object_type'].get_object_for_this_type(id=data['assigned_object_id']) + except ObjectDoesNotExist: + # Sets a standard error message for invalid GFK + error_message_invalid_gfk = f"Invalid assigned_object {data['assigned_object_type']} ID {data['assigned_object_id']}" + error_message['assigned_object_type'] = [error_message_invalid_gfk] + error_message['assigned_object_id'] = [error_message_invalid_gfk] + + + if data['assigned_object_type'].model == 'interface': + interface_host = data['assigned_object_type'].get_object_for_this_type(id=data['assigned_object_id']).device + elif data['assigned_object_type'].model == 'vminterface': + interface_host = data['assigned_object_type'].get_object_for_this_type(id=data['assigned_object_id']).virtual_machine + # Check that the associated interface's parent host has the selected ACL defined. + if acl_host != interface_host: + error_acl_not_assigned_to_host = "Access List not present on the selected interface's host." + error_message['access_list'] = [error_acl_not_assigned_to_host] + error_message['assigned_object_id'] = [error_acl_not_assigned_to_host] + + if error_message: + raise serializers.ValidationError(error_message) + + return super().validate(data) + class ACLStandardRuleSerializer(NetBoxModelSerializer): """ Defines the serializer for the django ACLStandardRule model & associates it to a view. @@ -73,7 +166,11 @@ class ACLStandardRuleSerializer(NetBoxModelSerializer): view_name='plugins-api:netbox_access_lists-api:aclstandardrule-detail' ) access_list = NestedAccessListSerializer() - source_prefix = NestedPrefixSerializer() + source_prefix = NestedPrefixSerializer( + required=False, + allow_null=True, + default=None + ) class Meta: """ @@ -82,18 +179,26 @@ class ACLStandardRuleSerializer(NetBoxModelSerializer): model = ACLStandardRule fields = ( 'id', 'url', 'display', 'access_list', 'index', 'action', 'tags', 'description', - 'created', 'custom_fields', 'last_updated', 'source_prefix' + 'remark', 'created', 'custom_fields', 'last_updated', 'source_prefix' ) def validate(self, data): """ - Validate the ACLStandardRule django model model's inputs before allowing it to update the instance. + Validate the ACLStandardRule django model model's inputs before allowing it to update the instance: + - Check if action set to remark, but no remark set. + - Check if action set to remark, but source_prefix set. """ - access_list = data.get('access_list') - if access_list.type == 'extended': - raise serializers.ValidationError({ - 'access_list': 'CANNOT associated standard ACL rules to an extended ACL!!' - }) + error_message = {} + + # Check if action set to remark, but no remark set. + if data.get('action') == 'remark' and data.get('remark') is None: + error_message['remark'] = [error_message_no_remark] + # Check if action set to remark, but source_prefix set. + if data.get('source_prefix'): + error_message['source_prefix'] = [error_message_action_remark_source_prefix_set] + + if error_message: + raise serializers.ValidationError(error_message) return super().validate(data) @@ -106,8 +211,16 @@ class ACLExtendedRuleSerializer(NetBoxModelSerializer): view_name='plugins-api:netbox_access_lists-api:aclextendedrule-detail' ) access_list = NestedAccessListSerializer() - source_prefix = NestedPrefixSerializer() - destination_prefix = NestedPrefixSerializer() + source_prefix = NestedPrefixSerializer( + required=False, + allow_null=True, + default=None + ) + destination_prefix = NestedPrefixSerializer( + required=False, + allow_null=True, + default=None + ) class Meta: """ @@ -117,17 +230,42 @@ class ACLExtendedRuleSerializer(NetBoxModelSerializer): fields = ( 'id', 'url', 'display', 'access_list', 'index', 'action', 'tags', 'description', 'created', 'custom_fields', 'last_updated', 'source_prefix', 'source_ports', - 'destination_prefix', 'destination_ports', 'protocol' + 'destination_prefix', 'destination_ports', 'protocol', 'remark', ) def validate(self, data): """ - Validate the ACLExtendedRule django model model's inputs before allowing it to update the instance. + Validate the ACLExtendedRule django model model's inputs before allowing it to update the instance: + - Check if action set to remark, but no remark set. + - Check if action set to remark, but source_prefix set. + - Check if action set to remark, but source_ports set. + - Check if action set to remark, but destination_prefix set. + - Check if action set to remark, but destination_ports set. + - Check if action set to remark, but protocol set. + - Check if action set to remark, but protocol set. """ - access_list = data.get('access_list') - if access_list.type == 'standard': - raise serializers.ValidationError({ - 'access_list': 'CANNOT associated extended ACL rules to a standard ACL!!' - }) + error_message = {} + + # Check if action set to remark, but no remark set. + if data.get('action') == 'remark' and data.get('remark') is None: + error_message['remark'] = [error_message_no_remark] + # Check if action set to remark, but source_prefix set. + if data.get('source_prefix'): + error_message['source_prefix'] = [error_message_action_remark_source_prefix_set] + # Check if action set to remark, but source_ports set. + if data.get('source_ports'): + error_message['source_ports'] = ['Action is set to remark, Source Ports CANNOT be set.'] + # Check if action set to remark, but destination_prefix set. + if data.get('destination_prefix'): + error_message['destination_prefix'] = ['Action is set to remark, Destination Prefix CANNOT be set.'] + # Check if action set to remark, but destination_ports set. + if data.get('destination_ports'): + error_message['destination_ports'] = ['Action is set to remark, Destination Ports CANNOT be set.'] + # Check if action set to remark, but protocol set. + if data.get('protocol'): + error_message['protocol'] = ['Action is set to remark, Protocol CANNOT be set.'] + + if error_message: + raise serializers.ValidationError(error_message) return super().validate(data) diff --git a/netbox_access_lists/api/urls.py b/netbox_access_lists/api/urls.py index 20fb28b..f38d1d9 100644 --- a/netbox_access_lists/api/urls.py +++ b/netbox_access_lists/api/urls.py @@ -10,6 +10,7 @@ app_name = 'netbox_access_list' router = NetBoxRouter() router.register('access-lists', views.AccessListViewSet) +router.register('interface-assignments', views.ACLInterfaceAssignmentViewSet) router.register('standard-acl-rules', views.ACLStandardRuleViewSet) router.register('extended-acl-rules', views.ACLExtendedRuleViewSet) diff --git a/netbox_access_lists/api/views.py b/netbox_access_lists/api/views.py index b2c9056..d4bd19b 100644 --- a/netbox_access_lists/api/views.py +++ b/netbox_access_lists/api/views.py @@ -9,11 +9,13 @@ from netbox.api.viewsets import NetBoxModelViewSet from .. import filtersets, models from .serializers import (AccessListSerializer, ACLExtendedRuleSerializer, + ACLInterfaceAssignmentSerializer, ACLStandardRuleSerializer) __all__ = [ 'AccessListViewSet', 'ACLStandardRuleViewSet', + 'ACLInterfaceAssignmentViewSet' 'ACLExtendedRuleViewSet', ] @@ -29,6 +31,15 @@ class AccessListViewSet(NetBoxModelViewSet): filterset_class = filtersets.AccessListFilterSet +class ACLInterfaceAssignmentViewSet(NetBoxModelViewSet): + """ + Defines the view set for the django ACLInterfaceAssignment model & associates it to a view. + """ + queryset = models.ACLInterfaceAssignment.objects.prefetch_related('access_list', 'tags') + serializer_class = ACLInterfaceAssignmentSerializer + filterset_class = filtersets.ACLInterfaceAssignmentFilterSet + + class ACLStandardRuleViewSet(NetBoxModelViewSet): """ Defines the view set for the django ACLStandardRule model & associates it to a view. diff --git a/netbox_access_lists/choices.py b/netbox_access_lists/choices.py index 086c665..6531e9b 100644 --- a/netbox_access_lists/choices.py +++ b/netbox_access_lists/choices.py @@ -6,6 +6,8 @@ from utilities.choices import ChoiceSet __all__ = ( 'ACLActionChoices', + 'ACLAssignmentDirectionChoices', + 'ACLProtocolChoices', 'ACLRuleActionChoices', 'ACLTypeChoices', 'ACLProtocolChoices', @@ -42,6 +44,17 @@ class ACLRuleActionChoices(ChoiceSet): ] +class ACLAssignmentDirectionChoices(ChoiceSet): + """ + Defines the direction of the application of the ACL on an associated interface. + """ + + CHOICES = [ + ('ingress', 'Ingress', 'blue'), + ('egress', 'Egress', 'purple'), + ] + + class ACLTypeChoices(ChoiceSet): """ Defines the choices availble for the Access Lists plugin specific to ACL type. diff --git a/netbox_access_lists/filtersets.py b/netbox_access_lists/filtersets.py index 4a87988..d03b16e 100644 --- a/netbox_access_lists/filtersets.py +++ b/netbox_access_lists/filtersets.py @@ -3,15 +3,16 @@ Filters enable users to request only a specific subset of objects matching a que when filtering the sites list by status or region, for instance. """ import django_filters -from dcim.models import Device, VirtualChassis +from dcim.models import Device, Interface, VirtualChassis from netbox.filtersets import NetBoxModelFilterSet -from virtualization.models import VirtualMachine +from virtualization.models import VirtualMachine, VMInterface from .models import * __all__ = ( 'AccessListFilterSet', 'ACLStandardRuleFilterSet', + 'ACLInterfaceAssignmentFilterSet', 'ACLExtendedRuleFilterSet', ) @@ -68,6 +69,47 @@ class AccessListFilterSet(NetBoxModelFilterSet): return queryset.filter(description__icontains=value) +class ACLInterfaceAssignmentFilterSet(NetBoxModelFilterSet): + """ + Define the filter set for the django model ACLInterfaceAssignment. + """ + interface = django_filters.ModelMultipleChoiceFilter( + field_name='interface__name', + queryset=Interface.objects.all(), + to_field_name='name', + label='Interface (name)', + ) + interface_id = django_filters.ModelMultipleChoiceFilter( + field_name='interface', + queryset=Interface.objects.all(), + label='Interface (ID)', + ) + vminterface = django_filters.ModelMultipleChoiceFilter( + field_name='vminterface__name', + queryset=VMInterface.objects.all(), + to_field_name='name', + label='VM Interface (name)', + ) + vminterface_id = django_filters.ModelMultipleChoiceFilter( + field_name='vminterface', + queryset=VMInterface.objects.all(), + label='VM Interface (ID)', + ) + + class Meta: + """ + Associates the django model ACLInterfaceAssignment & fields to the filter set. + """ + model = ACLInterfaceAssignment + fields = ('id', 'access_list', 'direction', 'interface', 'interface_id', 'vminterface', 'vminterface_id') + + def search(self, queryset, name, value): + """ + Override the default search behavior for the django model. + """ + return queryset.filter(description__icontains=value) + + class ACLStandardRuleFilterSet(NetBoxModelFilterSet): """ Define the filter set for the django model ACLStandardRule. diff --git a/netbox_access_lists/forms/bulk_edit.py b/netbox_access_lists/forms/bulk_edit.py index 07403d9..bf9d72d 100644 --- a/netbox_access_lists/forms/bulk_edit.py +++ b/netbox_access_lists/forms/bulk_edit.py @@ -77,7 +77,7 @@ from ..models import AccessList # return cleaned_data # name = cleaned_data.get('name') # device = cleaned_data.get('device') -# type = cleaned_data.get('type') +# type = cleaned_data.get('type') # if ('name' in self.changed_data or 'device' in self.changed_data) and AccessList.objects.filter(name__iexact=name, device=device).exists(): # raise forms.ValidationError('An ACL with this name (case insensitive) is already associated to this device.') # if type == 'extended' and self.cleaned_data['aclstandardrules'].exists(): diff --git a/netbox_access_lists/forms/filtersets.py b/netbox_access_lists/forms/filtersets.py index 074f1cc..96d09b9 100644 --- a/netbox_access_lists/forms/filtersets.py +++ b/netbox_access_lists/forms/filtersets.py @@ -2,21 +2,25 @@ Defines each django model's GUI filter/search options. """ -from dcim.models import Device, Region, Site, SiteGroup, VirtualChassis +from dcim.models import (Device, Interface, Region, Site, SiteGroup, + VirtualChassis) from django import forms from ipam.models import Prefix from netbox.forms import NetBoxModelFilterSetForm from utilities.forms import (ChoiceField, DynamicModelChoiceField, StaticSelect, StaticSelectMultiple, TagFilterField, add_blank_choice) -from virtualization.models import VirtualMachine +from virtualization.models import VirtualMachine, VMInterface -from ..choices import (ACLActionChoices, ACLProtocolChoices, - ACLRuleActionChoices, ACLTypeChoices) -from ..models import AccessList, ACLExtendedRule, ACLStandardRule +from ..choices import (ACLActionChoices, ACLAssignmentDirectionChoices, + ACLProtocolChoices, ACLRuleActionChoices, + ACLTypeChoices) +from ..models import (AccessList, ACLExtendedRule, ACLInterfaceAssignment, + ACLStandardRule) __all__ = ( 'AccessListFilterForm', + 'ACLInterfaceAssignmentFilterForm', 'ACLStandardRuleFilterForm', 'ACLExtendedRuleFilterForm', ) @@ -71,6 +75,75 @@ class AccessListFilterForm(NetBoxModelFilterSetForm): ) +class ACLInterfaceAssignmentFilterForm(NetBoxModelFilterSetForm): + """ + GUI filter form to search the django AccessList model. + """ + model = ACLInterfaceAssignment + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label='Site Group' + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False + ) + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + query_params={ + 'region': '$region', + 'group_id': '$site_group', + 'site_id': '$site', + }, + required=False + ) + interface = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + query_params={ + 'device_id': '$device' + } + ) + virtual_machine = DynamicModelChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + label='Virtual Machine', + ) + vminterface = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + query_params={ + 'virtual_machine_id': '$virtual_machine' + }, + label='Interface' + ) + access_list = DynamicModelChoiceField( + queryset=AccessList.objects.all(), + query_params={ + 'assigned_object': '$device', + }, + label='Access List', + ) + direction = ChoiceField( + choices=add_blank_choice(ACLAssignmentDirectionChoices), + required=False, + initial='', + widget=StaticSelect(), + ) + tag = TagFilterField(model) + + #fieldsets = ( + # (None, ('q', 'tag')), + # ('Host Details', ('region', 'site_group', 'site', 'device')), + # ('ACL Details', ('type', 'default_action')), + #) + + class ACLStandardRuleFilterForm(NetBoxModelFilterSetForm): """ GUI filter form to search the django ACLStandardRule model. diff --git a/netbox_access_lists/forms/models.py b/netbox_access_lists/forms/models.py index 66cd728..7a479bc 100644 --- a/netbox_access_lists/forms/models.py +++ b/netbox_access_lists/forms/models.py @@ -2,26 +2,41 @@ Defines each django model's GUI form to add or edit objects for each django model. """ -from dcim.models import Device, Region, Site, SiteGroup, VirtualChassis +from dcim.models import (Device, Interface, Region, Site, SiteGroup, + VirtualChassis) from django import forms +from django.contrib.contenttypes.models import ContentType from django.utils.safestring import mark_safe from extras.models import Tag from ipam.models import Prefix from netbox.forms import NetBoxModelForm from utilities.forms import (CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField) -from virtualization.models import VirtualMachine +from virtualization.models import VirtualMachine, VMInterface -from ..models import AccessList, ACLExtendedRule, ACLStandardRule +from ..models import (AccessList, ACLExtendedRule, ACLInterfaceAssignment, + ACLStandardRule) __all__ = ( 'AccessListForm', + 'ACLInterfaceAssignmentForm', 'ACLStandardRuleForm', 'ACLExtendedRuleForm', ) -# Sets a standard mark_save help_text value to be used by the various classes -acl_rule_logic_help = mark_safe('*Note: CANNOT be set if action is set to remark.') +# Sets a standard mark_safe help_text value to be used by the various classes +help_text_acl_rule_logic = mark_safe('*Note: CANNOT be set if action is set to remark.') +# Sets a standard help_text value to be used by the various classes for acl action +help_text_acl_action = 'Action the rule will take (remark, deny, or allow).' +# Sets a standard help_text value to be used by the various classes for acl index +help_text_acl_rule_index = 'Determines the order of the rule in the ACL processing. AKA Sequence Number.' + +# Sets a standard error message for ACL rules with an action of remark, but no remark set. +error_message_no_remark = 'Action is set to remark, you MUST add a remark.' +# Sets a standard error message for ACL rules with an action of remark, but no source_prefix is set. +error_message_action_remark_source_prefix_set = 'Action is set to remark, Source Prefix CANNOT be set.' +# Sets a standard error message for ACL rules with an action not set to remark, but no remark is set. +error_message_remark_without_action_remark = 'CANNOT set remark unless action is set to remark.' class AccessListForm(NetBoxModelForm): @@ -94,47 +109,50 @@ class AccessListForm(NetBoxModelForm): def clean(self): """ - Validates form inputs before submitting. + Validates form inputs before submitting: + - Check if more than one host type selected. + - Check if no hosts selected. + - Check if duplicate entry. (Because of GFK.) + - Check if Access List has no existing rules before change the Access List's type. """ cleaned_data = super().clean() error_message = {} if self.errors.get('name'): return cleaned_data name = cleaned_data.get('name') - type = cleaned_data.get('type') + acl_type = cleaned_data.get('type') device = cleaned_data.get('device') virtual_chassis = cleaned_data.get('virtual_chassis') virtual_machine = cleaned_data.get('virtual_machine') + + + # Check if more than one host type selected. if (device and virtual_chassis) or (device and virtual_machine) or (virtual_chassis and virtual_machine): - raise forms.ValidationError('Access Lists must be assigned to one host (either a device, virtual chassis or virtual machine).') + raise forms.ValidationError('Access Lists must be assigned to one host (either a device, virtual chassis or virtual machine) at a time.') + # Check if no hosts selected. if not device and not virtual_chassis and not virtual_machine: raise forms.ValidationError('Access Lists must be assigned to a device, virtual chassis or virtual machine.') - if ('name' in self.changed_data or 'device' in self.changed_data) and device and AccessList.objects.filter(name__iexact=name, device=device).exists(): - error_message.update( - { - 'device': ['An ACL with this name (case insensitive) is already associated to this host.'], - 'name': ['An ACL with this name (case insensitive) is already associated to this host.'], - } - ) - if ('name' in self.changed_data or 'virtual_chassis' in self.changed_data) and virtual_chassis and AccessList.objects.filter(name__iexact=name, virtual_chassis=virtual_chassis).exists(): - error_message.update( - { - 'virtual_chassis': ['An ACL with this name (case insensitive) is already associated to this host.'], - 'name': ['An ACL with this name (case insensitive) is already associated to this host.'], - } - ) - if ('name' in self.changed_data or 'virtual_machine' in self.changed_data) and virtual_machine and AccessList.objects.filter(name__iexact=name, virtual_machine=virtual_machine).exists(): - error_message.update( - { - 'virtual_machine': ['An ACL with this name (case insensitive) is already associated to this host.'], - 'name': ['An ACL with this name (case insensitive) is already associated to this host.'], - } - ) - if type == 'extended' and self.instance.aclstandardrules.exists(): - error_message.update({'type': ['This ACL has Standard ACL rules already associated, CANNOT change ACL type!!']}) - elif type == 'standard' and self.instance.aclextendedrules.exists(): - error_message.update({'type': ['This ACL has Extended ACL rules already associated, CANNOT change ACL type!!']}) - if len(error_message) > 0: + + if device: + host_type = 'device' + existing_acls = AccessList.objects.filter(name=name, device=device).exists() + elif virtual_chassis: + host_type = 'virtual_chassis' + existing_acls = AccessList.objects.filter(name=name, virtual_chassis=virtual_chassis).exists() + elif virtual_machine: + host_type = 'virtual_machine' + existing_acls = AccessList.objects.filter(name=name, virtual_machine=virtual_machine).exists() + host = cleaned_data.get(host_type) + + # Check if duplicate entry. + if ('name' in self.changed_data or host_type in self.changed_data) and existing_acls: + error_same_acl_name = 'An ACL with this name is already associated to this host.' + error_message |= {host_type: [error_same_acl_name], 'name': [error_same_acl_name]} + # Check if Access List has no existing rules before change the Access List's type. + if (acl_type == 'extended' and self.instance.aclstandardrules.exists()) or (acl_type == 'standard' and self.instance.aclextendedrules.exists()): + error_message['type'] = ['This ACL has ACL rules associated, CANNOT change ACL type.'] + + if error_message: raise forms.ValidationError(error_message) return cleaned_data @@ -146,6 +164,150 @@ class AccessListForm(NetBoxModelForm): return super().save(*args, **kwargs) +class ACLInterfaceAssignmentForm(NetBoxModelForm): + """ + GUI form to add or edit ACL Host Object assignments + Requires an access_list, a name, a type, and a default_action. + """ + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False, + query_params={ + # Need to pass ACL device to it + }, + ) + interface = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + query_params={ + 'device_id': '$device' + } + ) + virtual_machine = DynamicModelChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + label='Virtual Machine', + ) + vminterface = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + query_params={ + 'virtual_machine_id': '$virtual_machine' + }, + label='VM Interface' + ) + #virtual_chassis = DynamicModelChoiceField( + # queryset=VirtualChassis.objects.all(), + # required=False, + # label='Virtual Chassis', + #) + access_list = DynamicModelChoiceField( + queryset=AccessList.objects.all(), + #query_params={ + # 'assigned_object': '$device', + # 'assigned_object': '$virtual_machine', + #}, + label='Access List', + help_text=mark_safe('*Note: Access List must be present on the device already.') + ) + comments = CommentField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + def __init__(self, *args, **kwargs): + + # Initialize helper selectors + instance = kwargs.get('instance') + initial = kwargs.get('initial', {}).copy() + if instance: + if type(instance.assigned_object) is Interface: + initial['interface'] = instance.assigned_object + initial['device'] = 'device' + elif type(instance.assigned_object) is VMInterface: + initial['vminterface'] = instance.assigned_object + initial['virtual_machine'] = 'virtual_machine' + kwargs['initial'] = initial + + super().__init__(*args, **kwargs) + + + class Meta: + model = ACLInterfaceAssignment + fields = ( + 'access_list', 'direction', 'device', 'interface', 'virtual_machine', + 'vminterface', 'comments', 'tags', + ) + help_texts = { + 'direction': mark_safe('*Note: CANNOT assign 2 ACLs to the same interface & direction.'), + } + + def clean(self): + """ + Validates form inputs before submitting: + - Check if both interface and vminterface are set. + - Check if neither interface or vminterface are set. + - Check that an interface's parent device/virtual_machine is assigned to the Access List. + - Check that an interface's parent device/virtual_machine is assigned to the Access List. + - Check for duplicate entry. (Because of GFK) + - Check that the interface does not have an existing ACL applied in the direction already. + """ + cleaned_data = super().clean() + error_message = {} + access_list = cleaned_data.get('access_list') + direction = cleaned_data.get('direction') + interface = cleaned_data.get('interface') + vminterface = cleaned_data.get('vminterface') + assigned_object = cleaned_data.get('assigned_object') + if interface: + assigned_object = interface + assigned_object_type = 'interface' + host_type = 'device' + host = Interface.objects.get(pk=assigned_object.pk).device + elif vminterface: + assigned_object = vminterface + assigned_object_type = 'vminterface' + host_type = 'virtual_machine' + host = VMInterface.objects.get(pk=assigned_object.pk).virtual_machine + if interface or vminterface: + assigned_object_id = VMInterface.objects.get(pk=assigned_object.pk).pk + assigned_object_type_id = ContentType.objects.get_for_model(assigned_object).pk + access_list_host = AccessList.objects.get(pk=access_list.pk).assigned_object + + # Check if both interface and vminterface are set. + if interface and vminterface: + error_too_many_interfaces = 'Access Lists must be assigned to one type of interface at a time (VM interface or physical interface)' + error_too_many_hosts = 'Access Lists must be assigned to one type of device at a time (VM or physical device).' + error_message |= {'device': [error_too_many_hosts], 'interface': [error_too_many_interfaces], 'virtual_machine': [error_too_many_hosts], 'vminterface': [error_too_many_interfaces]} + # Check if neither interface or vminterface are set. + elif not (interface or vminterface): + error_no_interface = 'An Access List assignment but specify an Interface or VM Interface.' + error_message |= {'interface': [error_no_interface], 'vminterface': [error_no_interface]} + # Check that an interface's parent device/virtual_machine is assigned to the Access List. + elif access_list_host != host: + error_acl_not_assigned_to_host = 'Access List not present on selected host.' + error_message |= {'access_list': [error_acl_not_assigned_to_host], assigned_object_type: [error_acl_not_assigned_to_host], host_type: [error_acl_not_assigned_to_host]} + # Check for duplicate entry. + elif ACLInterfaceAssignment.objects.filter(access_list=access_list, assigned_object_id=assigned_object_id, assigned_object_type=assigned_object_type_id, direction=direction).exists(): + error_duplicate_entry = 'An ACL with this name is already associated to this interface & direction.' + error_message |= {'access_list': [error_duplicate_entry], 'direction': [error_duplicate_entry], assigned_object_type: [error_duplicate_entry]} + # Check that the interface does not have an existing ACL applied in the direction already. + elif ACLInterfaceAssignment.objects.filter(assigned_object_id=assigned_object_id, assigned_object_type=assigned_object_type_id, direction=direction).exists(): + error_interface_already_assigned = 'Interfaces can only have 1 Access List assigned in each direction.' + error_message |= {'direction': [error_interface_already_assigned], assigned_object_type: [error_interface_already_assigned]} + + if error_message: + raise forms.ValidationError(error_message) + return cleaned_data + + def save(self, *args, **kwargs): + # Set assigned object + self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface') + + return super().save(*args, **kwargs) + + class ACLStandardRuleForm(NetBoxModelForm): """ GUI form to add or edit Standard Access List. @@ -163,7 +325,7 @@ class ACLStandardRuleForm(NetBoxModelForm): source_prefix = DynamicModelChoiceField( queryset=Prefix.objects.all(), required=False, - help_text=acl_rule_logic_help, + help_text=help_text_acl_rule_logic, label='Source Prefix', ) tags = DynamicModelMultipleChoiceField( @@ -183,27 +345,35 @@ class ACLStandardRuleForm(NetBoxModelForm): 'tags', 'description' ) help_texts = { - 'index': 'Determines the order of the rule in the ACL processing.', + 'index': help_text_acl_rule_index, + 'action': help_text_acl_action, 'remark': mark_safe('*Note: CANNOT be set if source prefix OR action is set.'), } def clean(self): """ - Validates form inputs before submitting. - If action is set to remark, remark needs to be set. - If action is set to remark, source_prefix cannot be set. - If action is not set to remark, remark cannot be set. + Validates form inputs before submitting: + - Check if action set to remark, but no remark set. + - Check if action set to remark, but source_prefix set. + - Check remark set, but action not set to remark. """ cleaned_data = super().clean() error_message = {} + + # No need to check for unique_together since there is no usage of GFK + if cleaned_data.get('action') == 'remark': - if cleaned_data.get('remark') is None: - error_message.update({'remark': ['Action is set to remark, you MUST add a remark.']}) + # Check if action set to remark, but no remark set. + if not cleaned_data.get('remark'): + error_message['remark'] = [error_message_no_remark] + # Check if action set to remark, but source_prefix set. if cleaned_data.get('source_prefix'): - error_message.update({'source_prefix': ['Action is set to remark, Source Prefix CANNOT be set.']}) + error_message['source_prefix'] = [error_message_action_remark_source_prefix_set] + # Check remark set, but action not set to remark. elif cleaned_data.get('remark'): - error_message.update({'remark': ['CANNOT set remark unless action is set to remark, .']}) - if len(error_message) > 0: + error_message['remark'] = [error_message_remark_without_action_remark] + + if error_message: raise forms.ValidationError(error_message) return cleaned_data @@ -229,13 +399,13 @@ class ACLExtendedRuleForm(NetBoxModelForm): source_prefix = DynamicModelChoiceField( queryset=Prefix.objects.all(), required=False, - help_text=acl_rule_logic_help, + help_text=help_text_acl_rule_logic, label='Source Prefix', ) destination_prefix = DynamicModelChoiceField( queryset=Prefix.objects.all(), required=False, - help_text=acl_rule_logic_help, + help_text=help_text_acl_rule_logic, label='Destination Prefix', ) fieldsets = ( @@ -251,37 +421,54 @@ class ACLExtendedRuleForm(NetBoxModelForm): 'tags', 'description' ) help_texts = { - 'destination_ports': acl_rule_logic_help, - 'index': 'Determines the order of the rule in the ACL processing.', - 'protocol': acl_rule_logic_help, + 'action': help_text_acl_action, + 'destination_ports': help_text_acl_rule_logic, + 'index': help_text_acl_rule_index, + 'protocol': help_text_acl_rule_logic, 'remark': mark_safe('*Note: CANNOT be set if action is not set to remark.'), - 'source_ports': acl_rule_logic_help, + 'source_ports': help_text_acl_rule_logic, } def clean(self): """ - Validates form inputs before submitting. - If action is set to remark, remark needs to be set. - If action is set to remark, source_prefix, source_ports, desintation_prefix, destination_ports, or protocol cannot be set. - If action is not set to remark, remark cannot be set. + Validates form inputs before submitting: + - Check if action set to remark, but no remark set. + - Check if action set to remark, but source_prefix set. + - Check if action set to remark, but source_ports set. + - Check if action set to remark, but destination_prefix set. + - Check if action set to remark, but destination_ports set. + - Check if action set to remark, but destination_ports set. + - Check if action set to remark, but protocol set. + - Check remark set, but action not set to remark. """ cleaned_data = super().clean() error_message = {} + + # No need to check for unique_together since there is no usage of GFK + if cleaned_data.get('action') == 'remark': - if cleaned_data.get('remark') is None: - error_message.update({'remark': ['Action is set to remark, you MUST add a remark.']}) + # Check if action set to remark, but no remark set. + if not cleaned_data.get('remark'): + error_message['remark'] = [error_message_no_remark] + # Check if action set to remark, but source_prefix set. if cleaned_data.get('source_prefix'): - error_message.update({'source_prefix': ['Action is set to remark, Source Prefix CANNOT be set.']}) + error_message['source_prefix'] = [error_message_action_remark_source_prefix_set] + # Check if action set to remark, but source_ports set. if cleaned_data.get('source_ports'): - error_message.update({'source_ports': ['Action is set to remark, Source Ports CANNOT be set.']}) + error_message['source_ports'] = ['Action is set to remark, Source Ports CANNOT be set.'] + # Check if action set to remark, but destination_prefix set. if cleaned_data.get('destination_prefix'): - error_message.update({'destination_prefix': ['Action is set to remark, Destination Prefix CANNOT be set.']}) + error_message['destination_prefix'] = ['Action is set to remark, Destination Prefix CANNOT be set.'] + # Check if action set to remark, but destination_ports set. if cleaned_data.get('destination_ports'): - error_message.update({'destination_ports': ['Action is set to remark, Destination Ports CANNOT be set.']}) + error_message['destination_ports'] = ['Action is set to remark, Destination Ports CANNOT be set.'] + # Check if action set to remark, but protocol set. if cleaned_data.get('protocol'): - error_message.update({'protocol': ['Action is set to remark, Protocol CANNOT be set.']}) + error_message['protocol'] = ['Action is set to remark, Protocol CANNOT be set.'] + # Check if action not set to remark, but remark set. elif cleaned_data.get('remark'): - error_message.update({'remark': ['CANNOT set remark unless action is set to remark, .']}) - if len(error_message) > 0: + error_message['remark'] = [error_message_remark_without_action_remark] + + if error_message: raise forms.ValidationError(error_message) return cleaned_data diff --git a/netbox_access_lists/graphql.py b/netbox_access_lists/graphql.py index 3c410e4..e1eac6d 100644 --- a/netbox_access_lists/graphql.py +++ b/netbox_access_lists/graphql.py @@ -10,6 +10,7 @@ from . import filtersets, models __all__ = ( 'AccessListType', + 'ACLInterfaceAssignmentType', 'ACLExtendedRuleType', 'ACLStandardRuleType', ) @@ -33,6 +34,20 @@ class AccessListType(NetBoxObjectType): filterset_class = filtersets.AccessListFilterSet +class ACLInterfaceAssignmentType(NetBoxObjectType): + """ + Defines the object type for the django model AccessList. + """ + + class Meta: + """ + Associates the filterset, fields, and model for the django model ACLInterfaceAssignment. + """ + model = models.ACLInterfaceAssignment + fields = '__all__' + filterset_class = filtersets.ACLInterfaceAssignmentFilterSet + + class ACLExtendedRuleType(NetBoxObjectType): """ Defines the object type for the django model ACLExtendedRule. diff --git a/netbox_access_lists/migrations/0001_initial.py b/netbox_access_lists/migrations/0001_initial.py index 156d445..60e10ac 100644 --- a/netbox_access_lists/migrations/0001_initial.py +++ b/netbox_access_lists/migrations/0001_initial.py @@ -32,7 +32,7 @@ class Migration(migrations.Migration): ('created', models.DateTimeField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), - ('name', models.CharField(max_length=100)), + ('name', models.CharField(max_length=500)), ('assigned_object_id', models.PositiveIntegerField()), ('assigned_object_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype')), ('type', models.CharField(max_length=100)), @@ -46,6 +46,26 @@ class Migration(migrations.Migration): 'verbose_name': 'Access List', }, ), + migrations.CreateModel( + name='ACLInterfaceAssignment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('access_list', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='aclinterfaceassignment', to='netbox_access_lists.accesslist')), + ('direction', models.CharField(max_length=100)), + ('assigned_object_id', models.PositiveIntegerField()), + ('assigned_object_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype')), + ('comments', models.TextField(blank=True)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ('access_list', 'assigned_object_type', 'assigned_object_id', 'direction'), + 'unique_together': {('assigned_object_type', 'assigned_object_id', 'access_list', 'direction')}, + 'verbose_name': 'ACL Interface Assignment', + }, + ), migrations.CreateModel( name='ACLStandardRule', fields=[ diff --git a/netbox_access_lists/models/access_list_rules.py b/netbox_access_lists/models/access_list_rules.py index 79ec008..7ac017d 100644 --- a/netbox_access_lists/models/access_list_rules.py +++ b/netbox_access_lists/models/access_list_rules.py @@ -26,12 +26,12 @@ class ACLRule(NetBoxModel): on_delete=models.CASCADE, to=AccessList, verbose_name='Access List', + related_name='rules', ) index = models.PositiveIntegerField() remark = models.CharField( - max_length=200, - blank=True, - null=True + max_length=500, + blank=True ) description = models.CharField( max_length=500, @@ -72,6 +72,13 @@ class ACLStandardRule(ACLRule): """ Inherits ACLRule. """ + access_list = models.ForeignKey( + on_delete=models.CASCADE, + to=AccessList, + verbose_name='Standard Access List', + limit_choices_to={'type': 'standard'}, + related_name='aclstandardrules', + ) def get_absolute_url(self): """ @@ -87,7 +94,6 @@ class ACLStandardRule(ACLRule): - verbose name (for displaying in the GUI) - verbose name plural (for displaying in the GUI) """ - default_related_name='aclstandardrules' verbose_name='ACL Standard Rule' verbose_name_plural='ACL Standard Rules' @@ -96,6 +102,13 @@ class ACLExtendedRule(ACLRule): Inherits ACLRule. Add ACLExtendedRule specific fields: source_ports, desintation_prefix, destination_ports, and protocol """ + access_list = models.ForeignKey( + on_delete=models.CASCADE, + to=AccessList, + verbose_name='Extended Access List', + limit_choices_to={'type': 'extended'}, + related_name='aclextendedrules', + ) source_ports = ArrayField( base_field=models.PositiveIntegerField(), blank=True, @@ -139,6 +152,5 @@ class ACLExtendedRule(ACLRule): - verbose name (for displaying in the GUI) - verbose name plural (for displaying in the GUI) """ - default_related_name='aclextendedrules' verbose_name='ACL Extended Rule' verbose_name_plural='ACL Extended Rules' diff --git a/netbox_access_lists/models/access_lists.py b/netbox_access_lists/models/access_lists.py index 7f9a859..f0486e3 100644 --- a/netbox_access_lists/models/access_lists.py +++ b/netbox_access_lists/models/access_lists.py @@ -2,29 +2,35 @@ Define the django models for this plugin. """ -from dcim.models import Device, VirtualChassis +from dcim.models import Device, Interface, VirtualChassis from django.contrib.contenttypes.fields import (GenericForeignKey, GenericRelation) from django.contrib.contenttypes.models import ContentType +from django.core.validators import RegexValidator from django.db import models from django.urls import reverse from netbox.models import NetBoxModel -from virtualization.models import VirtualMachine +from virtualization.models import VirtualMachine, VMInterface from ..choices import * -from ..constants import ACL_HOST_ASSIGNMENT_MODELS +from ..constants import (ACL_HOST_ASSIGNMENT_MODELS, + ACL_INTERFACE_ASSIGNMENT_MODELS) __all__ = ( 'AccessList', + 'ACLInterfaceAssignment', ) +alphanumeric_plus = RegexValidator(r'^[0-9a-zA-Z,-,_]*$', 'Only alphanumeric, hyphens, and underscores characters are allowed.') + class AccessList(NetBoxModel): """ Model defintion for Access Lists. """ name = models.CharField( - max_length=100 + max_length=500, + validators=[alphanumeric_plus] ) assigned_object_type = models.ForeignKey( to=ContentType, @@ -53,7 +59,8 @@ class AccessList(NetBoxModel): class Meta: unique_together = ['assigned_object_type', 'assigned_object_id', 'name'] ordering = ['assigned_object_type', 'assigned_object_id', 'name'] - verbose_name = "Access List" + verbose_name = 'Access List' + verbose_name_plural = 'Access Lists' def __str__(self): return self.name @@ -72,6 +79,68 @@ class AccessList(NetBoxModel): return ACLTypeChoices.colors.get(self.type) +class ACLInterfaceAssignment(NetBoxModel): + """ + Model defintion for Access Lists associations with other Host interfaces: + - VM interfaces + - device interface + - tbd on more + """ + + access_list = models.ForeignKey( + on_delete=models.CASCADE, + to=AccessList, + verbose_name='Access List', + ) + direction = models.CharField( + max_length=30, + choices=ACLAssignmentDirectionChoices + ) + assigned_object_type = models.ForeignKey( + to=ContentType, + limit_choices_to=ACL_INTERFACE_ASSIGNMENT_MODELS, + on_delete=models.PROTECT + ) + assigned_object_id = models.PositiveBigIntegerField() + assigned_object = GenericForeignKey( + ct_field='assigned_object_type', + fk_field='assigned_object_id' + ) + comments = models.TextField( + blank=True + ) + + class Meta: + unique_together = ['assigned_object_type', 'assigned_object_id', 'access_list', 'direction'] + ordering = ['assigned_object_type', 'assigned_object_id', 'access_list', 'direction'] + verbose_name = 'ACL Interface Assignment' + verbose_name_plural = 'ACL Interface Assignments' + + def get_absolute_url(self): + """ + The method is a Django convention; although not strictly required, + it conveniently returns the absolute URL for any particular object. + """ + return reverse('plugins:netbox_access_lists:aclinterfaceassignment', args=[self.pk]) + + def get_direction_color(self): + return ACLAssignmentDirectionChoices.colors.get(self.direction) + + +GenericRelation( + to=ACLInterfaceAssignment, + content_type_field='assigned_object_type', + object_id_field='assigned_object_id', + related_query_name='interface' +).contribute_to_class(Interface, 'accesslistassignments') + +GenericRelation( + to=ACLInterfaceAssignment, + content_type_field='assigned_object_type', + object_id_field='assigned_object_id', + related_query_name='vminterface' +).contribute_to_class(VMInterface, 'accesslistassignments') + GenericRelation( to=AccessList, content_type_field='assigned_object_type', diff --git a/netbox_access_lists/navigation.py b/netbox_access_lists/navigation.py index 0ba5610..2131f4e 100644 --- a/netbox_access_lists/navigation.py +++ b/netbox_access_lists/navigation.py @@ -36,6 +36,15 @@ aclextendedrule_butons = [ ) ] +accesslistassignment_buttons = [ + PluginMenuButton( + link='plugins:netbox_access_lists:aclinterfaceassignment_add', + title='Add', + icon_class='mdi mdi-plus-thick', + color=ButtonColorChoices.GREEN + ) +] + # # Define navigation bar links including the above buttons defined. # @@ -49,7 +58,7 @@ menu_items = ( # Comment out Standard Access List rule to force creation in the ACL view PluginMenuItem( link='plugins:netbox_access_lists:aclstandardrule_list', - link_text='ACL Standard Rule', + link_text='ACL Standard Rules', buttons=aclstandardrule_butons ), # Comment out Extended Access List rule to force creation in the ACL view @@ -58,4 +67,9 @@ menu_items = ( link_text='ACL Extended Rules', buttons=aclextendedrule_butons ), + PluginMenuItem( + link='plugins:netbox_access_lists:aclinterfaceassignment_list', + link_text='ACL Interface Assignments', + buttons=accesslistassignment_buttons + ), ) diff --git a/netbox_access_lists/tables.py b/netbox_access_lists/tables.py index 9284b81..fda26ec 100644 --- a/netbox_access_lists/tables.py +++ b/netbox_access_lists/tables.py @@ -3,23 +3,34 @@ Define the object lists / table view for each of the plugin models. """ import django_tables2 as tables -from netbox.tables import ChoiceFieldColumn, NetBoxTable, columns +from netbox.tables import (ChoiceFieldColumn, NetBoxTable, TemplateColumn, + columns) -from .models import AccessList, ACLExtendedRule, ACLStandardRule +from .models import (AccessList, ACLExtendedRule, ACLInterfaceAssignment, + ACLStandardRule) __all__ = ( 'AccessListTable', + 'ACLInterfaceAssignmentTable', 'ACLStandardRuleTable', 'ACLExtendedRuleTable', ) +COL_HOST_ASSIGNMENT = """ + {% if record.assigned_object.device %} + {{ record.assigned_object.device|placeholder }} + {% else %} + {{ record.assigned_object.virtual_machine|placeholder }} + {% endif %} + """ + class AccessListTable(NetBoxTable): """ Defines the table view for the AccessList model. """ pk = columns.ToggleColumn() - id = tables.Column( # Provides a link to the secret + id = tables.Column( linkify=True ) assigned_object = tables.Column( @@ -48,6 +59,37 @@ class AccessListTable(NetBoxTable): default_columns = ('name', 'assigned_object', 'type', 'rule_count', 'default_action', 'tags') +class ACLInterfaceAssignmentTable(NetBoxTable): + """ + Defines the table view for the AccessList model. + """ + pk = columns.ToggleColumn() + id = tables.Column( + linkify=True + ) + access_list = tables.Column( + linkify=True + ) + direction = ChoiceFieldColumn() + host = tables.TemplateColumn( + template_code=COL_HOST_ASSIGNMENT + + ) + assigned_object = tables.Column( + linkify=True, + orderable=False, + verbose_name='Assigned Interface' + ) + tags = columns.TagColumn( + url_name='plugins:netbox_access_lists:aclinterfaceassignment_list' + ) + + class Meta(NetBoxTable.Meta): + model = ACLInterfaceAssignment + fields = ('pk', 'id', 'access_list', 'direction', 'host', 'assigned_object', 'tags') + default_columns = ('id', 'access_list', 'direction', 'host', 'assigned_object', 'tags') + + class ACLStandardRuleTable(NetBoxTable): """ Defines the table view for the ACLStandardRule model. diff --git a/netbox_access_lists/template_content.py b/netbox_access_lists/template_content.py index 528f537..0d1eeee 100644 --- a/netbox_access_lists/template_content.py +++ b/netbox_access_lists/template_content.py @@ -2,16 +2,35 @@ from django.contrib.contenttypes.models import ContentType from extras.plugins import PluginTemplateExtension -from .models import AccessList +from .models import AccessList, ACLInterfaceAssignment __all__ = ( 'AccessLists', + "ACLInterfaceAssignments", 'DeviceAccessLists', 'VirtualChassisAccessLists', 'VMAccessLists', + 'DeviceACLInterfaceAssignments', + 'VMAACLInterfaceAssignments', ) +class ACLInterfaceAssignments(PluginTemplateExtension): + + def right_page(self): + obj = self.context['object'] + + acl_interface_assignments = None + ctype = ContentType.objects.get_for_model(obj) + if ctype.model in ['interface', 'vminterface']: + acl_interface_assignments = ACLInterfaceAssignment.objects.filter(assigned_object_id=obj.pk, assigned_object_type=ctype) + + return self.render('inc/assigned_interface/access_lists.html', extra_context={ + 'acl_interface_assignments': acl_interface_assignments, + 'type': ctype.model if ctype.model == 'device' else ctype.name.replace(' ', '_'), + }) + + class AccessLists(PluginTemplateExtension): def right_page(self): @@ -19,11 +38,7 @@ class AccessLists(PluginTemplateExtension): access_lists = None ctype = ContentType.objects.get_for_model(obj) - if ctype.model == 'device': - access_lists = AccessList.objects.filter(assigned_object_id=obj.pk, assigned_object_type=ctype) - elif ctype.model == 'virtualchassis': - access_lists = AccessList.objects.filter(assigned_object_id=obj.pk, assigned_object_type=ctype) - elif ctype.model == 'virtualmachine': + if ctype.model in ['device', 'virtualchassis', 'virtualmachine']: access_lists = AccessList.objects.filter(assigned_object_id=obj.pk, assigned_object_type=ctype) return self.render('inc/assigned_host/access_lists.html', extra_context={ @@ -44,4 +59,12 @@ class VMAccessLists(AccessLists): model = 'virtualization.virtualmachine' -template_extensions = [DeviceAccessLists, VirtualChassisAccessLists, VMAccessLists] +class DeviceACLInterfaceAssignments(ACLInterfaceAssignments): + model = 'dcim.interface' + + +class VMAACLInterfaceAssignments(ACLInterfaceAssignments): + model = 'virtualization.vminterface' + + +template_extensions = [DeviceAccessLists, VirtualChassisAccessLists, VMAccessLists, DeviceACLInterfaceAssignments, VMAACLInterfaceAssignments] diff --git a/netbox_access_lists/templates/inc/assigned_host/assigned_access_lists.html b/netbox_access_lists/templates/inc/assigned_host/assigned_access_lists.html index adb99c4..f7111dc 100644 --- a/netbox_access_lists/templates/inc/assigned_host/assigned_access_lists.html +++ b/netbox_access_lists/templates/inc/assigned_host/assigned_access_lists.html @@ -7,15 +7,15 @@ Default Action Rule Count - {% for access_list in access_lists %} + {% for object in access_lists %} - {{ access_list|linkify }} - {{ access_list.type|title }} - {{ access_list.default_action|title }} - {% if access_list.type == 'standard' %} - {{ access_list.aclstandardrules.count|placeholder }} - {% elif access_list.type == 'extended' %} - {{ access_list.aclextendedrules.count|placeholder }} + {{ object|linkify }} + {{ object.type|title }} + {{ object.default_action|title }} + {% if object.type == 'standard' %} + {{ object.aclstandardrules.count|placeholder }} + {% elif object.type == 'extended' %} + {{ object.aclextendedrules.count|placeholder }} {% endif %} {% endfor %} @@ -24,4 +24,4 @@
None found
-{% endif %} +{% endif %} \ No newline at end of file diff --git a/netbox_access_lists/templates/inc/assigned_interface/access_lists.html b/netbox_access_lists/templates/inc/assigned_interface/access_lists.html new file mode 100644 index 0000000..512c1cf --- /dev/null +++ b/netbox_access_lists/templates/inc/assigned_interface/access_lists.html @@ -0,0 +1,13 @@ +
+
+ Access Lists +
+
+ {% include 'inc/assigned_interface/assigned_access_lists.html' %} +
+ +
diff --git a/netbox_access_lists/templates/inc/assigned_interface/assigned_access_lists.html b/netbox_access_lists/templates/inc/assigned_interface/assigned_access_lists.html new file mode 100644 index 0000000..26bb009 --- /dev/null +++ b/netbox_access_lists/templates/inc/assigned_interface/assigned_access_lists.html @@ -0,0 +1,29 @@ +{% if acl_interface_assignments %} + + + + + + + + + + {% for object in acl_interface_assignments %} + + + + + {% if object.access_list.type == 'standard' %} + + {% elif object.access_list.type == 'extended' %} + + {% endif %} + + + {% endfor %} +
NameTypeDefault ActionRule CountDirection
{{ object.access_list|linkify }}{{ object.access_list.type|title }}{{ object.access_list.default_action|title }}{{ object.access_list.aclstandardrules.count|placeholder }}{{ object.access_list.aclextendedrules.count|placeholder }}{{ object.direction|title }}
+{% else %} +
+ None +
+{% endif %} diff --git a/netbox_access_lists/templates/netbox_access_lists/accesslist_edit.html b/netbox_access_lists/templates/netbox_access_lists/accesslist_edit.html index cc68bb8..64b46cb 100644 --- a/netbox_access_lists/templates/netbox_access_lists/accesslist_edit.html +++ b/netbox_access_lists/templates/netbox_access_lists/accesslist_edit.html @@ -2,7 +2,7 @@ {% load static %} {% load form_helpers %} -{% block title %}{% if obj.pk %}Editing {{ obj }}{% else %}Add a Access List{% endif %}{% endblock %} +{% block title %}{% if obj.pk %}Editing {{ obj }}{% else %}Add an Access List{% endif %}{% endblock %} {% block form %} {% render_errors form %} diff --git a/netbox_access_lists/templates/netbox_access_lists/aclinterfaceassignment.html b/netbox_access_lists/templates/netbox_access_lists/aclinterfaceassignment.html new file mode 100644 index 0000000..984af0a --- /dev/null +++ b/netbox_access_lists/templates/netbox_access_lists/aclinterfaceassignment.html @@ -0,0 +1,81 @@ +{% extends 'generic/object.html' %} +{% load render_table from django_tables2 %} + +{% block breadcrumbs %} + +{% endblock %} +{% block controls %} +
+ {% if perms.netbox_access_lists.change_policy %} + + Edit + + {% endif %} + {% if perms.netbox_access_lists.delete_policy %} + + Delete + + {% endif %} +
+{% endblock controls %} +{% block tabs %} + +{% endblock tabs %} + +{% block content %} +
+
+
+
ACL Interface Assignment
+
+ + + + + + + + + + + + + + + + + +
Host + {% if object.assigned_object.device %} + {{ object.assigned_object.device|placeholder }} + {% else %} + {{ object.assigned_object.virtual_machine|placeholder }} + {% endif %} +
Interface + {{ object.assigned_object }} +
Access List + {{ object.access_list }} +
Direction + {{ object.direction|title }} +
+
+
+ {% include 'inc/panels/custom_fields.html' %} +
+
+ {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} +
+
+{% endblock content %} diff --git a/netbox_access_lists/templates/netbox_access_lists/aclinterfaceassignment_edit.html b/netbox_access_lists/templates/netbox_access_lists/aclinterfaceassignment_edit.html new file mode 100644 index 0000000..4eae6f0 --- /dev/null +++ b/netbox_access_lists/templates/netbox_access_lists/aclinterfaceassignment_edit.html @@ -0,0 +1,68 @@ +{% extends 'generic/object_edit.html' %} +{% load static %} +{% load form_helpers %} + +{% block title %}{% if obj.pk %}Editing {{ obj }}{% else %}Add an Access List to an Interface{% endif %}{% endblock %} + +{% block form %} +{% render_errors form %} +
+

Access List Details

+ {% render_field form.access_list %} + {% render_field form.direction %} + {% render_field form.tags %} +
+
+

Interface Assignment

+ +
+
+ {% render_field form.device %} + {% render_field form.interface %} +
+
+ {% render_field form.virtual_machine %} + {% render_field form.vminterface %} +
+
+
+
+

Comments

+ {% render_field form.comments %} +
+{% if form.custom_fields %} +
+
Custom Fields
+
+ {% render_custom_fields form %} +
+
+{% endif %} +{% endblock %} diff --git a/netbox_access_lists/urls.py b/netbox_access_lists/urls.py index a508cf9..10b6e42 100644 --- a/netbox_access_lists/urls.py +++ b/netbox_access_lists/urls.py @@ -21,7 +21,19 @@ urlpatterns = ( 'model': models.AccessList }), - # Standard Access List rules + # Access List Interface Assignments + path('interface-assignments/', views.ACLInterfaceAssignmentListView.as_view(), name='aclinterfaceassignment_list'), + path('interface-assignments/add/', views.ACLInterfaceAssignmentEditView.as_view(), name='aclinterfaceassignment_add'), + #path('interface-assignments/edit/', views.ACLInterfaceAssignmentBulkEditView.as_view(), name='aclinterfaceassignment_bulk_edit'), + path('interface-assignments/delete/', views.ACLInterfaceAssignmentBulkDeleteView.as_view(), name='aclinterfaceassignment_bulk_delete'), + path('interface-assignments//', views.ACLInterfaceAssignmentView.as_view(), name='aclinterfaceassignment'), + path('interface-assignments//edit/', views.ACLInterfaceAssignmentEditView.as_view(), name='aclinterfaceassignment_edit'), + path('interface-assignments//delete/', views.ACLInterfaceAssignmentDeleteView.as_view(), name='aclinterfaceassignment_delete'), + path('interface-assignments//changelog/', ObjectChangeLogView.as_view(), name='aclinterfaceassignment_changelog', kwargs={ + 'model': models.ACLInterfaceAssignment + }), + + # Standard Access List Rules path('standard-rules/', views.ACLStandardRuleListView.as_view(), name='aclstandardrule_list'), path('standard-rules/add/', views.ACLStandardRuleEditView.as_view(), name='aclstandardrule_add'), path('standard-rules/delete/', views.ACLStandardRuleBulkDeleteView.as_view(), name='aclstandardrule_bulk_delete'), @@ -32,7 +44,7 @@ urlpatterns = ( 'model': models.ACLStandardRule }), - # Extended Access List rules + # Extended Access List Rules path('extended-rules/', views.ACLExtendedRuleListView.as_view(), name='aclextendedrule_list'), path('extended-rules/add/', views.ACLExtendedRuleEditView.as_view(), name='aclextendedrule_add'), path('extended-rules/delete/', views.ACLExtendedRuleBulkDeleteView.as_view(), name='aclextendedrule_bulk_delete'), diff --git a/netbox_access_lists/views.py b/netbox_access_lists/views.py index d08d7c4..cf59763 100644 --- a/netbox_access_lists/views.py +++ b/netbox_access_lists/views.py @@ -14,6 +14,11 @@ __all__ = ( 'AccessListEditView', 'AccessListDeleteView', 'AccessListBulkDeleteView', + 'ACLInterfaceAssignmentView', + 'ACLInterfaceAssignmentListView', + 'ACLInterfaceAssignmentEditView', + 'ACLInterfaceAssignmentDeleteView', + 'ACLInterfaceAssignmentBulkDeleteView', 'ACLStandardRuleView', 'ACLStandardRuleListView', 'ACLStandardRuleEditView', @@ -85,17 +90,48 @@ class AccessListBulkDeleteView(generic.BulkDeleteView): filterset = filtersets.AccessListFilterSet table = tables.AccessListTable +# +# ACLInterfaceAssignment views +# + +class ACLInterfaceAssignmentView(generic.ObjectView): + """ + Defines the view for the ACLInterfaceAssignments django model. + """ + queryset = models.ACLInterfaceAssignment.objects.all() + + +class ACLInterfaceAssignmentListView(generic.ObjectListView): + """ + Defines the list view for the ACLInterfaceAssignments django model. + """ + queryset = models.ACLInterfaceAssignment.objects.all() + table = tables.ACLInterfaceAssignmentTable + filterset = filtersets.ACLInterfaceAssignmentFilterSet + filterset_form = forms.ACLInterfaceAssignmentFilterForm + + +class ACLInterfaceAssignmentEditView(generic.ObjectEditView): + """ + Defines the edit view for the ACLInterfaceAssignments django model. + """ + queryset = models.ACLInterfaceAssignment.objects.all() + form = forms.ACLInterfaceAssignmentForm + template_name = 'netbox_access_lists/aclinterfaceassignment_edit.html' + + +class ACLInterfaceAssignmentDeleteView(generic.ObjectDeleteView): + """ + Defines the delete view for the ACLInterfaceAssignments django model. + """ + queryset = models.ACLInterfaceAssignment.objects.all() + + +class ACLInterfaceAssignmentBulkDeleteView(generic.BulkDeleteView): + queryset = models.ACLInterfaceAssignment.objects.all() + filterset = filtersets.ACLInterfaceAssignmentFilterSet + table = tables.ACLInterfaceAssignmentTable -#class AccessListBulkEditView(generic.BulkEditView): -# """ -# Defines the bulk edit view for the AccessList django model. -# """ -# queryset = models.AccessList.objects.annotate( -# rule_count=Count('aclextendedrules') + Count('aclstandardrules') -# ) -# table = tables.AccessListTable -# filterset = filtersets.AccessListFilterSet -# form = forms.AccessListBulkEditForm # # ACLStandardRule views