diff --git a/netbox_acls/api/serializers.py b/netbox_acls/api/serializers.py index 39cd253a..f7eadcdd 100644 --- a/netbox_acls/api/serializers.py +++ b/netbox_acls/api/serializers.py @@ -5,31 +5,30 @@ from django.contrib.contenttypes.models import ContentType from drf_spectacular.utils import extend_schema_field -from ipam.api.serializers import PrefixSerializer from netbox.api.fields 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, ACL_INTERFACE_ASSIGNMENT_MODELS +from ..constants import ACL_ASSIGNMENT_MODELS, ACL_RULE_SOURCE_DESTINATION_MODELS from ..models import ( AccessList, ACLExtendedRule, - ACLInterfaceAssignment, + ACLAssignment, ACLStandardRule, ) __all__ = [ "AccessListSerializer", - "ACLInterfaceAssignmentSerializer", + "ACLAssignmentSerializer", "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 of remark, but no source is set. +error_message_action_remark_source_set = "Action is set to remark, Source 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 with an ACL of the same type. @@ -45,10 +44,6 @@ class AccessListSerializer(NetBoxModelSerializer): view_name="plugins-api:netbox_acls-api:accesslist-detail", ) rule_count = serializers.IntegerField(read_only=True) - assigned_object_type = ContentTypeField( - queryset=ContentType.objects.filter(ACL_HOST_ASSIGNMENT_MODELS), - ) - assigned_object = serializers.SerializerMethodField(read_only=True) class Meta: """ @@ -61,9 +56,6 @@ class Meta: "url", "display", "name", - "assigned_object_type", - "assigned_object_id", - "assigned_object", "type", "default_action", "comments", @@ -75,14 +67,6 @@ class Meta: ) brief_fields = ("id", "url", "display", "name") - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_assigned_object(self, obj): - if obj.assigned_object is None: - return None - serializer = get_serializer_for_model(obj.assigned_object) - context = {"request": self.context["request"]} - return serializer(obj.assigned_object, nested=True, context=context).data - def validate(self, data): """ Validates api inputs before processing: @@ -103,26 +87,26 @@ def validate(self, data): return super().validate(data) -class ACLInterfaceAssignmentSerializer(NetBoxModelSerializer): +class ACLAssignmentSerializer(NetBoxModelSerializer): """ - Defines the serializer for the django ACLInterfaceAssignment model and associates it with a view. + Defines the serializer for the django ACLAssignment model and associates it with a view. """ url = serializers.HyperlinkedIdentityField( - view_name="plugins-api:netbox_acls-api:aclinterfaceassignment-detail", + view_name="plugins-api:netbox_acls-api:aclassignment-detail", ) access_list = AccessListSerializer(nested=True, required=True) assigned_object_type = ContentTypeField( - queryset=ContentType.objects.filter(ACL_INTERFACE_ASSIGNMENT_MODELS), + queryset=ContentType.objects.filter(ACL_ASSIGNMENT_MODELS), ) assigned_object = serializers.SerializerMethodField(read_only=True) class Meta: """ - Associates the django model ACLInterfaceAssignment & fields to the serializer. + Associates the django model ACLAssignment & fields to the serializer. """ - model = ACLInterfaceAssignment + model = ACLAssignment fields = ( "id", "url", @@ -148,34 +132,6 @@ def get_assigned_object(self, obj): context = {"request": self.context["request"]} return serializer(obj.assigned_object, nested=True, context=context).data - def validate(self, data): - """ - Validate the AccessList django 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 - - 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 - ) - else: - interface_host = None - # 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): """ @@ -186,12 +142,18 @@ class ACLStandardRuleSerializer(NetBoxModelSerializer): view_name="plugins-api:netbox_acls-api:aclstandardrule-detail", ) access_list = AccessListSerializer(nested=True, required=True) - source_prefix = PrefixSerializer( - nested=True, + source_type = ContentTypeField( + queryset=ContentType.objects.filter(ACL_RULE_SOURCE_DESTINATION_MODELS), required=False, + default=None, allow_null=True, + ) + source_id = serializers.IntegerField( + required=False, default=None, + allow_null=True, ) + source = serializers.SerializerMethodField(read_only=True) class Meta: """ @@ -207,20 +169,36 @@ class Meta: "index", "action", "remark", - "source_prefix", + "source_type", + "source_id", + "source", "description", "tags", "created", "custom_fields", "last_updated", ) - brief_fields = ("id", "url", "display", "access_list", "index") + brief_fields = ( + "id", + "url", + "display", + "access_list", + "index", + ) + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_source(self, obj): + if obj.source_id is None: + return None + serializer = get_serializer_for_model(obj.source) + context = {"request": self.context["request"]} + return serializer(obj.source, nested=True, context=context).data def validate(self, data): """ Validate the ACLStandardRule django 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 set. """ error_message = {} @@ -230,10 +208,10 @@ def validate(self, data): 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 the source set. + if data.get("source"): + error_message["source"] = [ + error_message_action_remark_source_set, ] if error_message: @@ -251,18 +229,30 @@ class ACLExtendedRuleSerializer(NetBoxModelSerializer): view_name="plugins-api:netbox_acls-api:aclextendedrule-detail", ) access_list = AccessListSerializer(nested=True, required=True) - source_prefix = PrefixSerializer( - nested=True, + source_type = ContentTypeField( + queryset=ContentType.objects.filter(ACL_RULE_SOURCE_DESTINATION_MODELS), required=False, + default=None, allow_null=True, + ) + source_id = serializers.IntegerField( + required=False, default=None, + allow_null=True, ) - destination_prefix = PrefixSerializer( - nested=True, + source = serializers.SerializerMethodField(read_only=True) + destination_type = ContentTypeField( + queryset=ContentType.objects.filter(ACL_RULE_SOURCE_DESTINATION_MODELS), required=False, + default=None, allow_null=True, + ) + destination_id = serializers.IntegerField( + required=False, default=None, + allow_null=True, ) + destination = serializers.SerializerMethodField(read_only=True) class Meta: """ @@ -279,9 +269,13 @@ class Meta: "action", "remark", "protocol", - "source_prefix", + "source_type", + "source_id", + "source", "source_ports", - "destination_prefix", + "destination_type", + "destination_id", + "destination", "destination_ports", "description", "tags", @@ -289,15 +283,37 @@ class Meta: "custom_fields", "last_updated", ) - brief_fields = ("id", "url", "display", "access_list", "index") + brief_fields = ( + "id", + "url", + "display", + "access_list", + "index", + ) + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_source(self, obj): + if obj.source_id is None: + return None + serializer = get_serializer_for_model(obj.source) + context = {"request": self.context["request"]} + return serializer(obj.source, nested=True, context=context).data + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_destination(self, obj): + if obj.destination_id is None: + return None + serializer = get_serializer_for_model(obj.destination) + context = {"request": self.context["request"]} + return serializer(obj.destination, nested=True, context=context).data def validate(self, data): """ Validate the ACLExtendedRule django 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 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 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. @@ -310,19 +326,19 @@ def validate(self, data): 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 the source set. + if data.get("source"): + error_message["source"] = [ + error_message_action_remark_source_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"] = [ + # Check if action set to remark, but destination set. + if data.get("destination"): + error_message["destination"] = [ "Action is set to remark, Destination Prefix CANNOT be set.", ] # Check if action set to remark, but destination_ports set. diff --git a/netbox_acls/api/urls.py b/netbox_acls/api/urls.py index 3d292f80..40ff58c0 100644 --- a/netbox_acls/api/urls.py +++ b/netbox_acls/api/urls.py @@ -10,7 +10,7 @@ router = NetBoxRouter() router.register("access-lists", views.AccessListViewSet) -router.register("interface-assignments", views.ACLInterfaceAssignmentViewSet) +router.register("assignments", views.ACLAssignmentViewSet) router.register("standard-acl-rules", views.ACLStandardRuleViewSet) router.register("extended-acl-rules", views.ACLExtendedRuleViewSet) diff --git a/netbox_acls/api/views.py b/netbox_acls/api/views.py index cdff48d1..eec43c45 100644 --- a/netbox_acls/api/views.py +++ b/netbox_acls/api/views.py @@ -11,21 +11,21 @@ from .serializers import ( AccessListSerializer, ACLExtendedRuleSerializer, - ACLInterfaceAssignmentSerializer, + ACLAssignmentSerializer, ACLStandardRuleSerializer, ) __all__ = [ "AccessListViewSet", "ACLStandardRuleViewSet", - "ACLInterfaceAssignmentViewSet", + "ACLAssignmentViewSet", "ACLExtendedRuleViewSet", ] class AccessListViewSet(NetBoxModelViewSet): """ - Defines the view set for the django AccessList model & associates it to a view. + Defines the view set for the django AccessList model and associates it with a view. """ queryset = ( @@ -39,28 +39,28 @@ class AccessListViewSet(NetBoxModelViewSet): filterset_class = filtersets.AccessListFilterSet -class ACLInterfaceAssignmentViewSet(NetBoxModelViewSet): +class ACLAssignmentViewSet(NetBoxModelViewSet): """ - Defines the view set for the django ACLInterfaceAssignment model & associates it to a view. + Defines the view set for the django ACLInterfaceAssignment model and associates it with a view. """ - queryset = models.ACLInterfaceAssignment.objects.prefetch_related( + queryset = models.ACLAssignment.objects.prefetch_related( "access_list", "tags", ) - serializer_class = ACLInterfaceAssignmentSerializer - filterset_class = filtersets.ACLInterfaceAssignmentFilterSet + serializer_class = ACLAssignmentSerializer + filterset_class = filtersets.ACLAssignmentFilterSet class ACLStandardRuleViewSet(NetBoxModelViewSet): """ - Defines the view set for the django ACLStandardRule model & associates it to a view. + Defines the view set for the django ACLStandardRule model and associates it with a view. """ queryset = models.ACLStandardRule.objects.prefetch_related( "access_list", + "source", "tags", - "source_prefix", ) serializer_class = ACLStandardRuleSerializer filterset_class = filtersets.ACLStandardRuleFilterSet @@ -68,14 +68,14 @@ class ACLStandardRuleViewSet(NetBoxModelViewSet): class ACLExtendedRuleViewSet(NetBoxModelViewSet): """ - Defines the view set for the django ACLExtendedRule model & associates it to a view. + Defines the view set for the django ACLExtendedRule model and associates it with a view. """ queryset = models.ACLExtendedRule.objects.prefetch_related( "access_list", + "source", + "destination", "tags", - "source_prefix", - "destination_prefix", ) serializer_class = ACLExtendedRuleSerializer filterset_class = filtersets.ACLExtendedRuleFilterSet diff --git a/netbox_acls/choices.py b/netbox_acls/choices.py index 4b1c5b2a..654d9f7d 100644 --- a/netbox_acls/choices.py +++ b/netbox_acls/choices.py @@ -7,6 +7,7 @@ __all__ = ( "ACLActionChoices", "ACLAssignmentDirectionChoices", + "ACLAssignmentDirectionUIChoices", "ACLProtocolChoices", "ACLRuleActionChoices", "ACLTypeChoices", @@ -16,7 +17,7 @@ class ACLActionChoices(ChoiceSet): """ - Defines the choices availble for the Access Lists plugin specific to ACL default_action. + Defines the choices available for the Access Lists plugin specific to ACL default_action. """ ACTION_DENY = "deny" @@ -32,7 +33,7 @@ class ACLActionChoices(ChoiceSet): class ACLRuleActionChoices(ChoiceSet): """ - Defines the choices availble for the Access Lists plugin specific to ACL rule actions. + Defines the choices available for the Access Lists plugin specific to ACL rule actions. """ ACTION_DENY = "deny" @@ -46,23 +47,39 @@ class ACLRuleActionChoices(ChoiceSet): ] +class ACLAssignmentDirectionUIChoices(ChoiceSet): + """ + Defines the application direction of the ACL on an associated interface (UI version). + """ + + DIRECTION_INGRESS = "ingress" + DIRECTION_EGRESS = "egress" + + CHOICES = [ + (DIRECTION_INGRESS, "Ingress", "blue"), + (DIRECTION_EGRESS, "Egress", "purple"), + ] + + class ACLAssignmentDirectionChoices(ChoiceSet): """ - Defines the direction of the application of the ACL on an associated interface. + Defines the application direction of the ACL on an associated interface. """ DIRECTION_INGRESS = "ingress" DIRECTION_EGRESS = "egress" + DIRECTION_NONE = "none" CHOICES = [ (DIRECTION_INGRESS, "Ingress", "blue"), (DIRECTION_EGRESS, "Egress", "purple"), + (DIRECTION_NONE, "N/A", "darkgray"), ] class ACLTypeChoices(ChoiceSet): """ - Defines the choices availble for the Access Lists plugin specific to ACL type. + Defines the choices available for the Access Lists plugin specific to ACL type. """ TYPE_STANDARD = "standard" @@ -76,7 +93,7 @@ class ACLTypeChoices(ChoiceSet): class ACLProtocolChoices(ChoiceSet): """ - Defines the choices availble for the Access Lists plugin specific to ACL Rule protocol. + Defines the choices available for the Access Lists plugin specific to ACL Rule protocol. """ PROTOCOL_ICMP = "icmp" diff --git a/netbox_acls/constants.py b/netbox_acls/constants.py index 468845c2..08759573 100644 --- a/netbox_acls/constants.py +++ b/netbox_acls/constants.py @@ -1,14 +1,43 @@ """ Constants for filters """ + from django.db.models import Q -ACL_HOST_ASSIGNMENT_MODELS = Q( - Q(app_label="dcim", model="device") - | Q(app_label="dcim", model="virtualchassis") - | Q(app_label="virtualization", model="virtualmachine"), +# +# AccessList Assignments +# + +ACL_ASSIGNMENT_MODELS = Q( + Q( + app_label="dcim", + model__in=( + "device", + "interface", + "virtualchassis", + ), + ) + | Q( + app_label="virtualization", + model__in=( + "virtualmachine", + "vminterface", + ), + ) ) -ACL_INTERFACE_ASSIGNMENT_MODELS = Q( - Q(app_label="dcim", model="interface") | Q(app_label="virtualization", model="vminterface"), +# +# AccessList Rule +# + +ACL_RULE_SOURCE_DESTINATION_MODELS = Q( + Q( + app_label="ipam", + model__in=( + "aggregate", + "ipaddress", + "iprange", + "prefix", + ), + ) ) diff --git a/netbox_acls/filtersets.py b/netbox_acls/filtersets.py index 45b860dc..ec3a981d 100644 --- a/netbox_acls/filtersets.py +++ b/netbox_acls/filtersets.py @@ -7,17 +7,18 @@ from dcim.models import Device, Interface, Region, Site, SiteGroup, VirtualChassis from django.db.models import Q from django.utils.translation import gettext_lazy as _ -from ipam.models import Prefix +from ipam.models import Aggregate, IPAddress, IPRange, Prefix from netbox.filtersets import NetBoxModelFilterSet +from utilities.filters import ContentTypeFilter, NumericArrayFilter from virtualization.models import VirtualMachine, VMInterface from .choices import ACLTypeChoices -from .models import AccessList, ACLExtendedRule, ACLInterfaceAssignment, ACLStandardRule +from .models import AccessList, ACLExtendedRule, ACLAssignment, ACLStandardRule __all__ = ( "AccessListFilterSet", "ACLStandardRuleFilterSet", - "ACLInterfaceAssignmentFilterSet", + "ACLAssignmentFilterSet", "ACLExtendedRuleFilterSet", ) @@ -27,6 +28,51 @@ class AccessListFilterSet(NetBoxModelFilterSet): Define the filter set for the django model AccessList. """ + class Meta: + """ + Associates the django model AccessList & fields to the filter set. + """ + + model = AccessList + fields = ( + "id", + "name", + "type", + "default_action", + "comments", + ) + + def search(self, queryset, name, value): + """ + Override the default search behavior for the django model. + """ + query = ( + Q(name__icontains=value) + | Q(type__icontains=value) + | Q(default_action__icontains=value) + | Q(comments__icontains=value) + ) + return queryset.filter(query) + + +class ACLAssignmentFilterSet(NetBoxModelFilterSet): + """ + Define the filter set for the django model ACLAssignment. + """ + + # Access List + access_list = django_filters.ModelMultipleChoiceFilter( + queryset=AccessList.objects.all(), + to_field_name="name", + label=_("Access List (name)"), + ) + access_list_id = django_filters.ModelMultipleChoiceFilter( + queryset=AccessList.objects.all(), + to_field_name="id", + label=_("Access List (ID)"), + ) + + # Organization region = django_filters.ModelMultipleChoiceFilter( field_name="device__site__region", queryset=Region.objects.all(), @@ -45,6 +91,8 @@ class AccessListFilterSet(NetBoxModelFilterSet): to_field_name="id", label="Site", ) + + # Device device = django_filters.ModelMultipleChoiceFilter( field_name="device__name", queryset=Device.objects.all(), @@ -56,6 +104,21 @@ class AccessListFilterSet(NetBoxModelFilterSet): queryset=Device.objects.all(), label="Device (ID)", ) + + # Interface + 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)", + ) + + # Virtual Chassis virtual_chassis = django_filters.ModelMultipleChoiceFilter( field_name="virtual_chassis__name", queryset=VirtualChassis.objects.all(), @@ -67,6 +130,8 @@ class AccessListFilterSet(NetBoxModelFilterSet): queryset=VirtualChassis.objects.all(), label="Virtual Chassis (ID)", ) + + # Virtual Machine virtual_machine = django_filters.ModelMultipleChoiceFilter( field_name="virtual_machine__name", queryset=VirtualMachine.objects.all(), @@ -79,71 +144,7 @@ class AccessListFilterSet(NetBoxModelFilterSet): label="Virtual machine (ID)", ) - class Meta: - """ - Associates the django model AccessList & fields to the filter set. - """ - - model = AccessList - fields = ( - "id", - "name", - "device", - "device_id", - "virtual_chassis", - "virtual_chassis_id", - "virtual_machine", - "virtual_machine_id", - "type", - "default_action", - "comments", - "site", - "site_group", - "region", - ) - - def search(self, queryset, name, value): - """ - Override the default search behavior for the django model. - """ - query = ( - Q(name__icontains=value) - | Q(device__name__icontains=value) - | Q(virtual_chassis__name__icontains=value) - | Q(virtual_machine__name__icontains=value) - | Q(type__icontains=value) - | Q(default_action__icontains=value) - | Q(comments__icontains=value) - ) - return queryset.filter(query) - - -class ACLInterfaceAssignmentFilterSet(NetBoxModelFilterSet): - """ - Define the filter set for the django model ACLInterfaceAssignment. - """ - - access_list = django_filters.ModelMultipleChoiceFilter( - queryset=AccessList.objects.all(), - to_field_name="name", - label=_("Access List (name)"), - ) - access_list_id = django_filters.ModelMultipleChoiceFilter( - queryset=AccessList.objects.all(), - to_field_name="id", - label=_("Access List (ID)"), - ) - 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)", - ) + # Virtual Machine Interface vminterface = django_filters.ModelMultipleChoiceFilter( field_name="vminterface__name", queryset=VMInterface.objects.all(), @@ -161,10 +162,19 @@ class Meta: Associates the django model ACLInterfaceAssignment & fields to the filter set. """ - model = ACLInterfaceAssignment + model = ACLAssignment fields = ( "id", "access_list", + "site", + "site_group", + "region", + "device", + "device_id", + "virtual_chassis", + "virtual_chassis_id", + "virtual_machine", + "virtual_machine_id", "direction", "interface", "interface_id", @@ -181,6 +191,9 @@ def search(self, queryset, name, value): | Q(direction__icontains=value) | Q(interface__name__icontains=value) | Q(vminterface__name__icontains=value) + | Q(device__name__icontains=value) + | Q(virtual_chassis__name__icontains=value) + | Q(virtual_machine__name__icontains=value) ) return queryset.filter(query) @@ -203,14 +216,53 @@ class ACLStandardRuleFilterSet(NetBoxModelFilterSet): ) # Source + source_type = ContentTypeFilter( + label=_("Source Type"), + ) + source_aggregate = django_filters.ModelMultipleChoiceFilter( + field_name="_source_aggregate__prefix", + queryset=Aggregate.objects.all(), + to_field_name="prefix", + label=_("Source Aggregate (name)"), + ) + source_aggregate_id = django_filters.ModelMultipleChoiceFilter( + field_name="_source_aggregate", + queryset=Aggregate.objects.all(), + to_field_name="id", + label=_("Source Aggregate (ID)"), + ) + source_ipaddress = django_filters.ModelMultipleChoiceFilter( + field_name="_source_ipaddress__address", + queryset=IPAddress.objects.all(), + to_field_name="address", + label=_("Source IP-Address (name)"), + ) + source_ipaddress_id = django_filters.ModelMultipleChoiceFilter( + field_name="_source_ipaddress", + queryset=IPAddress.objects.all(), + to_field_name="id", + label=_("Source IP-Address (ID)"), + ) + source_iprange = django_filters.ModelMultipleChoiceFilter( + field_name="_source_iprange__start_address", + queryset=IPRange.objects.all(), + to_field_name="start_address", + label=_("Source IP-Range (name)"), + ) + source_iprange_id = django_filters.ModelMultipleChoiceFilter( + field_name="_source_iprange", + queryset=IPRange.objects.all(), + to_field_name="id", + label=_("Source IP-Range (ID)"), + ) source_prefix = django_filters.ModelMultipleChoiceFilter( - field_name="source_prefix", + field_name="_source_prefix__prefix", queryset=Prefix.objects.all(), - to_field_name="name", + to_field_name="prefix", label=_("Source Prefix (name)"), ) source_prefix_id = django_filters.ModelMultipleChoiceFilter( - field_name="source_prefix", + field_name="_source_prefix", queryset=Prefix.objects.all(), to_field_name="id", label=_("Source Prefix (ID)"), @@ -222,17 +274,21 @@ class Meta: """ model = ACLStandardRule - fields = ("id", "access_list", "index", "action") + fields = ( + "id", + "access_list", + "index", + "action", + "remark", + "source_type", + "source_id", + ) def search(self, queryset, name, value): """ Override the default search behavior for the django model. """ - query = ( - Q(access_list__name__icontains=value) - | Q(index__icontains=value) - | Q(action__icontains=value) - ) + query = Q(access_list__name__icontains=value) | Q(index__icontains=value) | Q(action__icontains=value) return queryset.filter(query) @@ -254,32 +310,120 @@ class ACLExtendedRuleFilterSet(NetBoxModelFilterSet): ) # Source + source_type = ContentTypeFilter( + label=_("Source Type"), + ) + source_aggregate = django_filters.ModelMultipleChoiceFilter( + field_name="_source_aggregate__prefix", + queryset=Aggregate.objects.all(), + to_field_name="prefix", + label=_("Source Aggregate (name)"), + ) + source_aggregate_id = django_filters.ModelMultipleChoiceFilter( + field_name="_source_aggregate", + queryset=Aggregate.objects.all(), + to_field_name="id", + label=_("Source Aggregate (ID)"), + ) + source_ipaddress = django_filters.ModelMultipleChoiceFilter( + field_name="_source_ipaddress__address", + queryset=IPAddress.objects.all(), + to_field_name="address", + label=_("Source IP-Address (name)"), + ) + source_ipaddress_id = django_filters.ModelMultipleChoiceFilter( + field_name="_source_ipaddress", + queryset=IPAddress.objects.all(), + to_field_name="id", + label=_("Source IP-Address (ID)"), + ) + source_iprange = django_filters.ModelMultipleChoiceFilter( + field_name="_source_iprange__start_address", + queryset=IPRange.objects.all(), + to_field_name="start_address", + label=_("Source IP-Range (name)"), + ) + source_iprange_id = django_filters.ModelMultipleChoiceFilter( + field_name="_source_iprange", + queryset=IPRange.objects.all(), + to_field_name="id", + label=_("Source IP-Range (ID)"), + ) source_prefix = django_filters.ModelMultipleChoiceFilter( - field_name="source_prefix", + field_name="_source_prefix__prefix", queryset=Prefix.objects.all(), - to_field_name="name", + to_field_name="prefix", label=_("Source Prefix (name)"), ) source_prefix_id = django_filters.ModelMultipleChoiceFilter( - field_name="source_prefix", + field_name="_source_prefix", queryset=Prefix.objects.all(), to_field_name="id", label=_("Source Prefix (ID)"), ) + source_port = NumericArrayFilter( + field_name="source_ports", + lookup_expr="contains", + label=_("Source Port"), + ) # Destination + destination_type = ContentTypeFilter( + label=_("Destination Type"), + ) + destination_aggregate = django_filters.ModelMultipleChoiceFilter( + field_name="_destination_aggregate__prefix", + queryset=Aggregate.objects.all(), + to_field_name="prefix", + label=_("Destination Aggregate (name)"), + ) + destination_aggregate_id = django_filters.ModelMultipleChoiceFilter( + field_name="_destination_aggregate", + queryset=Aggregate.objects.all(), + to_field_name="id", + label=_("Destination Aggregate (ID)"), + ) + destination_ipaddress = django_filters.ModelMultipleChoiceFilter( + field_name="_destination_ipaddress__address", + queryset=IPAddress.objects.all(), + to_field_name="address", + label=_("Destination IP-Address (name)"), + ) + destination_ipaddress_id = django_filters.ModelMultipleChoiceFilter( + field_name="_destination_ipaddress", + queryset=IPAddress.objects.all(), + to_field_name="id", + label=_("Destination IP-Address (ID)"), + ) + destination_iprange = django_filters.ModelMultipleChoiceFilter( + field_name="_destination_iprange__start_address", + queryset=IPRange.objects.all(), + to_field_name="start_address", + label=_("Destination IP-Range (name)"), + ) + destination_iprange_id = django_filters.ModelMultipleChoiceFilter( + field_name="_destination_iprange", + queryset=IPRange.objects.all(), + to_field_name="id", + label=_("Destination IP-Range (ID)"), + ) destination_prefix = django_filters.ModelMultipleChoiceFilter( - field_name="destination_prefix", + field_name="_destination_prefix__prefix", queryset=Prefix.objects.all(), - to_field_name="name", + to_field_name="prefix", label=_("Destination Prefix (name)"), ) destination_prefix_id = django_filters.ModelMultipleChoiceFilter( - field_name="destination_prefix", + field_name="_destination_prefix", queryset=Prefix.objects.all(), to_field_name="id", label=_("Destination Prefix (ID)"), ) + destination_port = NumericArrayFilter( + field_name="destination_ports", + lookup_expr="contains", + label=_("Destination Port"), + ) class Meta: """ @@ -287,7 +431,20 @@ class Meta: """ model = ACLExtendedRule - fields = ("id", "access_list", "index", "action", "protocol") + fields = ( + "id", + "access_list", + "index", + "action", + "remark", + "source_type", + "source_id", + "source_port", + "destination_type", + "destination_id", + "destination_port", + "protocol", + ) def search(self, queryset, name, value): """ diff --git a/netbox_acls/forms/filtersets.py b/netbox_acls/forms/filtersets.py index 6998144a..2a7a21ee 100644 --- a/netbox_acls/forms/filtersets.py +++ b/netbox_acls/forms/filtersets.py @@ -5,7 +5,7 @@ from dcim.models import Device, Interface, Region, Site, SiteGroup, VirtualChassis from django import forms from django.utils.translation import gettext_lazy as _ -from ipam.models import Prefix +from ipam.models import Aggregate, IPAddress, IPRange, Prefix from netbox.forms import NetBoxModelFilterSetForm from utilities.forms.fields import ( DynamicModelChoiceField, @@ -26,13 +26,13 @@ from ..models import ( AccessList, ACLExtendedRule, - ACLInterfaceAssignment, + ACLAssignment, ACLStandardRule, ) __all__ = ( "AccessListFilterForm", - "ACLInterfaceAssignmentFilterForm", + "ACLAssignmentFilterForm", "ACLStandardRuleFilterForm", "ACLExtendedRuleFilterForm", ) @@ -45,17 +45,23 @@ class AccessListFilterForm(NetBoxModelFilterSetForm): model = AccessList fieldsets = ( - FieldSet("q", "tag", name=None), - FieldSet("type", "default_action", name=_("ACL Details")), - FieldSet("region_id", "site_group_id", "site_id", "device_id", name=_("Device Details")), - FieldSet("virtual_chassis_id", name=_("Virtual Chassis Details")), - FieldSet("virtual_machine_id", name=_("Virtual Machine Details")), + FieldSet( + "q", + "tag", + name=None, + ), + FieldSet( + "type", + "default_action", + name=_("ACL Details"), + ), ) - # ACL + # ACL selector type = forms.ChoiceField( choices=add_blank_choice(ACLTypeChoices), required=False, + label=_("Type"), ) default_action = forms.ChoiceField( choices=add_blank_choice(ACLActionChoices), @@ -63,66 +69,44 @@ class AccessListFilterForm(NetBoxModelFilterSetForm): label=_("Default Action"), ) - # Device selector - region_id = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - label=_("Region"), - ) - site_group_id = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - label=_("Site Group"), - ) - site_id = DynamicModelChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={ - "region_id": "$region_id", - "group_id": "$site_group_id", - }, - label=_("Site"), - ) - device_id = DynamicModelChoiceField( - queryset=Device.objects.all(), - query_params={ - "region_id": "$region_id", - "group_id": "$site_group_id", - "site_id": "$site_id", - }, - required=False, - label=_("Device"), - ) - - # Virtual Chassis selector - virtual_chassis_id = DynamicModelChoiceField( - queryset=VirtualChassis.objects.all(), - required=False, - label=_("Virtual Chassis"), - ) - - # Virtual Machine selector - virtual_machine_id = DynamicModelChoiceField( - queryset=VirtualMachine.objects.all(), - required=False, - label=_("Virtual Machine"), - ) - # Tag selector tag = TagFilterField(model) -class ACLInterfaceAssignmentFilterForm(NetBoxModelFilterSetForm): +class ACLAssignmentFilterForm(NetBoxModelFilterSetForm): """ - GUI filter form to search the django AccessList model. + GUI filter form to search the django ACLAssignment model. """ - model = ACLInterfaceAssignment + model = ACLAssignment fieldsets = ( - FieldSet("q", "tag", name=None), - FieldSet("access_list_id", "direction", name=_("ACL Details")), - FieldSet("region_id", "site_group_id", "site_id", "device_id", "interface_id", name=_("Device Details")), - FieldSet("virtual_machine_id", "vminterface_id", name=_("Virtual Machine Details")), + FieldSet( + "q", + "tag", + name=None, + ), + FieldSet( + "access_list_id", + "direction", + name=_("ACL Details"), + ), + FieldSet( + "region_id", + "site_group_id", + "site_id", + "device_id", + "interface_id", + name=_("Device Details"), + ), + FieldSet( + "virtual_chassis_id", + name=_("Virtual Chassis Details"), + ), + FieldSet( + "virtual_machine_id", + "vminterface_id", + name=_("Virtual Machine Details"), + ), ) # ACL selector @@ -137,7 +121,7 @@ class ACLInterfaceAssignmentFilterForm(NetBoxModelFilterSetForm): label=_("Direction"), ) - # Device Interface selector + # Device and Interface selectors region_id = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -176,7 +160,14 @@ class ACLInterfaceAssignmentFilterForm(NetBoxModelFilterSetForm): label=_("Device Interface"), ) - # Virtual Machine Interface selector + # Virtual Chassis selector + virtual_chassis_id = DynamicModelChoiceField( + queryset=VirtualChassis.objects.all(), + required=False, + label=_("Virtual Chassis"), + ) + + # Virtual Machine and VM Interface selectors virtual_machine_id = DynamicModelChoiceField( queryset=VirtualMachine.objects.all(), required=False, @@ -202,9 +193,25 @@ class ACLStandardRuleFilterForm(NetBoxModelFilterSetForm): model = ACLStandardRule fieldsets = ( - FieldSet("q", "tag", name=None), - FieldSet("access_list_id", "index", "action", name=_("ACL Details")), - FieldSet("source_prefix_id", name=_("Source Details")), + FieldSet( + "q", + "tag", + name=None, + ), + FieldSet( + "access_list_id", + "index", + "action", + "remark", + name=_("ACL Details"), + ), + FieldSet( + "source_aggregate_id", + "source_ipaddress_id", + "source_iprange_id", + "source_prefix_id", + name=_("Source Details"), + ), ) access_list_id = DynamicModelMultipleChoiceField( @@ -224,8 +231,27 @@ class ACLStandardRuleFilterForm(NetBoxModelFilterSetForm): required=False, label=_("Action"), ) + remark = forms.CharField( + required=False, + label=_("Remark"), + ) # Source selectors + source_aggregate_id = DynamicModelMultipleChoiceField( + queryset=Aggregate.objects.all(), + required=False, + label=_("Source Aggregate"), + ) + source_ipaddress_id = DynamicModelMultipleChoiceField( + queryset=IPAddress.objects.all(), + required=False, + label=_("Source IP-Address"), + ) + source_iprange_id = DynamicModelMultipleChoiceField( + queryset=IPRange.objects.all(), + required=False, + label=_("Source IP-Range"), + ) source_prefix_id = DynamicModelMultipleChoiceField( queryset=Prefix.objects.all(), required=False, @@ -243,10 +269,35 @@ class ACLExtendedRuleFilterForm(NetBoxModelFilterSetForm): model = ACLExtendedRule fieldsets = ( - FieldSet("q", "tag", name=None), - FieldSet("access_list_id", "index", "action", "protocol", name=_("ACL Details")), - FieldSet("source_prefix_id", name=_("Source Details")), - FieldSet("destination_prefix_id", name=_("Destination Details")), + FieldSet( + "q", + "tag", + name=None, + ), + FieldSet( + "access_list_id", + "index", + "action", + "remark", + "protocol", + name=_("ACL Details"), + ), + FieldSet( + "source_aggregate_id", + "source_ipaddress_id", + "source_iprange_id", + "source_prefix_id", + "source_port", + name=_("Source Details"), + ), + FieldSet( + "destination_aggregate_id", + "destination_ipaddress_id", + "destination_iprange_id", + "destination_prefix_id", + "destination_port", + name=_("Destination Details"), + ), ) access_list_id = DynamicModelMultipleChoiceField( @@ -266,6 +317,10 @@ class ACLExtendedRuleFilterForm(NetBoxModelFilterSetForm): required=False, label=_("Action"), ) + remark = forms.CharField( + required=False, + label=_("Remark"), + ) protocol = forms.ChoiceField( choices=add_blank_choice(ACLProtocolChoices), required=False, @@ -273,18 +328,56 @@ class ACLExtendedRuleFilterForm(NetBoxModelFilterSetForm): ) # Source selectors + source_aggregate_id = DynamicModelMultipleChoiceField( + queryset=Aggregate.objects.all(), + required=False, + label=_("Source Aggregate"), + ) + source_ipaddress_id = DynamicModelMultipleChoiceField( + queryset=IPAddress.objects.all(), + required=False, + label=_("Source IP-Address"), + ) + source_iprange_id = DynamicModelMultipleChoiceField( + queryset=IPRange.objects.all(), + required=False, + label=_("Source IP-Range"), + ) source_prefix_id = DynamicModelMultipleChoiceField( queryset=Prefix.objects.all(), required=False, label=_("Source Prefix"), ) + source_port = forms.IntegerField( + label=_("Source Port"), + required=False, + ) # Destination selectors + destination_aggregate_id = DynamicModelMultipleChoiceField( + queryset=Aggregate.objects.all(), + required=False, + label=_("Destination Aggregate"), + ) + destination_ipaddress_id = DynamicModelMultipleChoiceField( + queryset=IPAddress.objects.all(), + required=False, + label=_("Destination IP-Address"), + ) + destination_iprange_id = DynamicModelMultipleChoiceField( + queryset=IPRange.objects.all(), + required=False, + label=_("Destination IP-Range"), + ) destination_prefix_id = DynamicModelMultipleChoiceField( queryset=Prefix.objects.all(), required=False, label=_("Destination Prefix"), ) + destination_port = forms.IntegerField( + label=_("Destination Port"), + required=False, + ) # Tag selector tag = TagFilterField(model) diff --git a/netbox_acls/forms/models.py b/netbox_acls/forms/models.py index 42450a70..9e434eb6 100644 --- a/netbox_acls/forms/models.py +++ b/netbox_acls/forms/models.py @@ -2,34 +2,40 @@ Defines each django model's GUI form to add or edit objects for each django model. """ -from dcim.models import Device, Interface, Region, Site, SiteGroup, VirtualChassis +from django import forms +from dcim.models import Device, Interface from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ValidationError +from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from ipam.models import Prefix from netbox.forms import NetBoxModelForm -from utilities.forms.fields import CommentField, DynamicModelChoiceField -from utilities.forms.rendering import FieldSet, TabbedGroups -from virtualization.models import ( - Cluster, - ClusterGroup, - ClusterType, - VirtualMachine, - VMInterface, +from utilities.forms import ( + get_field_value, + add_blank_choice, +) +from utilities.forms.fields import CommentField, DynamicModelChoiceField, ContentTypeChoiceField +from utilities.forms.rendering import FieldSet +from utilities.forms.widgets import HTMXSelect +from utilities.templatetags.builtins.filters import bettertitle +from virtualization.models import VMInterface + +from ..constants import ACL_ASSIGNMENT_MODELS, ACL_RULE_SOURCE_DESTINATION_MODELS +from ..choices import ( + ACLAssignmentDirectionChoices, + ACLAssignmentDirectionUIChoices, + ACLTypeChoices, ) - -from ..choices import ACLTypeChoices from ..models import ( AccessList, ACLExtendedRule, - ACLInterfaceAssignment, + ACLAssignment, ACLStandardRule, ) __all__ = ( "AccessListForm", - "ACLInterfaceAssignmentForm", + "ACLAssignmentForm", "ACLStandardRuleForm", "ACLExtendedRuleForm", ) @@ -50,67 +56,6 @@ class AccessListForm(NetBoxModelForm): Requires a device, a name, a type, and a default_action. """ - # Device selector - region = DynamicModelChoiceField( - queryset=Region.objects.all(), - required=False, - initial_params={ - "sites": "$site", - }, - ) - site_group = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - label="Site Group", - initial_params={"sites": "$site"}, - ) - site = DynamicModelChoiceField( - queryset=Site.objects.all(), - required=False, - query_params={"region_id": "$region", "group_id": "$site_group"}, - ) - device = DynamicModelChoiceField( - queryset=Device.objects.all(), - required=False, - query_params={ - "region_id": "$region", - "group_id": "$site_group", - "site_id": "$site", - }, - ) - - # Virtual Chassis selector - virtual_chassis = DynamicModelChoiceField( - queryset=VirtualChassis.objects.all(), - required=False, - label="Virtual Chassis", - ) - - # Virtual Machine selector - cluster_type = DynamicModelChoiceField( - queryset=ClusterType.objects.all(), - required=False, - ) - cluster_group = DynamicModelChoiceField( - queryset=ClusterGroup.objects.all(), - required=False, - query_params={"type_id": "$cluster_type"}, - ) - cluster = DynamicModelChoiceField( - queryset=Cluster.objects.all(), - required=False, - query_params={"type_id": "$cluster_type", "group_id": "$cluster_group"}, - ) - - virtual_machine = DynamicModelChoiceField( - queryset=VirtualMachine.objects.all(), - required=False, - query_params={ - "cluster_id": "$cluster", - "cluster_type_id": "$cluster_type", - "cluster_group_id": "$cluster_group", - }, - ) comments = CommentField() fieldsets = ( @@ -121,40 +66,11 @@ class AccessListForm(NetBoxModelForm): "tags", name=_("Access List Details"), ), - FieldSet( - TabbedGroups( - FieldSet( - "region", - "site_group", - "site", - "device", - name=_("Device"), - ), - FieldSet( - "virtual_chassis", - name=_("Virtual Chassis"), - ), - FieldSet( - "cluster_type", - "cluster_group", - "cluster", - "virtual_machine", - name=_("Virtual Machine"), - ), - ), - name=_("Host Assignment"), - ), ) class Meta: model = AccessList fields = ( - "region", - "site_group", - "site", - "device", - "virtual_machine", - "virtual_chassis", "name", "type", "default_action", @@ -163,320 +79,147 @@ class Meta: ) help_texts = { - "default_action": "The default behavior of the ACL.", - "name": "The name uniqueness per device is case insensitive.", + "default_action": _("The default behavior of the ACL."), + "name": _("The name uniqueness per device is case insensitive."), "type": mark_safe( - "*Note: CANNOT be changed if ACL Rules are associated to this Access List.", + _("*Note: CANNOT be changed if ACL Rules are associated to this Access List."), ), } - def __init__(self, *args, **kwargs): - # Initialize helper selectors - instance = kwargs.get("instance") - initial = kwargs.get("initial", {}).copy() - if instance: - if isinstance(instance.assigned_object, Device): - initial["device"] = instance.assigned_object - if instance.assigned_object.site: - initial["site"] = instance.assigned_object.site - if instance.assigned_object.site.group: - initial["site_group"] = instance.assigned_object.site.group - - if instance.assigned_object.site.region: - initial["region"] = instance.assigned_object.site.region - elif isinstance(instance.assigned_object, VirtualMachine): - initial["virtual_machine"] = instance.assigned_object - if instance.assigned_object.cluster: - initial["cluster"] = instance.assigned_object.cluster - if instance.assigned_object.cluster.group: - initial["cluster_group"] = instance.assigned_object.cluster.group - - if instance.assigned_object.cluster.type: - initial["cluster_type"] = instance.assigned_object.cluster.type - elif isinstance(instance.assigned_object, VirtualChassis): - initial["virtual_chassis"] = instance.assigned_object - - kwargs["initial"] = initial - super().__init__(*args, **kwargs) - def clean(self): """ - 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. + Validates and cleans the input data for the current object. + + Ensures that the type of the Access Control List (ACL) cannot be + altered if there are existing rules associated with the current ACL. """ super().clean() - if self.errors.get("name"): - return - - name = self.cleaned_data.get("name") acl_type = self.cleaned_data.get("type") - device = self.cleaned_data.get("device") - virtual_chassis = self.cleaned_data.get("virtual_chassis") - virtual_machine = self.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 ValidationError( - { - "__all__": ( - "Access Lists must be assigned to one host at a time. Either a device, virtual chassis or " - "virtual machine." - ) - }, - ) - - # Check if no hosts selected. - if not device and not virtual_chassis and not virtual_machine: - raise ValidationError( - {"__all__": "Access Lists must be assigned to a device, virtual chassis or virtual machine."} - ) - - existing_acls = None - host_type = None - if device: - host_type = "device" - existing_acls = AccessList.objects.filter(name=name, device=device).exists() - elif virtual_machine: - host_type = "virtual_machine" - existing_acls = AccessList.objects.filter(name=name, virtual_machine=virtual_machine).exists() - elif virtual_chassis: - host_type = "virtual_chassis" - existing_acls = AccessList.objects.filter(name=name, virtual_chassis=virtual_chassis).exists() - - # 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." - raise ValidationError({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. + + # Check if Access List has no existing rules before change the + # Access List's type. if self.instance.pk and ( (acl_type == ACLTypeChoices.TYPE_EXTENDED and self.instance.aclstandardrules.exists()) or (acl_type == ACLTypeChoices.TYPE_STANDARD and self.instance.aclextendedrules.exists()) ): - raise ValidationError({"type": ["This ACL has ACL rules associated, CANNOT change ACL type."]}) - - def save(self, *args, **kwargs): - # Set assigned object - self.instance.assigned_object = ( - self.cleaned_data.get("device") - or self.cleaned_data.get("virtual_chassis") - or self.cleaned_data.get("virtual_machine") - ) + raise ValidationError({"type": _("This ACL has ACL rules associated, CANNOT change ACL type.")}) - return super().save(*args, **kwargs) - -class ACLInterfaceAssignmentForm(NetBoxModelForm): +class ACLAssignmentForm(NetBoxModelForm): """ - GUI form to add or edit ACL Host Object assignments + GUI form to add or edit ACL 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, - # query_params={ - # Need to pass ACL device to it - # }, - 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.", ), ) + assigned_object_type = ContentTypeChoiceField( + queryset=ContentType.objects.filter(ACL_ASSIGNMENT_MODELS), + widget=HTMXSelect(), + label=_("Assignment Object Type"), + ) + assigned_object = DynamicModelChoiceField( + queryset=Device.objects.none(), # Initial queryset + selector=True, + label=_("Assignment Object"), + disabled=True, + ) + direction = forms.ChoiceField( + choices=add_blank_choice(ACLAssignmentDirectionChoices), + required=False, + label=_("Direction"), + help_text=_( + "The ACL assignment direction field is only enabled for " + "Device Interface or Virtual Machine Interface objects. " + "For other types (such as Device, Virtual Chassis, or Virtual Machine), " + "this field is disabled. " + "*Note: CANNOT assign 2 ACLs to the same interface & direction." + ), + disabled=True, + ) comments = CommentField() fieldsets = ( FieldSet( "access_list", - "direction", "tags", name=_("Access List Details"), ), FieldSet( - TabbedGroups( - FieldSet( - "device", - "interface", - name=_("Device"), - ), - FieldSet( - "virtual_machine", - "vminterface", - name=_("Virtual Machine"), - ), - ), - name=_("Interface Assignment"), + "assigned_object_type", + "assigned_object", + "direction", + name=_("Assignment"), ), ) - def __init__(self, *args, **kwargs): - # Initialize helper selectors + def __init__(self, *args, **kwargs) -> None: + """ + Initialize the ACL Assignment form. + """ + + # Initialize fields with initial values 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" + initial["direction"] = ACLAssignmentDirectionChoices.DIRECTION_NONE + + if instance is not None and instance.assigned_object: + # Initialize the assigned object field + initial["assigned_object"] = instance.assigned_object + initial["direction"] = instance.direction + kwargs["initial"] = initial super().__init__(*args, **kwargs) + if assigned_object_type_id := get_field_value(self, "assigned_object_type"): + try: + # Retrieve the ContentType model class based on the assigned object type + assigned_object_type = ContentType.objects.get(pk=assigned_object_type_id) + assigned_object_model = assigned_object_type.model_class() + + # Configure the queryset and label for the assigned_object field + self.fields["assigned_object"].queryset = assigned_object_model.objects.all() + self.fields["assigned_object"].widget.attrs["selector"] = assigned_object_model._meta.label_lower + self.fields["assigned_object"].disabled = False + self.fields["assigned_object"].label = _(bettertitle(assigned_object_model._meta.verbose_name)) + if assigned_object_model == Interface or assigned_object_model == VMInterface: + self.fields["direction"].disabled = False + self.fields["direction"].required = True + self.fields["direction"].choices = add_blank_choice(ACLAssignmentDirectionUIChoices) + else: + self.fields["direction"].disabled = True + self.fields["direction"].widget.attrs["value"] = "None" + except ObjectDoesNotExist: + pass + + # Clears the assigned_object field if the selected type changes + if self.instance and self.instance.pk and assigned_object_type_id != self.instance.assigned_object_type_id: + self.initial["assigned_object"] = None + + def clean(self) -> None: + """Validate form fields for the ACL Assignment form.""" + super().clean() + + # Ensure the selected object gets assigned + self.instance.assigned_object = self.cleaned_data.get("assigned_object") + class Meta: - model = ACLInterfaceAssignment + model = ACLAssignment fields = ( "access_list", + "assigned_object_type", "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 nor 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. - """ - super().clean() - - error_message = {} - access_list = self.cleaned_data.get("access_list") - direction = self.cleaned_data.get("direction") - interface = self.cleaned_data.get("interface") - vminterface = self.cleaned_data.get("vminterface") - - # 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_message |= { - "interface": [error_too_many_interfaces], - "vminterface": [error_too_many_interfaces], - } - 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], - } - else: - # Define assigned_object, assigned_object_type, host_type, and host based on interface or vminterface - if interface: - assigned_object = interface - assigned_object_type = "interface" - host_type = "device" - host = Interface.objects.get(pk=assigned_object.pk).device - else: - assigned_object = vminterface - assigned_object_type = "vminterface" - host_type = "virtual_machine" - host = VMInterface.objects.get(pk=assigned_object.pk).virtual_machine - - assigned_object_id = assigned_object.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 that an interface's parent device/virtual_machine is assigned to the Access List. - if 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 and existing ACL in the direction. - existing_acl = ACLInterfaceAssignment.objects.filter( - access_list=access_list, - assigned_object_id=assigned_object_id, - assigned_object_type=assigned_object_type_id, - direction=direction, - ) - if existing_acl.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], - } - - if 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 ValidationError(error_message) - - 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): """ @@ -495,11 +238,22 @@ class ACLStandardRuleForm(NetBoxModelForm): ), label="Access List", ) - source_prefix = DynamicModelChoiceField( - queryset=Prefix.objects.all(), + + # Source + source_type = ContentTypeChoiceField( + queryset=ContentType.objects.filter(ACL_RULE_SOURCE_DESTINATION_MODELS), required=False, + widget=HTMXSelect(), + label=_("Source Type"), help_text=help_text_acl_rule_logic, - label="Source Prefix", + ) + source = DynamicModelChoiceField( + queryset=Prefix.objects.none(), # Initial queryset + selector=True, + required=False, + label=_("Source"), + help_text=help_text_acl_rule_logic, + disabled=True, ) fieldsets = ( @@ -512,10 +266,17 @@ class ACLStandardRuleForm(NetBoxModelForm): FieldSet( "index", "action", - "remark", - "source_prefix", name=_("Rule Definition"), ), + FieldSet( + "remark", + name=_("Remark"), + ), + FieldSet( + "source_type", + "source", + name=_("Source Definition"), + ), ) class Meta: @@ -525,7 +286,7 @@ class Meta: "index", "action", "remark", - "source_prefix", + "source_type", "tags", "description", ) @@ -534,10 +295,54 @@ class Meta: "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.", + "*Note: CANNOT be set if source OR action is set.", ), } + def __init__(self, *args, **kwargs) -> None: + """ + Initialize the ACLStandardRuleForm. + """ + + # Initialize fields with initial values + instance = kwargs.get("instance") + initial = kwargs.get("initial", {}).copy() + + if instance is not None and instance.source: + # Initialize the source object field + initial["source"] = instance.source + + kwargs["initial"] = initial + + super().__init__(*args, **kwargs) + + if source_type_id := get_field_value(self, "source_type"): + try: + # Retrieve the ContentType model class based on the source type + source_type = ContentType.objects.get(pk=source_type_id) + source_model = source_type.model_class() + + # Configure the queryset and label for the source field + self.fields["source"].queryset = source_model.objects.all() + self.fields["source"].widget.attrs["selector"] = source_model._meta.label_lower + self.fields["source"].disabled = False + self.fields["source"].label = _("Source " + bettertitle(source_model._meta.verbose_name)) + except ObjectDoesNotExist: + pass + + # Clears the source field if the selected type changes + if self.instance and self.instance.pk and source_type_id != self.instance.source_type_id: + self.initial["source"] = None + + def clean(self): + """ + Validate form fields for the ACL Standard Rule form. + """ + super().clean() + + # Ensure the selected source object gets assigned + self.instance.source = self.cleaned_data.get("source") + class ACLExtendedRuleForm(NetBoxModelForm): """ @@ -557,18 +362,40 @@ class ACLExtendedRuleForm(NetBoxModelForm): label="Access List", ) - source_prefix = DynamicModelChoiceField( - queryset=Prefix.objects.all(), + # Source + source_type = ContentTypeChoiceField( + queryset=ContentType.objects.filter(ACL_RULE_SOURCE_DESTINATION_MODELS), + required=False, + widget=HTMXSelect(), + label=_("Source Type"), + help_text=help_text_acl_rule_logic, + ) + source = DynamicModelChoiceField( + queryset=Prefix.objects.none(), # Initial queryset + selector=True, + required=False, + label=_("Source"), + help_text=help_text_acl_rule_logic, + disabled=True, + ) + + # Destination + destination_type = ContentTypeChoiceField( + queryset=ContentType.objects.filter(ACL_RULE_SOURCE_DESTINATION_MODELS), required=False, + widget=HTMXSelect(), + label=_("Destination Type"), help_text=help_text_acl_rule_logic, - label="Source Prefix", ) - destination_prefix = DynamicModelChoiceField( - queryset=Prefix.objects.all(), + destination = DynamicModelChoiceField( + queryset=Prefix.objects.none(), # Initial queryset + selector=True, required=False, + label=_("Destination"), help_text=help_text_acl_rule_logic, - label="Destination Prefix", + disabled=True, ) + fieldsets = ( FieldSet( "access_list", @@ -579,13 +406,27 @@ class ACLExtendedRuleForm(NetBoxModelForm): FieldSet( "index", "action", + name=_("Rule Definition"), + ), + FieldSet( "remark", - "source_prefix", + name=_("Remark"), + ), + FieldSet( + "protocol", + name=_("Protocol"), + ), + FieldSet( + "source_type", + "source", "source_ports", - "destination_prefix", + name=_("Source Definition"), + ), + FieldSet( + "destination_type", + "destination", "destination_ports", - "protocol", - name=_("Rule Definition"), + name=_("Destination Definition"), ), ) @@ -596,9 +437,9 @@ class Meta: "index", "action", "remark", - "source_prefix", + "source_type", "source_ports", - "destination_prefix", + "destination_type", "destination_ports", "protocol", "tags", @@ -615,3 +456,73 @@ class Meta: ), "source_ports": help_text_acl_rule_logic, } + + def __init__(self, *args, **kwargs) -> None: + """ + Initialize the ACLExtendedRuleForm. + """ + + # Initialize fields with initial values + instance = kwargs.get("instance") + initial = kwargs.get("initial", {}).copy() + + if instance is not None and instance.source: + # Initialize the source object field + initial["source"] = instance.source + if instance is not None and instance.destination: + # Initialize the destination object field + initial["destination"] = instance.destination + + kwargs["initial"] = initial + + super().__init__(*args, **kwargs) + + # Source + if source_type_id := get_field_value(self, "source_type"): + try: + # Retrieve the ContentType model class based on the source type + source_type = ContentType.objects.get(pk=source_type_id) + source_model = source_type.model_class() + + # Configure the queryset and label for the source field + self.fields["source"].queryset = source_model.objects.all() + self.fields["source"].widget.attrs["selector"] = source_model._meta.label_lower + self.fields["source"].disabled = False + self.fields["source"].label = _("Source " + bettertitle(source_model._meta.verbose_name)) + except ObjectDoesNotExist: + pass + + # Clears the source field if the selected type changes + if self.instance and self.instance.pk and source_type_id != self.instance.source_type_id: + self.initial["source"] = None + + # Destination + if destination_type_id := get_field_value(self, "destination_type"): + try: + # Retrieve the ContentType model class based on the destination type + destination_type = ContentType.objects.get(pk=destination_type_id) + destination_model = destination_type.model_class() + + # Configure the queryset and label for the destination field + self.fields["destination"].queryset = destination_model.objects.all() + self.fields["destination"].widget.attrs["selector"] = destination_model._meta.label_lower + self.fields["destination"].disabled = False + self.fields["destination"].label = _("Destination " + bettertitle(destination_model._meta.verbose_name)) + except ObjectDoesNotExist: + pass + + # Clears the destination field if the selected type changes + if self.instance and self.instance.pk and destination_type_id != self.instance.destination_type_id: + self.initial["destination"] = None + + def clean(self): + """ + Validate form fields for the ACL Extended Rule form. + """ + super().clean() + + # Ensure the selected source object gets assigned + self.instance.source = self.cleaned_data.get("source") + + # Ensure the selected destination object gets assigned + self.instance.destination = self.cleaned_data.get("destination") diff --git a/netbox_acls/graphql/filters/__init__.py b/netbox_acls/graphql/filters/__init__.py index fe0a8281..8873eda9 100644 --- a/netbox_acls/graphql/filters/__init__.py +++ b/netbox_acls/graphql/filters/__init__.py @@ -1,9 +1,9 @@ from .access_list_rules import ACLExtendedRuleFilter, ACLStandardRuleFilter -from .access_lists import AccessListFilter, ACLInterfaceAssignmentFilter +from .access_lists import AccessListFilter, ACLAssignmentFilter __all__ = ( "AccessListFilter", "ACLExtendedRuleFilter", - "ACLInterfaceAssignmentFilter", + "ACLAssignmentFilter", "ACLStandardRuleFilter", ) diff --git a/netbox_acls/graphql/filters/access_list_rules.py b/netbox_acls/graphql/filters/access_list_rules.py index be1e0b2d..50be41db 100644 --- a/netbox_acls/graphql/filters/access_list_rules.py +++ b/netbox_acls/graphql/filters/access_list_rules.py @@ -3,6 +3,7 @@ import strawberry import strawberry_django +from core.graphql.filters import ContentTypeFilter from netbox.graphql.filter_mixins import NetBoxModelFilterMixin from strawberry.scalars import ID from strawberry_django import FilterLookup @@ -10,7 +11,6 @@ from ... import models if TYPE_CHECKING: - from ipam.graphql.filters import PrefixFilter from netbox.graphql.filter_lookups import IntegerArrayLookup, IntegerLookup from ..enums import ( @@ -39,14 +39,19 @@ class ACLRuleFilterMixin(NetBoxModelFilterMixin): index: Annotated["IntegerLookup", strawberry.lazy("netbox.graphql.filter_lookups")] | None = ( strawberry_django.filter_field() ) - remark: FilterLookup[str] | None = strawberry_django.filter_field() description: FilterLookup[str] | None = strawberry_django.filter_field() action: Annotated["ACLRuleActionEnum", strawberry.lazy("netbox_acls.graphql.enums")] | None = ( strawberry_django.filter_field() ) - source_prefix: Annotated["PrefixFilter", strawberry.lazy("ipam.graphql.filters")] | None = ( + + # Remark + remark: FilterLookup[str] | None = strawberry_django.filter_field() + + # Source + source_type: Annotated["ContentTypeFilter", strawberry.lazy("core.graphql.filters")] | None = ( strawberry_django.filter_field() ) + source_id: ID | None = strawberry_django.filter_field() @strawberry_django.filter(models.ACLStandardRule, lookups=True) @@ -64,15 +69,21 @@ class ACLExtendedRuleFilter(ACLRuleFilterMixin): GraphQL filter definition for the ACLExtendedRule model. """ + # Source source_ports: Annotated["IntegerArrayLookup", strawberry.lazy("netbox.graphql.filter_lookups")] | None = ( strawberry_django.filter_field() ) - destination_prefix: Annotated["PrefixFilter", strawberry.lazy("ipam.graphql.filters")] | None = ( + + # Destination + destination_type: Annotated["ContentTypeFilter", strawberry.lazy("core.graphql.filters")] | None = ( strawberry_django.filter_field() ) + destination_id: ID | None = strawberry_django.filter_field() destination_ports: Annotated["IntegerArrayLookup", strawberry.lazy("netbox.graphql.filter_lookups")] | None = ( strawberry_django.filter_field() ) + + # Protocol protocol: Annotated["ACLProtocolEnum", strawberry.lazy("netbox_acls.graphql.enums")] | None = ( strawberry_django.filter_field() ) diff --git a/netbox_acls/graphql/filters/access_lists.py b/netbox_acls/graphql/filters/access_lists.py index 9801b996..efd1ff1f 100644 --- a/netbox_acls/graphql/filters/access_lists.py +++ b/netbox_acls/graphql/filters/access_lists.py @@ -19,7 +19,7 @@ __all__ = ( "AccessListFilter", - "ACLInterfaceAssignmentFilter", + "ACLAssignmentFilter", ) @@ -30,10 +30,6 @@ class AccessListFilter(NetBoxModelFilterMixin): """ name: FilterLookup[str] | None = strawberry_django.filter_field() - assigned_object_type: Annotated["ContentTypeFilter", strawberry.lazy("core.graphql.filters")] | None = ( - strawberry_django.filter_field() - ) - assigned_object_id: ID | None = strawberry_django.filter_field() type: Annotated["ACLTypeEnum", strawberry.lazy("netbox_acls.graphql.enums")] | None = ( strawberry_django.filter_field() ) @@ -42,20 +38,20 @@ class AccessListFilter(NetBoxModelFilterMixin): ) -@strawberry_django.filter(models.ACLInterfaceAssignment, lookups=True) -class ACLInterfaceAssignmentFilter(NetBoxModelFilterMixin): +@strawberry_django.filter(models.ACLAssignment, lookups=True) +class ACLAssignmentFilter(NetBoxModelFilterMixin): """ - GraphQL filter definition for the ACLInterfaceAssignment model. + GraphQL filter definition for the ACLAssignment model. """ access_list: Annotated["AccessListFilter", strawberry.lazy("netbox_acls.graphql.filters")] | None = ( strawberry_django.filter_field() ) access_list_id: ID | None = strawberry_django.filter_field() - direction: Annotated["ACLAssignmentDirectionEnum", strawberry.lazy("netbox_acls.graphql.enums")] | None = ( - strawberry_django.filter_field() - ) assigned_object_type: Annotated["ContentTypeFilter", strawberry.lazy("core.graphql.filters")] | None = ( strawberry_django.filter_field() ) assigned_object_id: ID | None = strawberry_django.filter_field() + direction: Annotated["ACLAssignmentDirectionEnum", strawberry.lazy("netbox_acls.graphql.enums")] | None = ( + strawberry_django.filter_field() + ) diff --git a/netbox_acls/graphql/schema.py b/netbox_acls/graphql/schema.py index 3516604d..ddfe92a8 100644 --- a/netbox_acls/graphql/schema.py +++ b/netbox_acls/graphql/schema.py @@ -6,7 +6,7 @@ from .types import ( AccessListType, ACLExtendedRuleType, - ACLInterfaceAssignmentType, + ACLAssignmentType, ACLStandardRuleType, ) @@ -26,5 +26,5 @@ class NetBoxACLSQuery: acl_standard_rule: ACLStandardRuleType = strawberry_django.field() acl_standard_rule_list: List[ACLStandardRuleType] = strawberry_django.field() - acl_interface_assignment: ACLInterfaceAssignmentType = strawberry_django.field() - acl_interface_assignment_list: List[ACLInterfaceAssignmentType] = strawberry_django.field() + acl_assignment: ACLAssignmentType = strawberry_django.field() + acl_assignment_list: List[ACLAssignmentType] = strawberry_django.field() diff --git a/netbox_acls/graphql/types.py b/netbox_acls/graphql/types.py index d7888d56..342d426a 100644 --- a/netbox_acls/graphql/types.py +++ b/netbox_acls/graphql/types.py @@ -2,20 +2,24 @@ Define the object types and queries available via the graphql api. """ -from typing import Annotated, List, Union +from typing import Annotated, List, Union, TYPE_CHECKING import strawberry import strawberry_django -from netbox.graphql.types import NetBoxObjectType +from netbox.graphql.types import ContentTypeType, NetBoxObjectType from .. import models from . import filters +if TYPE_CHECKING: + from dcim.graphql.types import DeviceType, InterfaceType, VirtualChassisType + from ipam.graphql.types import AggregateType, IPAddressType, IPRangeType, PrefixType + from virtualization.graphql.types import VirtualMachineType, VMInterfaceType + @strawberry_django.type( models.AccessList, fields="__all__", - exclude=["assigned_object_type", "assigned_object_id"], filters=filters.AccessListFilter, ) class AccessListType(NetBoxObjectType): @@ -23,16 +27,6 @@ class AccessListType(NetBoxObjectType): Defines the object type for the django model AccessList. """ - # Model fields - assigned_object_type: Annotated["ContentTypeType", strawberry.lazy("netbox.graphql.types")] - assigned_object: Annotated[ - Union[ - Annotated["DeviceType", strawberry.lazy("dcim.graphql.types")], - Annotated["VirtualMachineType", strawberry.lazy("virtualization.graphql.types")], - ], - strawberry.union("ACLAssignmentType"), - ] - # Related models aclstandardrules: List[ Annotated[ @@ -46,15 +40,21 @@ class AccessListType(NetBoxObjectType): strawberry.lazy("netbox_acls.graphql.types"), ] ] + aclassignments: List[ + Annotated[ + "ACLAssignmentType", + strawberry.lazy("netbox_acls.graphql.types"), + ] + ] @strawberry_django.type( - models.ACLInterfaceAssignment, + models.ACLAssignment, fields="__all__", exclude=["assigned_object_type", "assigned_object_id"], - filters=filters.ACLInterfaceAssignmentFilter, + filters=filters.ACLAssignmentFilter, ) -class ACLInterfaceAssignmentType(NetBoxObjectType): +class ACLAssignmentType(NetBoxObjectType): """ Defines the object type for the django model ACLInterfaceAssignment. """ @@ -64,16 +64,23 @@ class ACLInterfaceAssignmentType(NetBoxObjectType): assigned_object_type: Annotated["ContentTypeType", strawberry.lazy("netbox.graphql.types")] assigned_object: Annotated[ Union[ + Annotated["DeviceType", strawberry.lazy("dcim.graphql.types")], Annotated["InterfaceType", strawberry.lazy("dcim.graphql.types")], + Annotated["VirtualChassisType", strawberry.lazy("dcim.graphql.types")], + Annotated["VirtualMachineType", strawberry.lazy("virtualization.graphql.types")], Annotated["VMInterfaceType", strawberry.lazy("virtualization.graphql.types")], ], - strawberry.union("ACLInterfaceAssignedObjectType"), + strawberry.union("ACLAssignedObjectType"), ] @strawberry_django.type( models.ACLStandardRule, fields="__all__", + exclude=[ + "source_id", + "source_type", + ], filters=filters.ACLStandardRuleFilter, ) class ACLStandardRuleType(NetBoxObjectType): @@ -83,12 +90,48 @@ class ACLStandardRuleType(NetBoxObjectType): # Model fields access_list: Annotated["AccessListType", strawberry.lazy("netbox_acls.graphql.types")] - source_prefix: Annotated["PrefixType", strawberry.lazy("ipam.graphql.types")] | None + source_type: Annotated["ContentTypeType", strawberry.lazy("netbox.graphql.types")] | None + source: ( + Annotated[ + Union[ + Annotated[ + "AggregateType", + strawberry.lazy("ipam.graphql.types"), + ], + Annotated[ + "IPAddressType", + strawberry.lazy("ipam.graphql.types"), + ], + Annotated[ + "IPRangeType", + strawberry.lazy("ipam.graphql.types"), + ], + Annotated[ + "PrefixType", + strawberry.lazy("ipam.graphql.types"), + ], + ], + strawberry.union("ACLStandardRuleObjectType"), + ] + | None + ) + + # Cached related source objects + _source_aggregate: Annotated["AggregateType", strawberry.lazy("ipam.graphql.types")] | None + _source_ipaddress: Annotated["IPAddressType", strawberry.lazy("ipam.graphql.types")] | None + _source_iprange: Annotated["IPRangeType", strawberry.lazy("ipam.graphql.types")] | None + _source_prefix: Annotated["PrefixType", strawberry.lazy("ipam.graphql.types")] | None @strawberry_django.type( models.ACLExtendedRule, fields="__all__", + exclude=[ + "source_id", + "source_type", + "destination_id", + "destination_type", + ], filters=filters.ACLExtendedRuleFilter, ) class ACLExtendedRuleType(NetBoxObjectType): @@ -98,7 +141,67 @@ class ACLExtendedRuleType(NetBoxObjectType): # Model fields access_list: Annotated["AccessListType", strawberry.lazy("netbox_acls.graphql.types")] - source_prefix: Annotated["PrefixType", strawberry.lazy("ipam.graphql.types")] | None + source_type: Annotated["ContentTypeType", strawberry.lazy("netbox.graphql.types")] | None + source: ( + Annotated[ + Union[ + Annotated[ + "AggregateType", + strawberry.lazy("ipam.graphql.types"), + ], + Annotated[ + "IPAddressType", + strawberry.lazy("ipam.graphql.types"), + ], + Annotated[ + "IPRangeType", + strawberry.lazy("ipam.graphql.types"), + ], + Annotated[ + "PrefixType", + strawberry.lazy("ipam.graphql.types"), + ], + ], + strawberry.union("ACLStandardRuleObjectType"), + ] + | None + ) source_ports: List[int] | None - destination_prefix: Annotated["PrefixType", strawberry.lazy("ipam.graphql.types")] | None + destination_type: Annotated["ContentTypeType", strawberry.lazy("netbox.graphql.types")] | None + destination: ( + Annotated[ + Union[ + Annotated[ + "AggregateType", + strawberry.lazy("ipam.graphql.types"), + ], + Annotated[ + "IPAddressType", + strawberry.lazy("ipam.graphql.types"), + ], + Annotated[ + "IPRangeType", + strawberry.lazy("ipam.graphql.types"), + ], + Annotated[ + "PrefixType", + strawberry.lazy("ipam.graphql.types"), + ], + ], + strawberry.union("ACLStandardRuleObjectType"), + ] + | None + ) destination_ports: List[int] | None + + # Cached related source objects + _source_aggregate: Annotated["AggregateType", strawberry.lazy("ipam.graphql.types")] | None + _source_ipaddress: Annotated["IPAddressType", strawberry.lazy("ipam.graphql.types")] | None + _source_iprange: Annotated["IPRangeType", strawberry.lazy("ipam.graphql.types")] | None + _source_prefix: Annotated["PrefixType", strawberry.lazy("ipam.graphql.types")] | None + + # Cached related destination objects + _destination_aggregate: Annotated["AggregateType", strawberry.lazy("ipam.graphql.types")] | None + _destination_ipaddress: Annotated["IPAddressType", strawberry.lazy("ipam.graphql.types")] | None + _destination_iprange: Annotated["IPRangeType", strawberry.lazy("ipam.graphql.types")] | None + _destination_prefix: Annotated["PrefixType", strawberry.lazy("ipam.graphql.types")] | None diff --git a/netbox_acls/migrations/0005_acl_rule_source_and_destination_objects.py b/netbox_acls/migrations/0005_acl_rule_source_and_destination_objects.py new file mode 100644 index 00000000..52e32349 --- /dev/null +++ b/netbox_acls/migrations/0005_acl_rule_source_and_destination_objects.py @@ -0,0 +1,257 @@ +import django.db.models.deletion +from django.db import migrations, models + + +def copy_prefix_assignments(apps, schema_editor): + """ + Copy Source and Destination Prefix ForeignKey IDs to the GenericForeignKey + fields. + """ + + db_alias = schema_editor.connection.alias + ContentType = apps.get_model("contenttypes", "ContentType") + Prefix = apps.get_model("ipam", "Prefix") + ACLStandardRule = apps.get_model("netbox_acls", "ACLStandardRule") + ACLExtendedRule = apps.get_model("netbox_acls", "ACLExtendedRule") + + ACLStandardRule.objects.using(db_alias).filter(_source_prefix__isnull=False).update( + source_type=ContentType.objects.get_for_model(Prefix), + source_id=models.F("_source_prefix_id"), + ) + ACLExtendedRule.objects.using(db_alias).filter(_source_prefix__isnull=False).filter( + _destination_prefix__isnull=False + ).update( + source_type=ContentType.objects.get_for_model(Prefix), + source_id=models.F("_source_prefix_id"), + destination_type=ContentType.objects.get_for_model(Prefix), + destination_id=models.F("_destination_prefix_id"), + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("extras", "0128_tableconfig"), + ("ipam", "0081_remove_service_device_virtual_machine_add_parent_gfk_index"), + ("netbox_acls", "0004_netbox_acls"), + ] + + operations = [ + migrations.RenameField( + model_name="aclextendedrule", + old_name="destination_prefix", + new_name="_destination_prefix", + ), + migrations.RenameField( + model_name="aclextendedrule", + old_name="source_prefix", + new_name="_source_prefix", + ), + migrations.RenameField( + model_name="aclstandardrule", + old_name="source_prefix", + new_name="_source_prefix", + ), + migrations.AddField( + model_name="aclextendedrule", + name="_destination_aggregate", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="_%(class)s_destinations", + to="ipam.aggregate", + ), + ), + migrations.AddField( + model_name="aclextendedrule", + name="_destination_ipaddress", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="_%(class)s_destinations", + to="ipam.ipaddress", + ), + ), + migrations.AddField( + model_name="aclextendedrule", + name="_destination_iprange", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="_%(class)s_destinations", + to="ipam.iprange", + ), + ), + migrations.AddField( + model_name="aclextendedrule", + name="_source_aggregate", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="_%(class)s_sources", + to="ipam.aggregate", + ), + ), + migrations.AddField( + model_name="aclextendedrule", + name="_source_ipaddress", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="_%(class)s_sources", + to="ipam.ipaddress", + ), + ), + migrations.AddField( + model_name="aclextendedrule", + name="_source_iprange", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="_%(class)s_sources", + to="ipam.iprange", + ), + ), + migrations.AddField( + model_name="aclextendedrule", + name="destination_id", + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="aclextendedrule", + name="destination_type", + field=models.ForeignKey( + blank=True, + limit_choices_to=models.Q( + models.Q(("app_label", "ipam"), ("model__in", ("aggregate", "ipaddress", "iprange", "prefix"))) + ), + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to="contenttypes.contenttype", + ), + ), + migrations.AddField( + model_name="aclextendedrule", + name="source_id", + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="aclextendedrule", + name="source_type", + field=models.ForeignKey( + blank=True, + limit_choices_to=models.Q( + models.Q(("app_label", "ipam"), ("model__in", ("aggregate", "ipaddress", "iprange", "prefix"))) + ), + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to="contenttypes.contenttype", + ), + ), + migrations.AddField( + model_name="aclstandardrule", + name="_source_aggregate", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="_%(class)s_sources", + to="ipam.aggregate", + ), + ), + migrations.AddField( + model_name="aclstandardrule", + name="_source_ipaddress", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="_%(class)s_sources", + to="ipam.ipaddress", + ), + ), + migrations.AddField( + model_name="aclstandardrule", + name="_source_iprange", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="_%(class)s_sources", + to="ipam.iprange", + ), + ), + migrations.AddField( + model_name="aclstandardrule", + name="source_id", + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="aclstandardrule", + name="source_type", + field=models.ForeignKey( + blank=True, + limit_choices_to=models.Q( + models.Q(("app_label", "ipam"), ("model__in", ("aggregate", "ipaddress", "iprange", "prefix"))) + ), + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to="contenttypes.contenttype", + ), + ), + migrations.AlterField( + model_name="aclextendedrule", + name="_destination_prefix", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="_%(class)s_destinations", + to="ipam.prefix", + ), + ), + migrations.AlterField( + model_name="aclextendedrule", + name="_source_prefix", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="_%(class)s_sources", + to="ipam.prefix", + ), + ), + migrations.AlterField( + model_name="aclstandardrule", + name="_source_prefix", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="_%(class)s_sources", + to="ipam.prefix", + ), + ), + migrations.AddIndex( + model_name="aclextendedrule", + index=models.Index( + fields=["destination_type", "destination_id", "source_type", "source_id"], + name="netbox_acls_destina_8f93b4_idx", + ), + ), + migrations.AddIndex( + model_name="aclstandardrule", + index=models.Index(fields=["source_type", "source_id"], name="netbox_acls_source__01d2fa_idx"), + ), + # Copy over existing Prefix assignments + migrations.RunPython(code=copy_prefix_assignments, reverse_code=migrations.RunPython.noop), + ] diff --git a/netbox_acls/migrations/0006_acl_assignments.py b/netbox_acls/migrations/0006_acl_assignments.py new file mode 100644 index 00000000..eb2af35e --- /dev/null +++ b/netbox_acls/migrations/0006_acl_assignments.py @@ -0,0 +1,75 @@ +import django.db.models.deletion +from django.db import migrations, models + + +def copy_host_assignments(apps, schema_editor): + """ + Copies host assignments from the AccessList model to ACLAssignment model. + """ + + db_alias = schema_editor.connection.alias + AccessList = apps.get_model("netbox_acls", "AccessList") + ACLAssignment = apps.get_model("netbox_acls", "ACLAssignment") + + for acl in AccessList.objects.using(db_alias).all(): + ACLAssignment.objects.using(db_alias).create( + access_list=acl, + assigned_object_type=acl.assigned_object_type, + assigned_object_id=acl.assigned_object_id, + direction="none", + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("netbox_acls", "0005_acl_rule_source_and_destination_objects"), + ] + + operations = [ + migrations.RenameModel( + old_name="ACLInterfaceAssignment", + new_name="ACLAssignment", + ), + migrations.AlterModelOptions( + name="accesslist", + options={"ordering": ("name",)}, + ), + migrations.AlterUniqueTogether( + name="accesslist", + unique_together=set(), + ), + migrations.AlterField( + model_name="aclassignment", + name="access_list", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="aclassignments", + to="netbox_acls.accesslist", + ), + ), + migrations.AlterField( + model_name="aclassignment", + name="assigned_object_type", + field=models.ForeignKey( + limit_choices_to=models.Q( + models.Q( + models.Q(("app_label", "dcim"), ("model__in", ("device", "interface", "virtualchassis"))), + models.Q(("app_label", "virtualization"), ("model__in", ("virtualmachine", "vminterface"))), + _connector="OR", + ), + ), + on_delete=django.db.models.deletion.PROTECT, + to="contenttypes.contenttype", + ), + ), + # Copy over existing Host assignments + migrations.RunPython(code=copy_host_assignments, reverse_code=migrations.RunPython.noop), + migrations.RemoveField( + model_name="accesslist", + name="assigned_object_id", + ), + migrations.RemoveField( + model_name="accesslist", + name="assigned_object_type", + ), + ] diff --git a/netbox_acls/models/access_list_rules.py b/netbox_acls/models/access_list_rules.py index c8a46c7b..d7b7b92a 100644 --- a/netbox_acls/models/access_list_rules.py +++ b/netbox_acls/models/access_list_rules.py @@ -2,14 +2,18 @@ Define the django models for this plugin. """ +from django.apps import apps +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ +from ipam.models import Aggregate, IPAddress, IPRange, Prefix from netbox.models import NetBoxModel from ..choices import ACLProtocolChoices, ACLRuleActionChoices, ACLTypeChoices +from ..constants import ACL_RULE_SOURCE_DESTINATION_MODELS from .access_lists import AccessList __all__ = ( @@ -21,16 +25,14 @@ # Error message when the action is 'remark', but no remark is provided. ERROR_MESSAGE_NO_REMARK = _("When the action is 'remark', a remark is required.") -# Error message when the action is 'remark', but the source_prefix is set. -ERROR_MESSAGE_ACTION_REMARK_SOURCE_PREFIX_SET = _("When the action is 'remark', the Source Prefix must not be set.") +# Error message when the action is 'remark', but the source is set. +ERROR_MESSAGE_ACTION_REMARK_SOURCE_SET = _("When the action is 'remark', the Source must not be set.") # Error message when the action is 'remark', but the source_ports are set. ERROR_MESSAGE_ACTION_REMARK_SOURCE_PORTS_SET = _("When the action is 'remark', Source Ports must not be set.") -# Error message when the action is 'remark', but the destination_prefix is set. -ERROR_MESSAGE_ACTION_REMARK_DESTINATION_PREFIX_SET = _( - "When the action is 'remark', the Destination Prefix must not be set." -) +# Error message when the action is 'remark', but the destination is set. +ERROR_MESSAGE_ACTION_REMARK_DESTINATION_SET = _("When the action is 'remark', the Destination must not be set.") # Error message when the action is 'remark', but the destination_ports are set. ERROR_MESSAGE_ACTION_REMARK_DESTINATION_PORTS_SET = _("When the action is 'remark', Destination Ports must not be set.") @@ -54,12 +56,9 @@ class ACLRule(NetBoxModel): related_name="rules", verbose_name=_("Access List"), ) + + # Rule index = models.PositiveIntegerField() - remark = models.CharField( - verbose_name=_("Remark"), - max_length=500, - blank=True, - ) description = models.CharField( verbose_name=_("Description"), max_length=500, @@ -70,17 +69,75 @@ class ACLRule(NetBoxModel): max_length=30, choices=ACLRuleActionChoices, ) - source_prefix = models.ForeignKey( - to="ipam.prefix", + + # Remark + remark = models.CharField( + verbose_name=_("Remark"), + max_length=500, + blank=True, + ) + + # Source + source_type = models.ForeignKey( + to="contenttypes.ContentType", on_delete=models.PROTECT, related_name="+", + limit_choices_to=ACL_RULE_SOURCE_DESTINATION_MODELS, + verbose_name=_("Source Type"), + blank=True, + null=True, + ) + source_id = models.PositiveBigIntegerField( + verbose_name=_("Source ID"), + blank=True, + null=True, + ) + source = GenericForeignKey( + ct_field="source_type", + fk_field="source_id", + ) + + # Cached related objects by association name for faster access + _source_aggregate = models.ForeignKey( + to="ipam.aggregate", + on_delete=models.PROTECT, + related_name="_%(class)s_sources", + verbose_name=_("Source Aggregate"), + blank=True, + null=True, + ) + _source_ipaddress = models.ForeignKey( + to="ipam.ipaddress", + on_delete=models.PROTECT, + related_name="_%(class)s_sources", + verbose_name=_("Source IP-Address"), + blank=True, + null=True, + ) + _source_iprange = models.ForeignKey( + to="ipam.iprange", + on_delete=models.PROTECT, + related_name="_%(class)s_sources", + verbose_name=_("Source IP-Range"), + blank=True, + null=True, + ) + _source_prefix = models.ForeignKey( + to="ipam.prefix", + on_delete=models.PROTECT, + related_name="_%(class)s_sources", verbose_name=_("Source Prefix"), blank=True, null=True, ) - clone_fields = ("access_list", "action", "source_prefix") - prerequisite_models = ("netbox_acls.AccessList",) + clone_fields = ( + "access_list", + "action", + "source_id", + "source_type", + ) + prerequisite_models: tuple = ("netbox_acls.AccessList",) class Meta: """ @@ -91,12 +148,56 @@ class Meta: """ abstract = True + indexes = (models.Index(fields=("source_type", "source_id")),) ordering = ["access_list", "index"] unique_together = ["access_list", "index"] def __str__(self): return f"{self.access_list}: Rule {self.index}" + def clean(self): + """ + Override the model's clean method for custom field validation. + """ + # Validate source assignment + if self.source_type and not (self.source or self.source_id): + source_type = self.source_type.model_class() + raise ValidationError( + { + "source": _("Please select a source {source_type}.").format( + source_type=source_type._meta.verbose_name + ) + } + ) + super().clean() + + def save(self, *args, **kwargs): + """ + Saves the current instance to the database. + """ + # Cache the related source objects for faster access + self.cache_related_source_object() + + super().save(*args, **kwargs) + + def cache_related_source_object(self): + """ + Cache the related source objects for faster access. + """ + self._source_aggregate = self._source_ipaddress = self._source_iprange = self._source_prefix = None + if self.source_type: + source_type = self.source_type.model_class() + if source_type == apps.get_model("ipam", "aggregate"): + self._source_aggregate = self.source + elif source_type == apps.get_model("ipam", "ipaddress"): + self._source_ipaddress = self.source + elif source_type == apps.get_model("ipam", "iprange"): + self._source_iprange = self.source + elif source_type == apps.get_model("ipam", "prefix"): + self._source_prefix = self.source + + cache_related_source_object.alters_data = True + def get_absolute_url(self): """ The method is a Django convention; although not strictly required, @@ -140,7 +241,7 @@ def clean(self): Validate the ACL Standard Rule inputs. If the action is 'remark', then the remark field must be provided (non-empty), - and the source_prefix field must be empty. + and the source field must be empty. Conversely, if the remark field is provided, the action must be set to 'remark'. """ @@ -151,8 +252,8 @@ def clean(self): if self.action == ACLRuleActionChoices.ACTION_REMARK: if not self.remark: errors["remark"] = ERROR_MESSAGE_NO_REMARK - if self.source_prefix: - errors["source_prefix"] = ERROR_MESSAGE_ACTION_REMARK_SOURCE_PREFIX_SET + if self.source: + errors["source"] = ERROR_MESSAGE_ACTION_REMARK_SOURCE_SET # Validate that the action is "remark", when the remark field is provided elif self.remark: errors["remark"] = ERROR_MESSAGE_REMARK_WITHOUT_ACTION_REMARK @@ -164,7 +265,8 @@ def clean(self): class ACLExtendedRule(ACLRule): """ Inherits ACLRule. - Add ACLExtendedRule specific fields: source_ports, destination_prefix, destination_ports, and protocol + + Add ACLExtendedRule specific fields: source_ports, destination, destination_ports, and protocol """ access_list = models.ForeignKey( @@ -174,39 +276,91 @@ class ACLExtendedRule(ACLRule): limit_choices_to={"type": "extended"}, verbose_name=_("Extended Access List"), ) + + # Protocol + protocol = models.CharField( + verbose_name=_("Protocol"), + max_length=30, + choices=ACLProtocolChoices, + blank=True, + ) + + # Source source_ports = ArrayField( base_field=models.PositiveIntegerField(), verbose_name=_("Source Ports"), blank=True, null=True, ) - destination_prefix = models.ForeignKey( - to="ipam.prefix", + + # Destination + destination_type = models.ForeignKey( + to="contenttypes.ContentType", on_delete=models.PROTECT, related_name="+", - verbose_name=_("Destination Prefix"), + limit_choices_to=ACL_RULE_SOURCE_DESTINATION_MODELS, + verbose_name=_("Destination Type"), + blank=True, + null=True, + ) + destination_id = models.PositiveBigIntegerField( + verbose_name=_("Destination ID"), blank=True, null=True, ) + destination = GenericForeignKey( + ct_field="destination_type", + fk_field="destination_id", + ) destination_ports = ArrayField( base_field=models.PositiveIntegerField(), verbose_name=_("Destination Ports"), blank=True, null=True, ) - protocol = models.CharField( - verbose_name=_("Protocol"), - max_length=30, - choices=ACLProtocolChoices, + + # Cached related objects by association name for faster access + _destination_aggregate = models.ForeignKey( + to="ipam.aggregate", + on_delete=models.PROTECT, + related_name="_%(class)s_destinations", + verbose_name=_("Destination Aggregate"), + blank=True, + null=True, + ) + _destination_ipaddress = models.ForeignKey( + to="ipam.ipaddress", + on_delete=models.PROTECT, + related_name="_%(class)s_destinations", + verbose_name=_("Destination IP-Address"), blank=True, + null=True, + ) + _destination_iprange = models.ForeignKey( + to="ipam.iprange", + on_delete=models.PROTECT, + related_name="_%(class)s_destinations", + verbose_name=_("Destination IP-Range"), + blank=True, + null=True, + ) + _destination_prefix = models.ForeignKey( + to="ipam.prefix", + on_delete=models.PROTECT, + related_name="_%(class)s_destinations", + verbose_name=_("Destination Prefix"), + blank=True, + null=True, ) clone_fields = ( "access_list", "action", - "source_prefix", + "source_id", + "source_type", "source_ports", - "destination_prefix", + "destination_id", + "destination_type", "destination_ports", "protocol", ) @@ -221,6 +375,7 @@ class Meta(ACLRule.Meta): verbose_name = _("ACL Extended Rule") verbose_name_plural = _("ACL Extended Rules") + indexes = (models.Index(fields=("destination_type", "destination_id", "source_type", "source_id")),) def clean(self): """ @@ -228,27 +383,39 @@ def clean(self): When the action is 'remark', the remark field must be provided (non-empty), and the following fields must be empty: - - source_prefix + - source - source_ports - - destination_prefix + - destination - destination_ports - protocol Conversely, if a remark is provided, the action must be set to 'remark'. """ + # Validate destination assignment + if self.destination_type and not (self.destination or self.destination_id): + destination_type = self.destination_type.model_class() + raise ValidationError( + { + "destination": _("Please select a destination {destination_type}.").format( + destination_type=destination_type._meta.verbose_name, + ), + }, + ) + super().clean() + errors = {} # Validate that only the remark field is filled if self.action == ACLRuleActionChoices.ACTION_REMARK: if not self.remark: errors["remark"] = ERROR_MESSAGE_NO_REMARK - if self.source_prefix: - errors["source_prefix"] = ERROR_MESSAGE_ACTION_REMARK_SOURCE_PREFIX_SET + if self.source: + errors["source"] = ERROR_MESSAGE_ACTION_REMARK_SOURCE_SET if self.source_ports: errors["source_ports"] = ERROR_MESSAGE_ACTION_REMARK_SOURCE_PORTS_SET - if self.destination_prefix: - errors["destination_prefix"] = ERROR_MESSAGE_ACTION_REMARK_DESTINATION_PREFIX_SET + if self.destination: + errors["destination"] = ERROR_MESSAGE_ACTION_REMARK_DESTINATION_SET if self.destination_ports: errors["destination_ports"] = ERROR_MESSAGE_ACTION_REMARK_DESTINATION_PORTS_SET if self.protocol: @@ -260,5 +427,140 @@ def clean(self): if errors: raise ValidationError(errors) + def save(self, *args, **kwargs): + """ + Saves the current instance to the database. + """ + # Cache the related destination objects for faster access + self.cache_related_destination_objects() + + super().save(*args, **kwargs) + + def cache_related_destination_objects(self): + """ + Cache the related destination objects for faster access. + """ + self._destination_aggregate = self._destination_ipaddress = self._destination_iprange = ( + self._destination_prefix + ) = None + if self.destination_type: + destination_type = self.destination_type.model_class() + if destination_type == apps.get_model("ipam", "aggregate"): + self._destination_aggregate = self.destination + elif destination_type == apps.get_model("ipam", "ipaddress"): + self._destination_ipaddress = self.destination + elif destination_type == apps.get_model("ipam", "iprange"): + self._destination_iprange = self.destination + elif destination_type == apps.get_model("ipam", "prefix"): + self._destination_prefix = self.destination + + cache_related_destination_objects.alters_data = True + def get_protocol_color(self): return ACLProtocolChoices.colors.get(self.protocol) + + +# +# Generic Relations: ACLStandardRule +# + +# Source Aggregate +GenericRelation( + to=ACLStandardRule, + content_type_field="source_type", + object_id_field="source_id", + related_query_name="source_aggregate", +).contribute_to_class(Aggregate, "accesslist_standard_rule_sources") + +# Source IPAddress +GenericRelation( + to=ACLStandardRule, + content_type_field="source_type", + object_id_field="source_id", + related_query_name="source_ip_address", +).contribute_to_class(IPAddress, "accesslist_standard_rule_sources") + +# Source IPRange +GenericRelation( + to=ACLStandardRule, + content_type_field="source_type", + object_id_field="source_id", + related_query_name="source_ip_range", +).contribute_to_class(IPRange, "accesslist_standard_rule_sources") + +# Source Prefix +GenericRelation( + to=ACLStandardRule, + content_type_field="source_type", + object_id_field="source_id", + related_query_name="source_prefix", +).contribute_to_class(Prefix, "accesslist_standard_rule_sources") + + +# +# Generic Relations: ACLExtendedRule +# + +# Source Aggregate +GenericRelation( + to=ACLExtendedRule, + content_type_field="source_type", + object_id_field="source_id", + related_query_name="source_aggregate", +).contribute_to_class(Aggregate, "accesslist_extended_rule_sources") + +# Source IPAddress +GenericRelation( + to=ACLExtendedRule, + content_type_field="source_type", + object_id_field="source_id", + related_query_name="source_ip_address", +).contribute_to_class(IPAddress, "accesslist_extended_rule_sources") + +# Source IPRange +GenericRelation( + to=ACLExtendedRule, + content_type_field="source_type", + object_id_field="source_id", + related_query_name="source_ip_range", +).contribute_to_class(IPRange, "accesslist_extended_rule_sources") + +# Source Prefix +GenericRelation( + to=ACLExtendedRule, + content_type_field="source_type", + object_id_field="source_id", + related_query_name="source_prefix", +).contribute_to_class(Prefix, "accesslist_extended_rule_sources") + +# Destination Aggregate +GenericRelation( + to=ACLExtendedRule, + content_type_field="destination_type", + object_id_field="destination_id", + related_query_name="destination_aggregate", +).contribute_to_class(Aggregate, "accesslist_extended_rule_destinations") + +# Destination IPAddress +GenericRelation( + to=ACLExtendedRule, + content_type_field="destination_type", + object_id_field="destination_id", + related_query_name="destination_ip_address", +).contribute_to_class(IPAddress, "accesslist_extended_rule_destinations") + +# Destination IPRange +GenericRelation( + to=ACLExtendedRule, + content_type_field="destination_type", + object_id_field="destination_id", + related_query_name="destination_ip_range", +).contribute_to_class(IPRange, "accesslist_extended_rule_destinations") + +# Destination Prefix +GenericRelation( + to=ACLExtendedRule, + content_type_field="destination_type", + object_id_field="destination_id", + related_query_name="destination_prefix", +).contribute_to_class(Prefix, "accesslist_extended_rule_destinations") diff --git a/netbox_acls/models/access_lists.py b/netbox_acls/models/access_lists.py index 5035a8df..36d76284 100644 --- a/netbox_acls/models/access_lists.py +++ b/netbox_acls/models/access_lists.py @@ -14,11 +14,11 @@ from virtualization.models import VirtualMachine, VMInterface from ..choices import ACLActionChoices, ACLAssignmentDirectionChoices, ACLTypeChoices -from ..constants import ACL_HOST_ASSIGNMENT_MODELS, ACL_INTERFACE_ASSIGNMENT_MODELS +from ..constants import ACL_ASSIGNMENT_MODELS __all__ = ( "AccessList", - "ACLInterfaceAssignment", + "ACLAssignment", ) @@ -38,17 +38,6 @@ class AccessList(NetBoxModel): max_length=500, validators=[alphanumeric_plus], ) - assigned_object_type = models.ForeignKey( - to=ContentType, - on_delete=models.PROTECT, - limit_choices_to=ACL_HOST_ASSIGNMENT_MODELS, - verbose_name=_("Assigned Object Type"), - ) - assigned_object_id = models.PositiveBigIntegerField() - assigned_object = GenericForeignKey( - ct_field="assigned_object_type", - fk_field="assigned_object_id", - ) type = models.CharField( verbose_name=_("Type"), max_length=30, @@ -70,14 +59,25 @@ class AccessList(NetBoxModel): ) class Meta: - unique_together = ["assigned_object_type", "assigned_object_id", "name"] - ordering = ["assigned_object_type", "assigned_object_id", "name"] + ordering = ("name",) verbose_name = _("Access List") verbose_name_plural = _("Access Lists") def __str__(self): + """ + Returns the string representation of the object. + """ return self.name + def __init__(self, *args, **kwargs): + """ + Initializes a new instance of the class. + """ + super().__init__(*args, **kwargs) + + # Save a copy of the ACL name for validation in clean() + self._original_name = self.__dict__.get("name") + def get_absolute_url(self): """ The method is a Django convention; although not strictly required, @@ -85,16 +85,65 @@ def get_absolute_url(self): """ return reverse("plugins:netbox_acls:accesslist", args=[self.pk]) + def clean(self): + """ + Override the model's clean method for custom validation. + """ + super().clean() + + # Validate that uniqueness of the AccessList name per assigned host type + # (device, virtual chassis, or virtual machine) during renaming. + if self.pk and self._original_name and self._original_name != self.name: + host_assigned_object_types = [ + ContentType.objects.get_for_model(Device), + ContentType.objects.get_for_model(VirtualChassis), + ContentType.objects.get_for_model(VirtualMachine), + ] + acl_assignments = ACLAssignment.objects.filter( + access_list=self, + assigned_object_type__in=host_assigned_object_types, + ) + for acl_assignment in acl_assignments: + conflicting_acl_assignments = ACLAssignment.objects.filter( + access_list__name=self.name, + assigned_object_type=acl_assignment.assigned_object_type, + assigned_object_id=acl_assignment.assigned_object_id, + ).exclude(pk=acl_assignment.pk) + if conflicting_acl_assignments.exists(): + assigned_object_model = acl_assignment.assigned_object_type.model_class() + raise ValidationError( + { + "name": _( + "An Access List with the name '{access_list}' " + "is already assigned to the {assigned_object} " + "'{assigned_object_name}'.".format( + access_list=self.name, + assigned_object=assigned_object_model._meta.verbose_name, + assigned_object_name=acl_assignment.assigned_object.name, + ) + ) + } + ) + def get_default_action_color(self): + """ + Retrieves the default action color from the ACLActionChoices. + """ return ACLActionChoices.colors.get(self.default_action) def get_type_color(self): + """ + Retrieves the type color from the ACLTypeChoices. + """ return ACLTypeChoices.colors.get(self.type) -class ACLInterfaceAssignment(NetBoxModel): +class ACLAssignment(NetBoxModel): """ - Model definition for Access Lists associations with other Host interfaces: + Model definition for Access Lists associations with objects: + - device + - virtual chassis + - virtual machine - VM interfaces - device interface """ @@ -102,17 +151,13 @@ class ACLInterfaceAssignment(NetBoxModel): access_list = models.ForeignKey( to=AccessList, on_delete=models.CASCADE, + related_name="aclassignments", verbose_name=_("Access List"), ) - direction = models.CharField( - verbose_name=_("Direction"), - max_length=30, - choices=ACLAssignmentDirectionChoices, - ) assigned_object_type = models.ForeignKey( to=ContentType, on_delete=models.PROTECT, - limit_choices_to=ACL_INTERFACE_ASSIGNMENT_MODELS, + limit_choices_to=ACL_ASSIGNMENT_MODELS, verbose_name=_("Assigned Object Type"), ) assigned_object_id = models.PositiveBigIntegerField() @@ -120,6 +165,11 @@ class ACLInterfaceAssignment(NetBoxModel): ct_field="assigned_object_type", fk_field="assigned_object_id", ) + direction = models.CharField( + verbose_name=_("Direction"), + max_length=30, + choices=ACLAssignmentDirectionChoices, + ) comments = models.TextField( blank=True, ) @@ -140,11 +190,14 @@ class Meta: "access_list", "direction", ] - verbose_name = _("ACL Interface Assignment") - verbose_name_plural = _("ACL Interface Assignments") + verbose_name = _("ACL Assignment") + verbose_name_plural = _("ACL Assignments") def __str__(self): - return f"{self.access_list}: Interface {self.assigned_object}" + """ + Returns the string representation of the object. + """ + return f"{self.access_list}: Object {self.assigned_object}" def get_absolute_url(self): """ @@ -152,57 +205,146 @@ def get_absolute_url(self): it conveniently returns the absolute URL for any particular object. """ return reverse( - "plugins:netbox_acls:aclinterfaceassignment", + "plugins:netbox_acls:aclassignment", args=[self.pk], ) - def save(self, *args, **kwargs): - """Saves the current instance to the database.""" - # Ensure the assigned interface's host matches the host assigned to the access list. - if self.assigned_object.parent_object != self.access_list.assigned_object: + def clean(self) -> None: + """ + Override the model's clean method for custom validation. + """ + + # Validate object assignment before validation of any other fields + if self.assigned_object_type and not (self.assigned_object or self.assigned_object_id): + assigned_object_model = self.assigned_object_type.model_class() raise ValidationError( { - "assigned_object": "The assigned object must be the same as the device assigned to it." + "assigned_object": _( + "The {assigned_object} field is required,if an assigned object type is selected.".format( + assigned_object=assigned_object_model._meta.verbose_name + ) + ) } ) + super().clean() + + # Validate that uniqueness of the AccessList name per assigned host type + # (device, virtual chassis, or virtual machine) + host_assigned_object_types = [ + ContentType.objects.get_for_model(Device), + ContentType.objects.get_for_model(VirtualChassis), + ContentType.objects.get_for_model(VirtualMachine), + ] + if self.assigned_object_type in host_assigned_object_types: + self._validate_unique_acl_name_per_assigned_object() + else: + self._validate_unique_acl_assignment_per_assigned_interface() + + def _validate_unique_acl_name_per_assigned_object(self) -> None: + """ + Validates that there is no Access List with the same name for + the assigned object type and object ID. + This ensures each ACL name is unique for the same object. + """ + conflicting_acl_assignments = ACLAssignment.objects.filter( + access_list__name=self.access_list.name, + assigned_object_type=self.assigned_object_type, + assigned_object_id=self.assigned_object_id, + ) + if self.pk: + conflicting_acl_assignments = conflicting_acl_assignments.exclude(pk=self.pk) + + if conflicting_acl_assignments.exists(): + assigned_object_model = self.assigned_object_type.model_class() + raise ValidationError( + { + "access_list": _( + "An Access List with the name '{access_list}' " + "already exists for the specified " + "{assigned_object}.".format( + access_list=self.access_list.name, assigned_object=assigned_object_model._meta.verbose_name + ) + ) + } + ) + + def _validate_unique_acl_assignment_per_assigned_interface(self) -> None: + conflicting_acl_assignments = ACLAssignment.objects.filter( + assigned_object_type=self.assigned_object_type, + assigned_object_id=self.assigned_object_id, + direction=self.direction, + ) + if self.pk: + conflicting_acl_assignments = conflicting_acl_assignments.exclude(pk=self.pk) + if conflicting_acl_assignments.exists(): + assigned_object_model = self.assigned_object_type.model_class() + raise ValidationError( + { + "direction": _( + "An ACL Assignment with the same direction already exists for the specified " + "{assigned_object}.".format(assigned_object=assigned_object_model._meta.verbose_name) + ) + } + ) + + def save(self, *args, **kwargs): + """ + Saves the current instance to the database. + """ + host_assigned_object_types = [ + ContentType.objects.get_for_model(Device), + ContentType.objects.get_for_model(VirtualChassis), + ContentType.objects.get_for_model(VirtualMachine), + ] + + # If the assigned object is a host type (device, virtual chassis, + # or virtual machine), directional semantics (ingress/egress) are + # not applicable. + # Therefore, the direction field is set to "none" in these cases. + if self.assigned_object_type in host_assigned_object_types: + self.direction = ACLAssignmentDirectionChoices.DIRECTION_NONE + super().save(*args, **kwargs) def get_direction_color(self): + """ + Retrieves the direction color from the ACLAssignmentDirectionChoices. + """ return ACLAssignmentDirectionChoices.colors.get(self.direction) GenericRelation( - to=ACLInterfaceAssignment, + to=ACLAssignment, content_type_field="assigned_object_type", object_id_field="assigned_object_id", related_query_name="interface", -).contribute_to_class(Interface, "accesslistassignments") +).contribute_to_class(Interface, "aclassignments") GenericRelation( - to=ACLInterfaceAssignment, + to=ACLAssignment, content_type_field="assigned_object_type", object_id_field="assigned_object_id", related_query_name="vminterface", -).contribute_to_class(VMInterface, "accesslistassignments") +).contribute_to_class(VMInterface, "aclassignments") GenericRelation( - to=AccessList, + to=ACLAssignment, content_type_field="assigned_object_type", object_id_field="assigned_object_id", related_query_name="device", -).contribute_to_class(Device, "accesslists") +).contribute_to_class(Device, "aclassignments") GenericRelation( - to=AccessList, + to=ACLAssignment, content_type_field="assigned_object_type", object_id_field="assigned_object_id", related_query_name="virtual_chassis", -).contribute_to_class(VirtualChassis, "accesslists") +).contribute_to_class(VirtualChassis, "aclassignments") GenericRelation( - to=AccessList, + to=ACLAssignment, content_type_field="assigned_object_type", object_id_field="assigned_object_id", related_query_name="virtual_machine", -).contribute_to_class(VirtualMachine, "accesslists") +).contribute_to_class(VirtualMachine, "aclassignments") diff --git a/netbox_acls/navigation.py b/netbox_acls/navigation.py index 1144cf1d..3ed9f391 100644 --- a/netbox_acls/navigation.py +++ b/netbox_acls/navigation.py @@ -56,17 +56,17 @@ ), ) -# ACL Interface Assignment -aclinterfaceassignment_item = PluginMenuItem( - link="plugins:netbox_acls:aclinterfaceassignment_list", - link_text="Interface Assignments", - permissions=["netbox_acls.view_aclinterfaceassignment"], +# ACL Assignment +aclassignment_item = PluginMenuItem( + link="plugins:netbox_acls:aclassignment_list", + link_text="Assignments", + permissions=["netbox_acls.view_aclassignment"], buttons=( PluginMenuButton( - link="plugins:netbox_acls:aclinterfaceassignment_add", + link="plugins:netbox_acls:aclassignment_add", title="Add", icon_class="mdi mdi-plus-thick", - permissions=["netbox_acls.add_aclinterfaceassignment"], + permissions=["netbox_acls.add_aclassignment"], ), ), ) @@ -78,7 +78,10 @@ groups=( ( "Access Lists", - (accesslist_item,), + ( + accesslist_item, + aclassignment_item, + ), ), ( "Rules", @@ -87,17 +90,13 @@ aclextendedrule_item, ), ), - ( - "Assignments", - (aclinterfaceassignment_item,), - ), ), icon_class="mdi mdi-lock", ) else: menu_items = ( accesslist_item, + aclassignment_item, aclstandardrule_item, aclextendedrule_item, - aclinterfaceassignment_item, ) diff --git a/netbox_acls/tables.py b/netbox_acls/tables.py index bc26a23e..7e595839 100644 --- a/netbox_acls/tables.py +++ b/netbox_acls/tables.py @@ -4,27 +4,18 @@ import django_tables2 as tables from django.utils.translation import gettext_lazy as _ -from netbox.tables import ChoiceFieldColumn, NetBoxTable, columns +from netbox.tables import NetBoxTable, columns -from .models import AccessList, ACLExtendedRule, ACLInterfaceAssignment, ACLStandardRule +from .models import AccessList, ACLExtendedRule, ACLAssignment, ACLStandardRule __all__ = ( "AccessListTable", - "ACLInterfaceAssignmentTable", + "ACLAssignmentTable", "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. @@ -34,19 +25,14 @@ class AccessListTable(NetBoxTable): id = tables.Column( linkify=True, ) - assigned_object = tables.Column( - verbose_name=_("Assigned Host"), - orderable=False, - linkify=True, - ) name = tables.Column( linkify=True, ) device = tables.Column( linkify=True, ) - type = ChoiceFieldColumn() - default_action = ChoiceFieldColumn() + type = columns.ChoiceFieldColumn() + default_action = columns.ChoiceFieldColumn() rule_count = tables.Column( verbose_name=_("Rule Count"), ) @@ -60,7 +46,6 @@ class Meta(NetBoxTable.Meta): "pk", "id", "name", - "assigned_object", "type", "rule_count", "default_action", @@ -70,7 +55,6 @@ class Meta(NetBoxTable.Meta): ) default_columns = ( "name", - "assigned_object", "type", "rule_count", "default_action", @@ -78,7 +62,7 @@ class Meta(NetBoxTable.Meta): ) -class ACLInterfaceAssignmentTable(NetBoxTable): +class ACLAssignmentTable(NetBoxTable): """ Defines the table view for the AccessList model. """ @@ -90,37 +74,46 @@ class ACLInterfaceAssignmentTable(NetBoxTable): access_list = tables.Column( linkify=True, ) - direction = ChoiceFieldColumn() - host = tables.TemplateColumn( - template_code=COL_HOST_ASSIGNMENT, - orderable=False, + assigned_object_type = columns.ContentTypeColumn( + linkify=True, ) assigned_object = tables.Column( - verbose_name=_("Assigned Interface"), + verbose_name=_("Assigned Object"), orderable=False, linkify=True, ) + direction = columns.ChoiceFieldColumn() tags = columns.TagColumn( - url_name="plugins:netbox_acls:aclinterfaceassignment_list", + url_name="plugins:netbox_acls:aclassignment_list", + ) + type = tables.Column( + accessor=tables.A("access_list__type"), + orderable=False, + verbose_name=_("Type"), + ) + default_action = tables.Column( + accessor=tables.A("access_list__default_action"), + orderable=False, + verbose_name=_("Default Action"), ) class Meta(NetBoxTable.Meta): - model = ACLInterfaceAssignment + model = ACLAssignment fields = ( "pk", "id", "access_list", - "direction", - "host", + "assigned_object_type", "assigned_object", + "direction", "tags", ) default_columns = ( "id", "access_list", - "direction", - "host", + "type", "assigned_object", + "direction", "tags", ) @@ -136,11 +129,21 @@ class ACLStandardRuleTable(NetBoxTable): index = tables.Column( linkify=True, ) - action = ChoiceFieldColumn() + action = columns.ChoiceFieldColumn() tags = columns.TagColumn( url_name="plugins:netbox_acls:aclstandardrule_list", ) + # Source + source_type = columns.ContentTypeColumn( + verbose_name=_("Source Type"), + ) + source = tables.Column( + verbose_name=_("Source"), + orderable=False, + linkify=True, + ) + class Meta(NetBoxTable.Meta): model = ACLStandardRule fields = ( @@ -152,14 +155,14 @@ class Meta(NetBoxTable.Meta): "remark", "tags", "description", - "source_prefix", + "source", ) default_columns = ( "access_list", "index", "action", "remark", - "source_prefix", + "source", "tags", ) @@ -175,11 +178,31 @@ class ACLExtendedRuleTable(NetBoxTable): index = tables.Column( linkify=True, ) - action = ChoiceFieldColumn() + action = columns.ChoiceFieldColumn() tags = columns.TagColumn( url_name="plugins:netbox_acls:aclextendedrule_list", ) - protocol = ChoiceFieldColumn() + protocol = columns.ChoiceFieldColumn() + + # Source + source_type = columns.ContentTypeColumn( + verbose_name=_("Source Type"), + ) + source = tables.Column( + verbose_name=_("Source"), + orderable=False, + linkify=True, + ) + + # Destination + destination_type = columns.ContentTypeColumn( + verbose_name=_("Destination Type"), + ) + destination = tables.Column( + verbose_name=_("Destination"), + orderable=False, + linkify=True, + ) class Meta(NetBoxTable.Meta): model = ACLExtendedRule @@ -192,9 +215,9 @@ class Meta(NetBoxTable.Meta): "remark", "tags", "description", - "source_prefix", + "source", "source_ports", - "destination_prefix", + "destination", "destination_ports", "protocol", ) @@ -203,10 +226,10 @@ class Meta(NetBoxTable.Meta): "index", "action", "remark", - "tags", - "source_prefix", + "protocol", + "source", "source_ports", - "destination_prefix", + "destination", "destination_ports", - "protocol", + "tags", ) diff --git a/netbox_acls/templates/inc/view_tab.html b/netbox_acls/templates/inc/view_tab.html index e86cb3b3..511ba2ba 100644 --- a/netbox_acls/templates/inc/view_tab.html +++ b/netbox_acls/templates/inc/view_tab.html @@ -1,30 +1,11 @@ -{% extends 'generic/object.html' %} -{% load buttons %} +{% extends 'generic/object_children.html' %} {% load helpers %} -{% load plugins %} -{% load render_table from django_tables2 %} +{% load i18n %} {% block extra_controls %} - {% if perms.netbox_todo.add_accesslist %} - - Add Access List + {% if perms.netbox_acls.add_accesslist %} + + {% trans "Assign an ACL" %} {% endif %} {% endblock extra_controls %} - -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal=table_config %} -
- {% csrf_token %} -
-
- {% include 'htmx/table.html' %} -
-
-
-{% endblock content %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} diff --git a/netbox_acls/templates/netbox_acls/accesslist.html b/netbox_acls/templates/netbox_acls/accesslist.html index 27528f69..1c050b15 100644 --- a/netbox_acls/templates/netbox_acls/accesslist.html +++ b/netbox_acls/templates/netbox_acls/accesslist.html @@ -42,10 +42,6 @@

Access List

{% endif %} - - Assigned Host - {{ object.assigned_object|linkify }} - {% include 'inc/panels/custom_fields.html' %} diff --git a/netbox_acls/templates/netbox_acls/aclinterfaceassignment.html b/netbox_acls/templates/netbox_acls/aclassignment.html similarity index 70% rename from netbox_acls/templates/netbox_acls/aclinterfaceassignment.html rename to netbox_acls/templates/netbox_acls/aclassignment.html index 677e2f9e..be174a4b 100644 --- a/netbox_acls/templates/netbox_acls/aclinterfaceassignment.html +++ b/netbox_acls/templates/netbox_acls/aclassignment.html @@ -3,33 +3,30 @@ {% block breadcrumbs %} {{ block.super }} - + {% endblock breadcrumbs %} {% block content %}
-

ACL Interface Assignment

+

ACL Assignment

- + + + + + - - - - - - - - diff --git a/netbox_acls/templates/netbox_acls/aclextendedrule.html b/netbox_acls/templates/netbox_acls/aclextendedrule.html index 3e2bad24..d40c0d42 100644 --- a/netbox_acls/templates/netbox_acls/aclextendedrule.html +++ b/netbox_acls/templates/netbox_acls/aclextendedrule.html @@ -45,16 +45,16 @@

Details

- - + + - - + + diff --git a/netbox_acls/templates/netbox_acls/aclstandardrule.html b/netbox_acls/templates/netbox_acls/aclstandardrule.html index 7d26f8ef..80129940 100644 --- a/netbox_acls/templates/netbox_acls/aclstandardrule.html +++ b/netbox_acls/templates/netbox_acls/aclstandardrule.html @@ -41,8 +41,8 @@

Details

- - + +
HostAccess List{{ object.access_list|linkify }}
Assigned Object {% if object.assigned_object.device %} - {{ object.assigned_object.device|linkify|placeholder }} - {% else %} - {{ object.assigned_object.virtual_machine|linkify|placeholder }} + {{ object.assigned_object.device|linkify }} / + {% elif object.assigned_object.virtual_machine %} + {{ object.assigned_object.virtual_machine|linkify }} / {% endif %} + {{ object.assigned_object|linkify|placeholder }}
Interface{{ object.assigned_object|linkify|placeholder }}
Access List{{ object.access_list|linkify }}
Direction {% badge object.get_direction_display bg_color=object.get_direction_color %} {{ object.get_protocol_display|placeholder }}
Source Prefix{{ object.source_prefix|linkify|placeholder }}Source{{ object.source|linkify|placeholder }}
Source Ports {{ object.source_ports|join:", "|placeholder }}
Destination Prefix{{ object.destination_prefix|linkify|placeholder }}Destination{{ object.destination|linkify|placeholder }}
Destination Ports {{ object.remark|placeholder }}
Source Prefix{{ object.source_prefix|linkify|placeholder }}Source{{ object.source|linkify|placeholder }}
diff --git a/netbox_acls/tests/api/test_access_list_rules.py b/netbox_acls/tests/api/test_access_list_rules.py index 21e4be4c..5e6e9251 100644 --- a/netbox_acls/tests/api/test_access_list_rules.py +++ b/netbox_acls/tests/api/test_access_list_rules.py @@ -1,7 +1,5 @@ -from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from ipam.models import Prefix from utilities.testing import APIViewTestCases -from virtualization.models import Cluster, ClusterType, VirtualMachine from netbox_acls.choices import ( ACLActionChoices, @@ -21,70 +19,22 @@ class ACLStandardRuleAPIViewTestCase(APIViewTestCases.APIViewTestCase): view_namespace = "plugins-api:netbox_acls" brief_fields = ["access_list", "display", "id", "index", "url"] user_permissions = ( - "dcim.view_site", - "dcim.view_manufacturer", - "dcim.view_devicetype", - "dcim.view_device", "ipam.view_prefix", - "virtualization.view_cluster", - "virtualization.view_clustergroup", - "virtualization.view_clustertype", - "virtualization.view_virtualmachine", "netbox_acls.view_accesslist", ) @classmethod def setUpTestData(cls): """Set up ACL Standard Rule for API view testing.""" - site = Site.objects.create( - name="Site 1", - slug="site-1", - ) - - # Device - manufacturer = Manufacturer.objects.create( - name="Manufacturer 1", - slug="manufacturer-1", - ) - device_type = DeviceType.objects.create( - manufacturer=manufacturer, - model="Device Type 1", - ) - device_role = DeviceRole.objects.create( - name="Device Role 1", - slug="device-role-1", - ) - device = Device.objects.create( - name="Device 1", - site=site, - device_type=device_type, - role=device_role, - ) - - # Virtual Machine - cluster_type = ClusterType.objects.create( - name="Cluster Type 1", - slug="cluster-type-1", - ) - cluster = Cluster.objects.create( - name="Cluster 1", - type=cluster_type, - ) - virtual_machine = VirtualMachine.objects.create( - name="VM 1", - cluster=cluster, - ) # AccessList access_list_device = AccessList.objects.create( name="testacl1", - assigned_object=device, type=ACLTypeChoices.TYPE_STANDARD, default_action=ACLActionChoices.ACTION_DENY, ) access_list_vm = AccessList.objects.create( name="testacl2", - assigned_object=virtual_machine, type=ACLTypeChoices.TYPE_STANDARD, default_action=ACLActionChoices.ACTION_PERMIT, ) @@ -103,7 +53,7 @@ def setUpTestData(cls): index=10, description="Rule 10", action=ACLRuleActionChoices.ACTION_PERMIT, - source_prefix=prefix1, + source=prefix1, ), ACLStandardRule( access_list=access_list_device, @@ -117,7 +67,7 @@ def setUpTestData(cls): index=10, description="Rule 10", action=ACLRuleActionChoices.ACTION_DENY, - source_prefix=prefix2, + source=prefix2, ), ) ACLStandardRule.objects.bulk_create(acl_standard_rules) @@ -128,14 +78,16 @@ def setUpTestData(cls): "index": 30, "description": "Rule 30", "action": ACLRuleActionChoices.ACTION_DENY, - "source_prefix": prefix2.id, + "source_type": "ipam.prefix", + "source_id": prefix2.id, }, { "access_list": access_list_vm.id, "index": 20, "description": "Rule 30", "action": ACLRuleActionChoices.ACTION_PERMIT, - "source_prefix": prefix1.id, + "source_type": "ipam.prefix", + "source_id": prefix1.id, }, { "access_list": access_list_vm.id, @@ -159,70 +111,22 @@ class ACLExtendedRuleAPIViewTestCase(APIViewTestCases.APIViewTestCase): view_namespace = "plugins-api:netbox_acls" brief_fields = ["access_list", "display", "id", "index", "url"] user_permissions = ( - "dcim.view_site", - "dcim.view_manufacturer", - "dcim.view_devicetype", - "dcim.view_device", "ipam.view_prefix", - "virtualization.view_cluster", - "virtualization.view_clustergroup", - "virtualization.view_clustertype", - "virtualization.view_virtualmachine", "netbox_acls.view_accesslist", ) @classmethod def setUpTestData(cls): """Set up ACL Extended Rule for API view testing.""" - site = Site.objects.create( - name="Site 1", - slug="site-1", - ) - - # Device - manufacturer = Manufacturer.objects.create( - name="Manufacturer 1", - slug="manufacturer-1", - ) - device_type = DeviceType.objects.create( - manufacturer=manufacturer, - model="Device Type 1", - ) - device_role = DeviceRole.objects.create( - name="Device Role 1", - slug="device-role-1", - ) - device = Device.objects.create( - name="Device 1", - site=site, - device_type=device_type, - role=device_role, - ) - - # Virtual Machine - cluster_type = ClusterType.objects.create( - name="Cluster Type 1", - slug="cluster-type-1", - ) - cluster = Cluster.objects.create( - name="Cluster 1", - type=cluster_type, - ) - virtual_machine = VirtualMachine.objects.create( - name="VM 1", - cluster=cluster, - ) # AccessList access_list_device = AccessList.objects.create( name="testacl1", - assigned_object=device, type=ACLTypeChoices.TYPE_EXTENDED, default_action=ACLActionChoices.ACTION_DENY, ) access_list_vm = AccessList.objects.create( name="testacl2", - assigned_object=virtual_machine, type=ACLTypeChoices.TYPE_EXTENDED, default_action=ACLActionChoices.ACTION_PERMIT, ) @@ -242,9 +146,9 @@ def setUpTestData(cls): description="Rule 10", action=ACLRuleActionChoices.ACTION_PERMIT, protocol=ACLProtocolChoices.PROTOCOL_TCP, - source_prefix=prefix1, + source=prefix1, source_ports=[22, 443], - destination_prefix=prefix1, + destination=prefix1, destination_ports=[22, 443], ), ACLExtendedRule( @@ -259,8 +163,8 @@ def setUpTestData(cls): index=10, description="Rule 10", action=ACLRuleActionChoices.ACTION_DENY, - source_prefix=prefix2, - destination_prefix=prefix1, + source=prefix2, + destination=prefix1, ), ) ACLExtendedRule.objects.bulk_create(acl_extended_rules) @@ -272,9 +176,11 @@ def setUpTestData(cls): "description": "Rule 30", "action": ACLRuleActionChoices.ACTION_DENY, "protocol": ACLProtocolChoices.PROTOCOL_UDP, - "source_prefix": prefix2.id, + "source_type": "ipam.prefix", + "source_id": prefix2.id, "source_ports": [53], - "destination_prefix": prefix2.id, + "destination_type": "ipam.prefix", + "destination_id": prefix2.id, "destination_ports": [53], }, { @@ -283,8 +189,10 @@ def setUpTestData(cls): "description": "Rule 30", "action": ACLRuleActionChoices.ACTION_PERMIT, "protocol": ACLProtocolChoices.PROTOCOL_ICMP, - "source_prefix": prefix1.id, - "destination_prefix": prefix2.id, + "source_type": "ipam.prefix", + "source_id": prefix1.id, + "destination_type": "ipam.prefix", + "destination_id": prefix2.id, }, { "access_list": access_list_vm.id, diff --git a/netbox_acls/tests/api/test_access_lists.py b/netbox_acls/tests/api/test_access_lists.py index 48639af7..776311a8 100644 --- a/netbox_acls/tests/api/test_access_lists.py +++ b/netbox_acls/tests/api/test_access_lists.py @@ -9,7 +9,7 @@ ACLAssignmentDirectionChoices, ACLTypeChoices, ) -from netbox_acls.models import AccessList, ACLInterfaceAssignment +from netbox_acls.models import AccessList, ACLAssignment class AccessListAPIViewTestCase(APIViewTestCases.APIViewTestCase): @@ -20,76 +20,23 @@ class AccessListAPIViewTestCase(APIViewTestCases.APIViewTestCase): model = AccessList view_namespace = "plugins-api:netbox_acls" brief_fields = ["display", "id", "name", "url"] - user_permissions = ( - "dcim.view_site", - "dcim.view_devicetype", - "dcim.view_device", - "virtualization.view_cluster", - "virtualization.view_clustergroup", - "virtualization.view_clustertype", - "virtualization.view_virtualmachine", - ) @classmethod def setUpTestData(cls): """Set up Access List for API view testing.""" - site = Site.objects.create( - name="Site 1", - slug="site-1", - ) - - # Device - manufacturer = Manufacturer.objects.create( - name="Manufacturer 1", - slug="manufacturer-1", - ) - device_type = DeviceType.objects.create( - manufacturer=manufacturer, - model="Device Type 1", - ) - device_role = DeviceRole.objects.create( - name="Device Role 1", - slug="device-role-1", - ) - device = Device.objects.create( - name="Device 1", - site=site, - device_type=device_type, - role=device_role, - ) - - # Virtual Machine - cluster_type = ClusterType.objects.create( - name="Cluster Type 1", - slug="cluster-type-1", - ) - cluster = Cluster.objects.create( - name="Cluster 1", - type=cluster_type, - ) - virtual_machine = VirtualMachine.objects.create( - name="VM 1", - cluster=cluster, - ) - access_lists = ( AccessList( name="testacl1", - assigned_object_type=ContentType.objects.get_for_model(Device), - assigned_object_id=device.id, type=ACLTypeChoices.TYPE_STANDARD, default_action=ACLActionChoices.ACTION_DENY, ), AccessList( name="testacl2", - assigned_object=device, type=ACLTypeChoices.TYPE_EXTENDED, default_action=ACLActionChoices.ACTION_PERMIT, ), AccessList( name="testacl3", - assigned_object_type=ContentType.objects.get_for_model(VirtualMachine), - assigned_object_id=virtual_machine.id, type=ACLTypeChoices.TYPE_EXTENDED, default_action=ACLActionChoices.ACTION_DENY, ), @@ -99,22 +46,16 @@ def setUpTestData(cls): cls.create_data = [ { "name": "testacl4", - "assigned_object_type": "dcim.device", - "assigned_object_id": device.id, "type": ACLTypeChoices.TYPE_STANDARD, "default_action": ACLActionChoices.ACTION_DENY, }, { "name": "testacl5", - "assigned_object_type": "dcim.device", - "assigned_object_id": device.id, "type": ACLTypeChoices.TYPE_EXTENDED, "default_action": ACLActionChoices.ACTION_DENY, }, { "name": "testacl6", - "assigned_object_type": "virtualization.virtualmachine", - "assigned_object_id": virtual_machine.id, "type": ACLTypeChoices.TYPE_STANDARD, "default_action": ACLActionChoices.ACTION_PERMIT, }, @@ -124,12 +65,12 @@ def setUpTestData(cls): } -class ACLInterfaceAssignmentAPIViewTestCase(APIViewTestCases.APIViewTestCase): +class ACLAssignmentAPIViewTestCase(APIViewTestCases.APIViewTestCase): """ - API view test case for ACLInterfaceAssignment. + API view test case for ACLAssignment. """ - model = ACLInterfaceAssignment + model = ACLAssignment view_namespace = "plugins-api:netbox_acls" brief_fields = ["access_list", "display", "id", "url"] user_permissions = ( @@ -215,55 +156,53 @@ def setUpTestData(cls): ) # AccessList - access_list_device = AccessList.objects.create( + acl1 = AccessList.objects.create( name="testacl1", - assigned_object=device, type=ACLTypeChoices.TYPE_STANDARD, default_action=ACLActionChoices.ACTION_DENY, ) - access_list_vm = AccessList.objects.create( + acl2 = AccessList.objects.create( name="testacl2", - assigned_object=virtual_machine, type=ACLTypeChoices.TYPE_EXTENDED, default_action=ACLActionChoices.ACTION_PERMIT, ) - acl_interface_assignments = ( - ACLInterfaceAssignment( - access_list=access_list_device, + acl_assignments = ( + ACLAssignment( + access_list=acl1, direction=ACLAssignmentDirectionChoices.DIRECTION_INGRESS, assigned_object_type=ContentType.objects.get_for_model(Interface), assigned_object_id=device_interface1.id, ), - ACLInterfaceAssignment( - access_list=access_list_device, + ACLAssignment( + access_list=acl1, direction=ACLAssignmentDirectionChoices.DIRECTION_EGRESS, assigned_object=device_interface2, ), - ACLInterfaceAssignment( - access_list=access_list_vm, + ACLAssignment( + access_list=acl2, direction=ACLAssignmentDirectionChoices.DIRECTION_EGRESS, assigned_object_type=ContentType.objects.get_for_model(VMInterface), assigned_object_id=virtual_machine_interface1.id, ), ) - ACLInterfaceAssignment.objects.bulk_create(acl_interface_assignments) + ACLAssignment.objects.bulk_create(acl_assignments) cls.create_data = [ { - "access_list": access_list_device.id, + "access_list": acl1.id, "assigned_object_type": "dcim.interface", "assigned_object_id": device_interface3.id, "direction": ACLAssignmentDirectionChoices.DIRECTION_EGRESS, }, { - "access_list": access_list_vm.id, + "access_list": acl2.id, "assigned_object_type": "virtualization.vminterface", "assigned_object_id": virtual_machine_interface2.id, "direction": ACLAssignmentDirectionChoices.DIRECTION_INGRESS, }, { - "access_list": access_list_vm.id, + "access_list": acl2.id, "assigned_object_type": "virtualization.vminterface", "assigned_object_id": virtual_machine_interface3.id, "direction": ACLAssignmentDirectionChoices.DIRECTION_EGRESS, diff --git a/netbox_acls/tests/models/base.py b/netbox_acls/tests/models/base.py index 55d5d0b5..7034ab3c 100644 --- a/netbox_acls/tests/models/base.py +++ b/netbox_acls/tests/models/base.py @@ -7,7 +7,8 @@ VirtualChassis, ) from django.test import TestCase -from ipam.models import Prefix +from ipam.models import RIR, Aggregate, IPAddress, IPRange, Prefix +from netaddr import IPNetwork from virtualization.models import Cluster, ClusterType, VirtualMachine @@ -20,12 +21,18 @@ class BaseTestCase(TestCase): def setUpTestData(cls): """ Create base data to test using including - - 1 of each of the following: test site, manufacturer, device type + - 1 of each of the following: rir, site, manufacturer, device type, device role, cluster type, cluster, virtual chassis, and virtual machine - - 2 of each Device, prefix + - 2 of each device, aggregate, ip address, ip range, prefix, service """ + # RIR + rir = RIR.objects.create( + name="RIR 1", + slug="rir-1", + ) + # Sites site = Site.objects.create( name="Site 1", @@ -108,10 +115,38 @@ def setUpTestData(cls): cluster=cluster, ) + # Aggregate + cls.aggregate1 = Aggregate.objects.create( + prefix=IPNetwork("10.0.0.0/8"), + rir=rir, + ) + cls.aggregate2 = Aggregate.objects.create( + prefix=IPNetwork("172.16.0.0/12"), + rir=rir, + ) + + # IP Address + cls.ip_address1 = IPAddress.objects.create( + address=IPNetwork("10.0.0.1/24"), + ) + cls.ip_address2 = IPAddress.objects.create( + address=IPNetwork("10.0.0.2/24"), + ) + + # IP Range + cls.ip_range1 = IPRange.objects.create( + start_address=IPNetwork("10.0.1.1/24"), + end_address=IPNetwork("10.0.1.254/24"), + ) + cls.ip_range2 = IPRange.objects.create( + start_address=IPNetwork("10.0.2.1/24"), + end_address=IPNetwork("10.0.2.254/24"), + ) + # Prefix cls.prefix1 = Prefix.objects.create( - prefix="10.1.0.0/16", + prefix=IPNetwork("10.1.0.0/16"), ) cls.prefix2 = Prefix.objects.create( - prefix="10.2.0.0/16", + prefix=IPNetwork("10.2.0.0/16"), ) diff --git a/netbox_acls/tests/models/test_accesslists.py b/netbox_acls/tests/models/test_accesslists.py index 579bd719..7bf20935 100644 --- a/netbox_acls/tests/models/test_accesslists.py +++ b/netbox_acls/tests/models/test_accesslists.py @@ -1,10 +1,6 @@ from itertools import cycle -from dcim.models import Device, VirtualChassis -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError -from ipam.models import Prefix -from virtualization.models import VirtualMachine from netbox_acls.models import AccessList @@ -29,7 +25,6 @@ def test_accesslist_standard_creation(self): created_acl = AccessList( name=acl_name, - assigned_object=self.device1, type="standard", default_action="deny", ) @@ -38,8 +33,6 @@ def test_accesslist_standard_creation(self): self.assertEqual(created_acl.name, acl_name) self.assertEqual(created_acl.type, "standard") self.assertEqual(created_acl.default_action, "deny") - self.assertEqual(isinstance(created_acl.assigned_object, Device), True) - self.assertEqual(created_acl.assigned_object, self.device1) def test_accesslist_extended_creation(self): """ @@ -49,7 +42,6 @@ def test_accesslist_extended_creation(self): created_acl = AccessList( name=acl_name, - assigned_object=self.device2, type="extended", default_action="permit", ) @@ -58,60 +50,6 @@ def test_accesslist_extended_creation(self): self.assertEqual(created_acl.name, acl_name) self.assertEqual(created_acl.type, "extended") self.assertEqual(created_acl.default_action, "permit") - self.assertEqual(isinstance(created_acl.assigned_object, Device), True) - self.assertEqual(created_acl.assigned_object, self.device2) - - def test_accesslist_creation_with_virtual_chassis(self): - """ - Test that AccessList creation with an assigned virtual chassis passes validation. - """ - acl_name = "Test-ACL-with-Virtual-Machine" - - created_acl = AccessList( - name=acl_name, - assigned_object=self.virtual_chassis1, - **self.common_acl_params, - ) - - self.assertTrue(isinstance(created_acl, AccessList)) - self.assertEqual(created_acl.name, acl_name) - self.assertEqual(created_acl.type, "extended") - self.assertEqual(created_acl.default_action, "permit") - self.assertEqual(isinstance(created_acl.assigned_object, VirtualChassis), True) - self.assertEqual(created_acl.assigned_object, self.virtual_chassis1) - - def test_accesslist_creation_with_virtual_machine(self): - """ - Test that AccessList creation with an assigned virtual machine passes validation. - """ - acl_name = "Test-ACL-with-Virtual-Machine" - - created_acl = AccessList( - name=acl_name, - assigned_object=self.virtual_machine1, - **self.common_acl_params, - ) - - self.assertTrue(isinstance(created_acl, AccessList)) - self.assertEqual(created_acl.name, acl_name) - self.assertEqual(created_acl.type, "extended") - self.assertEqual(created_acl.default_action, "permit") - self.assertEqual(isinstance(created_acl.assigned_object, VirtualMachine), True) - self.assertEqual(created_acl.assigned_object, self.virtual_machine1) - - def test_wrong_assigned_object_type_fail(self): - """ - Test that AccessList cannot be assigned to an object type other than Device, VirtualChassis, VirtualMachine, - or Cluster. - """ - acl_bad_gfk = AccessList( - name="TestACL_Wrong_GFK", - assigned_object_type=ContentType.objects.get_for_model(Prefix), - assigned_object_id=self.prefix1.id, - **self.common_acl_params, - ) - with self.assertRaises(ValidationError): - acl_bad_gfk.full_clean() def test_alphanumeric_plus_success(self): """ @@ -119,104 +57,41 @@ def test_alphanumeric_plus_success(self): """ acl_good_name = AccessList( name="Test-ACL-Good_Name-1", - assigned_object_type=ContentType.objects.get_for_model(Device), - assigned_object_id=self.device1.id, **self.common_acl_params, ) acl_good_name.full_clean() def test_duplicate_name_success(self): """ - Test that AccessList names can be non-unique if associated with different devices. + Test that AccessList names can be non-unique. """ - # Device - device_acl = AccessList( + acl1 = AccessList( name="GOOD-DUPLICATE-ACL", - assigned_object=self.device1, **self.common_acl_params, ) - device_acl.full_clean() + acl1.full_clean() - # Virtual Chassis - vc_acl = AccessList( + acl2 = AccessList( name="GOOD-DUPLICATE-ACL", - assigned_object=self.virtual_chassis1, **self.common_acl_params, ) - vc_acl.full_clean() - - # Virtual Machine - vm_acl = AccessList( - name="GOOD-DUPLICATE-ACL", - assigned_object=self.virtual_machine1, - **self.common_acl_params, - ) - vm_acl.full_clean() + acl2.full_clean() def test_alphanumeric_plus_fail(self): """ Test that AccessList names with non-alphanumeric (excluding '_' and '-') characters fail validation. """ - non_alphanumeric_plus_chars = " !@#$%^&*()[]{};:,./<>?\|~=+" + non_alphanumeric_plus_chars = r" !@#$%^&*()[]{};:,./<>?\|~=+" for i, char in enumerate(non_alphanumeric_plus_chars, start=1): bad_acl_name = AccessList( name=f"Test-ACL-bad_name_{i}_{char}", - assigned_object=self.device1, comments=f'ACL with "{char}" in name', **self.common_acl_params, ) with self.assertRaises(ValidationError): bad_acl_name.full_clean() - def test_duplicate_name_per_device_fail(self): - """ - Test that AccessList names must be unique per device. - """ - params = { - "name": "FAIL-DUPLICATE-ACL", - "assigned_object_type": ContentType.objects.get_for_model(Device), - "assigned_object_id": self.device1.id, - **self.common_acl_params, - } - acl_1 = AccessList.objects.create(**params) - acl_1.save() - acl_2 = AccessList(**params) - with self.assertRaises(ValidationError): - acl_2.full_clean() - - def test_duplicate_name_per_virtual_chassis_fail(self): - """ - Test that AccessList names must be unique per virtual chassis. - """ - params = { - "name": "FAIL-DUPLICATE-ACL", - "assigned_object_type": ContentType.objects.get_for_model(VirtualChassis), - "assigned_object_id": self.virtual_chassis1.id, - **self.common_acl_params, - } - acl_1 = AccessList.objects.create(**params) - acl_1.save() - acl_2 = AccessList(**params) - with self.assertRaises(ValidationError): - acl_2.full_clean() - - def test_duplicate_name_per_virtual_machine_fail(self): - """ - Test that AccessList names must be unique per virtual machine. - """ - params = { - "name": "FAIL-DUPLICATE-ACL", - "assigned_object_type": ContentType.objects.get_for_model(VirtualMachine), - "assigned_object_id": self.virtual_machine1.id, - **self.common_acl_params, - } - acl_1 = AccessList.objects.create(**params) - acl_1.save() - acl_2 = AccessList(**params) - with self.assertRaises(ValidationError): - acl_2.full_clean() - def test_valid_acl_choices(self): """ Test that AccessList action choices using VALID choices. @@ -233,7 +108,6 @@ def test_valid_acl_choices(self): for default_action, acl_type in valid_acl_choices: valid_acl_choice = AccessList( name=f"TestACL_Valid_Choice_{default_action}_{acl_type}", - assigned_object=self.device1, type=acl_type, default_action=default_action, comments=f"VALID ACL CHOICES USED: {default_action=} {acl_type=}", @@ -248,7 +122,6 @@ def test_invalid_acl_choices(self): invalid_acl_default_action_choice = "log" invalid_acl_default_action = AccessList( name=f"TestACL_Valid_Choice_{invalid_acl_default_action_choice}_{valid_acl_types[0]}", - assigned_object=self.device1, type=valid_acl_types[0], default_action=invalid_acl_default_action_choice, comments=f"INVALID ACL DEFAULT CHOICE USED: default_action='{invalid_acl_default_action_choice}'", @@ -260,7 +133,6 @@ def test_invalid_acl_choices(self): invalid_acl_type = "super-dupper-extended" invalid_acl_type = AccessList( name=f"TestACL_Valid_Choice_{valid_acl_default_action_choices[0]}_{invalid_acl_type}", - assigned_object=self.device1, type=invalid_acl_type, default_action=valid_acl_default_action_choices[0], comments=f"INVALID ACL DEFAULT CHOICE USED: type='{invalid_acl_type}'", diff --git a/netbox_acls/tests/models/test_aclassignments.py b/netbox_acls/tests/models/test_aclassignments.py new file mode 100644 index 00000000..e051f18d --- /dev/null +++ b/netbox_acls/tests/models/test_aclassignments.py @@ -0,0 +1,244 @@ +from django.core.exceptions import ValidationError +from virtualization.models import VirtualMachine +from dcim.models import Device, VirtualChassis, Interface +from virtualization.models import VMInterface + +from netbox_acls.models import AccessList, ACLAssignment + +from .base import BaseTestCase + + +class TestACLAssignment(BaseTestCase): + """ + Test ACLAssignment model. + """ + + @classmethod + def setUpTestData(cls): + """ + Extend BaseTestCase's setUpTestData() to create additional data for testing. + """ + super().setUpTestData() + + interface_type = "1000baset" + + # Device Interfaces + cls.device_interface1 = Interface.objects.create( + name="Interface 1", + device=cls.device1, + type=interface_type, + ) + cls.device_interface2 = Interface.objects.create( + name="Interface 2", + device=cls.device1, + type=interface_type, + ) + + # Virtual Machine Interfaces + cls.vm_interface1 = VMInterface.objects.create( + name="Interface 1", + virtual_machine=cls.virtual_machine1, + ) + cls.vm_interface2 = VMInterface.objects.create( + name="Interface 2", + virtual_machine=cls.virtual_machine1, + ) + + # Standard ACLs + cls.acl_standard1 = AccessList.objects.create( + name="STANDARD_ACL_1", + type="standard", + default_action="permit", + comments="STANDARD_ACL", + ) + cls.acl_standard2 = AccessList.objects.create( + name="STANDARD_ACL_2", + type="standard", + default_action="permit", + comments="STANDARD_ACL", + ) + + # Extended ACLs + cls.acl_extended1 = AccessList.objects.create( + name="EXTENDED_ACL_1", + type="extended", + default_action="permit", + comments="EXTENDED_ACL", + ) + cls.acl_extended2 = AccessList.objects.create( + name="EXTENDED_ACL_2", + type="extended", + default_action="permit", + comments="EXTENDED_ACL", + ) + + def test_acl_assignment_success(self): + """ + Test that ACLAssignment passes validation if the ACL is assigned to the host + and not already assigned to the interface and direction. + """ + acl_device_interface = ACLAssignment( + access_list=self.acl_standard1, + direction="ingress", + assigned_object=self.device_interface1, + ) + acl_device_interface.full_clean() + + def test_acl_assignment_with_device(self): + """ + Test that ACLAssignment creation with an assigned device passes validation. + """ + device_assignment = ACLAssignment( + access_list=self.acl_standard1, + direction="ingress", + assigned_object=self.device1, + ) + + self.assertTrue(isinstance(device_assignment, ACLAssignment)) + self.assertEqual(device_assignment.access_list.name, self.acl_standard1.name) + self.assertEqual(isinstance(device_assignment.assigned_object, Device), True) + self.assertEqual(device_assignment.assigned_object, self.device1) + self.assertEqual(device_assignment.direction, "ingress") + + def test_acl_assignment_with_device_interface(self): + """ + Test that ACLAssignment creation with an assigned interface passes validation. + """ + interface_assignment = ACLAssignment( + access_list=self.acl_standard1, + direction="ingress", + assigned_object=self.device_interface1, + ) + + self.assertTrue(isinstance(interface_assignment, ACLAssignment)) + self.assertEqual(interface_assignment.access_list.name, self.acl_standard1.name) + self.assertEqual(isinstance(interface_assignment.assigned_object, Interface), True) + self.assertEqual(interface_assignment.assigned_object, self.device_interface1) + self.assertEqual(interface_assignment.direction, "ingress") + + def test_acl_assignment_with_virtual_chassis(self): + """ + Test that ACLAssignment creation with an assigned virtual chassis passes validation. + """ + virtual_chassis_assignment = ACLAssignment( + access_list=self.acl_standard1, + direction="ingress", + assigned_object=self.virtual_chassis1, + ) + + self.assertTrue(isinstance(virtual_chassis_assignment, ACLAssignment)) + self.assertEqual(virtual_chassis_assignment.access_list.name, self.acl_standard1.name) + self.assertEqual(isinstance(virtual_chassis_assignment.assigned_object, VirtualChassis), True) + self.assertEqual(virtual_chassis_assignment.assigned_object, self.virtual_chassis1) + self.assertEqual(virtual_chassis_assignment.direction, "ingress") + + def test_acl_assignment_with_virtual_machine(self): + """ + Test that ACLAssignment creation with an assigned virtual machine passes validation. + """ + virtual_machine_assignment = ACLAssignment( + access_list=self.acl_standard1, + direction="ingress", + assigned_object=self.virtual_machine1, + ) + + self.assertTrue(isinstance(virtual_machine_assignment, ACLAssignment)) + self.assertEqual(virtual_machine_assignment.access_list.name, self.acl_standard1.name) + self.assertEqual(isinstance(virtual_machine_assignment.assigned_object, VirtualMachine), True) + self.assertEqual(virtual_machine_assignment.assigned_object, self.virtual_machine1) + self.assertEqual(virtual_machine_assignment.direction, "ingress") + + def test_acl_assignment_with_virtual_machine_interface(self): + """ + Test that ACLAssignment creation with an assigned virtual machine interface passes validation. + """ + virtual_machine_interface_assignment = ACLAssignment( + access_list=self.acl_standard1, + direction="ingress", + assigned_object=self.vm_interface1, + ) + + self.assertTrue(isinstance(virtual_machine_interface_assignment, ACLAssignment)) + self.assertEqual(virtual_machine_interface_assignment.access_list.name, self.acl_standard1.name) + self.assertEqual(isinstance(virtual_machine_interface_assignment.assigned_object, VMInterface), True) + self.assertEqual(virtual_machine_interface_assignment.assigned_object, self.vm_interface1) + self.assertEqual(virtual_machine_interface_assignment.direction, "ingress") + + def test_acl_assignment_with_duplicate_name_per_device_fail(self): + """ + Test that AccessList names must be unique per device. + """ + acl1_assignment = ACLAssignment( + access_list=self.acl_standard1, + direction="none", + assigned_object=self.device1, + ) + acl1_assignment.full_clean() + acl1_assignment.save() + + acl2 = AccessList( + name="STANDARD_ACL_1", + type="standard", + default_action="permit", + comments="Same Name ACL", + ) + acl2.save() + acl2_assignment = ACLAssignment( + access_list=acl2, + direction="none", + assigned_object=self.device1, + ) + + with self.assertRaises(ValidationError): + acl2_assignment.full_clean() + acl2_assignment.save() + + def test_acl_assignment_with_duplicate_interface_and_direction_fail(self): + """ + Test that ACLAssignment fails validation + if the ACL already is assigned to the same interface and direction. + """ + acl_device_interface1 = ACLAssignment( + access_list=self.acl_standard1, + direction="ingress", + assigned_object=self.device_interface1, + ) + acl_device_interface1.full_clean() + acl_device_interface1.save() + acl_device_interface2 = ACLAssignment( + access_list=self.acl_standard1, + direction="ingress", + assigned_object=self.device_interface1, + ) + with self.assertRaises(ValidationError): + acl_device_interface2.full_clean() + + def test_valid_acl_interface_assignment_choices(self): + """ + Test that ACLAssignment action choices using VALID choices. + """ + valid_acl_assignment_direction_choices = ["ingress", "egress"] + + for direction_choice in valid_acl_assignment_direction_choices: + valid_acl_assignment = ACLAssignment( + access_list=self.acl_standard1, + direction=direction_choice, + assigned_object=self.device_interface1, + comments=f"VALID ACL ASSIGNMENT CHOICES USED: direction={direction_choice}", + ) + valid_acl_assignment.full_clean() + + def test_invalid_acl_choices(self): + """ + Test that ACLAssignment action choices using INVALID choices. + """ + invalid_acl_assignment_direction_choice = "both" + + invalid_acl_assignment_direction = ACLAssignment( + access_list=self.acl_standard1, + direction=invalid_acl_assignment_direction_choice, + assigned_object=self.device_interface1, + comments=f"INVALID ACL DEFAULT CHOICE USED: default_action='{invalid_acl_assignment_direction_choice}'", + ) + with self.assertRaises(ValidationError): + invalid_acl_assignment_direction.full_clean() diff --git a/netbox_acls/tests/models/test_aclinterfaceassignments.py b/netbox_acls/tests/models/test_aclinterfaceassignments.py deleted file mode 100644 index bd2cb0f9..00000000 --- a/netbox_acls/tests/models/test_aclinterfaceassignments.py +++ /dev/null @@ -1,190 +0,0 @@ -from dcim.models import Interface -from django.core.exceptions import ValidationError -from virtualization.models import VMInterface - -from netbox_acls.models import AccessList, ACLInterfaceAssignment - -from .base import BaseTestCase - - -class TestACLInterfaceAssignment(BaseTestCase): - """ - Test ACLInterfaceAssignment model. - """ - - @classmethod - def setUpTestData(cls): - """ - Extend BaseTestCase's setUpTestData() to create additional data for testing. - """ - super().setUpTestData() - - interface_type = "1000baset" - - # Device Interfaces - cls.device_interface1 = Interface.objects.create( - name="Interface 1", - device=cls.device1, - type=interface_type, - ) - cls.device_interface2 = Interface.objects.create( - name="Interface 2", - device=cls.device1, - type=interface_type, - ) - - # Virtual Machine Interfaces - cls.vm_interface1 = VMInterface.objects.create( - name="Interface 1", - virtual_machine=cls.virtual_machine1, - ) - cls.vm_interface2 = VMInterface.objects.create( - name="Interface 2", - virtual_machine=cls.virtual_machine1, - ) - - def test_acl_interface_assignment_success(self): - """ - Test that ACLInterfaceAssignment passes validation if the ACL is assigned to the host - and not already assigned to the interface and direction. - """ - device_acl = AccessList( - name="STANDARD_ACL", - assigned_object=self.device1, - type="standard", - default_action="permit", - comments="STANDARD_ACL", - ) - device_acl.save() - acl_device_interface = ACLInterfaceAssignment( - access_list=device_acl, - direction="ingress", - assigned_object=self.device_interface1, - ) - acl_device_interface.full_clean() - - def test_acl_interface_assignment_fail(self): - """ - Test that ACLInterfaceAssignment fails validation if the ACL is not - assigned to the parent host. - """ - device_acl = AccessList( - name="STANDARD_ACL", - assigned_object=self.device1, - type="standard", - default_action="permit", - comments="STANDARD_ACL", - ) - device_acl.save() - acl_vm_interface = ACLInterfaceAssignment( - access_list=device_acl, - direction="ingress", - assigned_object=self.vm_interface1, - ) - with self.assertRaises(ValidationError): - acl_vm_interface.full_clean() - acl_vm_interface.save() - - def test_acl_vminterface_assignment_success(self): - """ - Test that ACLInterfaceAssignment passes validation if the ACL is assigned to the host - and not already assigned to the vminterface and direction. - """ - vm_acl = AccessList( - name="STANDARD_ACL", - assigned_object=self.virtual_machine1, - type="standard", - default_action="permit", - comments="STANDARD_ACL", - ) - vm_acl.save() - acl_vm_interface = ACLInterfaceAssignment( - access_list=vm_acl, - direction="ingress", - assigned_object=self.vm_interface1, - ) - acl_vm_interface.full_clean() - - def test_duplicate_assignment_fail(self): - """ - Test that ACLInterfaceAssignment fails validation - if the ACL already is assigned to the same interface and direction. - """ - device_acl = AccessList( - name="STANDARD_ACL", - assigned_object=self.device1, - type="standard", - default_action="permit", - comments="STANDARD_ACL", - ) - device_acl.save() - acl_device_interface1 = ACLInterfaceAssignment( - access_list=device_acl, - direction="ingress", - assigned_object=self.device_interface1, - ) - acl_device_interface1.full_clean() - acl_device_interface1.save() - acl_device_interface2 = ACLInterfaceAssignment( - access_list=device_acl, - direction="ingress", - assigned_object=self.device_interface1, - ) - with self.assertRaises(ValidationError): - acl_device_interface2.full_clean() - - def test_acl_already_assigned_fail(self): - """ - Test that ACLInterfaceAssignment fails validation - if the interface already has an ACL assigned in the same direction. - """ - pass - # TODO: test_acl_already_assigned_fail - VM & Device - - def test_valid_acl_interface_assignment_choices(self): - """ - Test that ACLInterfaceAssignment action choices using VALID choices. - """ - valid_acl_assignment_direction_choices = ["ingress", "egress"] - - test_acl = AccessList( - name="STANDARD_ACL", - assigned_object=self.device1, - type="standard", - default_action="permit", - comments="STANDARD_ACL", - ) - test_acl.save() - - for direction_choice in valid_acl_assignment_direction_choices: - valid_acl_assignment = ACLInterfaceAssignment( - access_list=test_acl, - direction=direction_choice, - assigned_object=self.device_interface1, - comments=f"VALID ACL ASSIGNMENT CHOICES USED: direction={direction_choice}", - ) - valid_acl_assignment.full_clean() - - def test_invalid_acl_choices(self): - """ - Test that ACLInterfaceAssignment action choices using INVALID choices. - """ - invalid_acl_assignment_direction_choice = "both" - - test_acl = AccessList( - name="STANDARD_ACL", - assigned_object=self.device1, - type="standard", - default_action="permit", - comments="STANDARD_ACL", - ) - test_acl.save() - - invalid_acl_assignment_direction = ACLInterfaceAssignment( - access_list=test_acl, - direction=invalid_acl_assignment_direction_choice, - assigned_object=self.device_interface1, - comments=f"INVALID ACL DEFAULT CHOICE USED: default_action='{invalid_acl_assignment_direction_choice}'", - ) - with self.assertRaises(ValidationError): - invalid_acl_assignment_direction.full_clean() diff --git a/netbox_acls/tests/models/test_extendedrules.py b/netbox_acls/tests/models/test_extendedrules.py index b84813b1..4867fcc3 100644 --- a/netbox_acls/tests/models/test_extendedrules.py +++ b/netbox_acls/tests/models/test_extendedrules.py @@ -25,14 +25,12 @@ def setUpTestData(cls): # AccessLists cls.extended_acl1 = AccessList.objects.create( name="EXTENDED_ACL", - assigned_object=cls.device1, type=cls.acl_type, default_action=cls.default_action, comments="EXTENDED_ACL", ) cls.extended_acl2 = AccessList.objects.create( name="EXTENDED_ACL", - assigned_object=cls.virtual_machine1, type=cls.acl_type, default_action=cls.default_action, comments="EXTENDED_ACL", @@ -47,14 +45,14 @@ def test_acl_extended_rule_creation_success(self): index=10, action="permit", remark="", - source_prefix=None, + source=None, source_ports=None, - destination_prefix=None, + destination=None, destination_ports=None, protocol=None, description=( - "Created rule with any source prefix, any source port, " - "any destination prefix, any destination port, and any protocol." + "Created rule with any source, any source port, " + "any destination, any destination port, and any protocol." ), ) created_rule.full_clean() @@ -63,18 +61,108 @@ def test_acl_extended_rule_creation_success(self): self.assertEqual(created_rule.index, 10) self.assertEqual(created_rule.action, "permit") self.assertEqual(created_rule.remark, "") - self.assertEqual(created_rule.source_prefix, None) + self.assertEqual(created_rule.source, None) self.assertEqual(created_rule.source_ports, None) - self.assertEqual(created_rule.destination_prefix, None) + self.assertEqual(created_rule.destination, None) self.assertEqual(created_rule.destination_ports, None) self.assertEqual(created_rule.protocol, None) self.assertEqual( created_rule.description, - ( - "Created rule with any source prefix, any source port, " - "any destination prefix, any destination port, and any protocol." - ), + ("Created rule with any source, any source port, any destination, any destination port, and any protocol."), + ) + self.assertEqual(isinstance(created_rule.access_list, AccessList), True) + self.assertEqual(created_rule.access_list.type, self.acl_type) + + def test_acl_extended_rule_source_aggregate_creation_success(self): + """ + Test that ACLExtendedRule with source aggregate creation passes validation. + """ + created_rule = ACLExtendedRule( + access_list=self.extended_acl1, + index=20, + action="permit", + remark="", + source=self.aggregate1, + source_ports=None, + destination=None, + destination_ports=None, + protocol=None, + description="Created rule with source aggregate", + ) + created_rule.full_clean() + + self.assertTrue(isinstance(created_rule, ACLExtendedRule), True) + self.assertEqual(created_rule.index, 20) + self.assertEqual(created_rule.action, "permit") + self.assertEqual(created_rule.remark, "") + self.assertEqual(created_rule.source, self.aggregate1) + self.assertEqual(created_rule.source_ports, None) + self.assertEqual(created_rule.destination, None) + self.assertEqual(created_rule.destination_ports, None) + self.assertEqual(created_rule.protocol, None) + self.assertEqual(created_rule.description, "Created rule with source aggregate") + self.assertEqual(isinstance(created_rule.access_list, AccessList), True) + self.assertEqual(created_rule.access_list.type, self.acl_type) + + def test_acl_extended_rule_source_ip_address_creation_success(self): + """ + Test that ACLExtendedRule with source ip address creation passes validation. + """ + created_rule = ACLExtendedRule( + access_list=self.extended_acl1, + index=30, + action="permit", + remark="", + source=self.ip_address1, + source_ports=None, + destination=None, + destination_ports=None, + protocol=None, + description="Created rule with source ip address", + ) + created_rule.full_clean() + + self.assertTrue(isinstance(created_rule, ACLExtendedRule), True) + self.assertEqual(created_rule.index, 30) + self.assertEqual(created_rule.action, "permit") + self.assertEqual(created_rule.remark, "") + self.assertEqual(created_rule.source, self.ip_address1) + self.assertEqual(created_rule.source_ports, None) + self.assertEqual(created_rule.destination, None) + self.assertEqual(created_rule.destination_ports, None) + self.assertEqual(created_rule.protocol, None) + self.assertEqual(created_rule.description, "Created rule with source ip address") + self.assertEqual(isinstance(created_rule.access_list, AccessList), True) + self.assertEqual(created_rule.access_list.type, self.acl_type) + + def test_acl_extended_rule_source_ip_range_creation_success(self): + """ + Test that ACLExtendedRule with source ip range creation passes validation. + """ + created_rule = ACLExtendedRule( + access_list=self.extended_acl1, + index=40, + action="permit", + remark="", + source=self.ip_range1, + source_ports=None, + destination=None, + destination_ports=None, + protocol=None, + description="Created rule with source ip range", ) + created_rule.full_clean() + + self.assertTrue(isinstance(created_rule, ACLExtendedRule), True) + self.assertEqual(created_rule.index, 40) + self.assertEqual(created_rule.action, "permit") + self.assertEqual(created_rule.remark, "") + self.assertEqual(created_rule.source, self.ip_range1) + self.assertEqual(created_rule.source_ports, None) + self.assertEqual(created_rule.destination, None) + self.assertEqual(created_rule.destination_ports, None) + self.assertEqual(created_rule.protocol, None) + self.assertEqual(created_rule.description, "Created rule with source ip range") self.assertEqual(isinstance(created_rule.access_list, AccessList), True) self.assertEqual(created_rule.access_list.type, self.acl_type) @@ -84,12 +172,12 @@ def test_acl_extended_rule_source_prefix_creation_success(self): """ created_rule = ACLExtendedRule( access_list=self.extended_acl1, - index=20, + index=50, action="permit", remark="", - source_prefix=self.prefix1, + source=self.prefix1, source_ports=None, - destination_prefix=None, + destination=None, destination_ports=None, protocol=None, description="Created rule with source prefix", @@ -97,12 +185,12 @@ def test_acl_extended_rule_source_prefix_creation_success(self): created_rule.full_clean() self.assertTrue(isinstance(created_rule, ACLExtendedRule), True) - self.assertEqual(created_rule.index, 20) + self.assertEqual(created_rule.index, 50) self.assertEqual(created_rule.action, "permit") self.assertEqual(created_rule.remark, "") - self.assertEqual(created_rule.source_prefix, self.prefix1) + self.assertEqual(created_rule.source, self.prefix1) self.assertEqual(created_rule.source_ports, None) - self.assertEqual(created_rule.destination_prefix, None) + self.assertEqual(created_rule.destination, None) self.assertEqual(created_rule.destination_ports, None) self.assertEqual(created_rule.protocol, None) self.assertEqual(created_rule.description, "Created rule with source prefix") @@ -115,12 +203,12 @@ def test_acl_extended_rule_source_ports_creation_success(self): """ created_rule = ACLExtendedRule( access_list=self.extended_acl1, - index=30, + index=70, action="permit", remark="", - source_prefix=self.prefix1, + source=self.prefix1, source_ports=[22, 443], - destination_prefix=None, + destination=None, destination_ports=None, protocol=self.protocol, description="Created rule with source ports", @@ -128,30 +216,123 @@ def test_acl_extended_rule_source_ports_creation_success(self): created_rule.full_clean() self.assertTrue(isinstance(created_rule, ACLExtendedRule), True) - self.assertEqual(created_rule.index, 30) + self.assertEqual(created_rule.index, 70) self.assertEqual(created_rule.action, "permit") self.assertEqual(created_rule.remark, "") - self.assertEqual(created_rule.source_prefix, self.prefix1) + self.assertEqual(created_rule.source, self.prefix1) self.assertEqual(created_rule.source_ports, [22, 443]) - self.assertEqual(created_rule.destination_prefix, None) + self.assertEqual(created_rule.destination, None) self.assertEqual(created_rule.destination_ports, None) self.assertEqual(created_rule.protocol, self.protocol) self.assertEqual(created_rule.description, "Created rule with source ports") self.assertEqual(isinstance(created_rule.access_list, AccessList), True) self.assertEqual(created_rule.access_list.type, self.acl_type) + def test_acl_extended_rule_destination_aggregate_creation_success(self): + """ + Test that ACLExtendedRule with destination aggregate creation passes validation. + """ + created_rule = ACLExtendedRule( + access_list=self.extended_acl1, + index=80, + action="permit", + remark="", + source=None, + source_ports=None, + destination=self.aggregate1, + destination_ports=None, + protocol=None, + description="Created rule with destination aggregate", + ) + created_rule.full_clean() + + self.assertTrue(isinstance(created_rule, ACLExtendedRule), True) + self.assertEqual(created_rule.index, 80) + self.assertEqual(created_rule.action, "permit") + self.assertEqual(created_rule.remark, "") + self.assertEqual(created_rule.source, None) + self.assertEqual(created_rule.source_ports, None) + self.assertEqual(created_rule.destination, self.aggregate1) + self.assertEqual(created_rule.destination_ports, None) + self.assertEqual(created_rule.protocol, None) + self.assertEqual(created_rule.description, "Created rule with destination aggregate") + self.assertEqual(isinstance(created_rule.access_list, AccessList), True) + self.assertEqual(created_rule.access_list.type, self.acl_type) + + def test_acl_extended_rule_destination_ip_address_creation_success(self): + """ + Test that ACLExtendedRule with destination ip address creation passes validation. + """ + created_rule = ACLExtendedRule( + access_list=self.extended_acl1, + index=90, + action="permit", + remark="", + source=None, + source_ports=None, + destination=self.ip_address1, + destination_ports=None, + protocol=None, + description="Created rule with destination ip address", + ) + created_rule.full_clean() + + self.assertTrue(isinstance(created_rule, ACLExtendedRule), True) + self.assertEqual(created_rule.index, 90) + self.assertEqual(created_rule.action, "permit") + self.assertEqual(created_rule.remark, "") + self.assertEqual(created_rule.source, None) + self.assertEqual(created_rule.source_ports, None) + self.assertEqual(created_rule.destination, self.ip_address1) + self.assertEqual(created_rule.destination_ports, None) + self.assertEqual(created_rule.protocol, None) + self.assertEqual(created_rule.description, "Created rule with destination ip address") + self.assertEqual(isinstance(created_rule.access_list, AccessList), True) + self.assertEqual(created_rule.access_list.type, self.acl_type) + + def test_acl_extended_rule_destination_ip_range_creation_success(self): + """ + Test that ACLExtendedRule with destination ip range creation passes validation. + """ + created_rule = ACLExtendedRule( + access_list=self.extended_acl1, + index=100, + action="permit", + remark="", + source=None, + source_ports=None, + destination=self.ip_range1, + destination_ports=None, + protocol=None, + description="Created rule with destination ip range", + ) + created_rule.full_clean() + + self.assertTrue(isinstance(created_rule, ACLExtendedRule), True) + self.assertEqual(created_rule.index, 100) + self.assertEqual(created_rule.action, "permit") + self.assertEqual(created_rule.remark, "") + self.assertEqual(created_rule.source, None) + self.assertEqual(created_rule.source_ports, None) + self.assertEqual(created_rule.destination, self.ip_range1) + self.assertEqual(created_rule.destination_ports, None) + self.assertEqual(created_rule.protocol, None) + self.assertEqual(created_rule.description, "Created rule with destination ip range") + self.assertEqual(isinstance(created_rule.access_list, AccessList), True) + self.assertEqual(created_rule.access_list.type, self.acl_type) + def test_acl_extended_rule_destination_prefix_creation_success(self): """ Test that ACLExtendedRule with destination prefix creation passes validation. """ created_rule = ACLExtendedRule( access_list=self.extended_acl1, - index=40, + index=110, action="permit", remark="", - source_prefix=None, + source=None, source_ports=None, - destination_prefix=self.prefix1, + destination=self.prefix1, destination_ports=None, protocol=None, description="Created rule with destination prefix", @@ -159,12 +340,12 @@ def test_acl_extended_rule_destination_prefix_creation_success(self): created_rule.full_clean() self.assertTrue(isinstance(created_rule, ACLExtendedRule), True) - self.assertEqual(created_rule.index, 40) + self.assertEqual(created_rule.index, 110) self.assertEqual(created_rule.action, "permit") self.assertEqual(created_rule.remark, "") - self.assertEqual(created_rule.source_prefix, None) + self.assertEqual(created_rule.source, None) self.assertEqual(created_rule.source_ports, None) - self.assertEqual(created_rule.destination_prefix, self.prefix1) + self.assertEqual(created_rule.destination, self.prefix1) self.assertEqual(created_rule.destination_ports, None) self.assertEqual(created_rule.protocol, None) self.assertEqual(created_rule.description, "Created rule with destination prefix") @@ -177,12 +358,12 @@ def test_acl_extended_rule_destination_ports_creation_success(self): """ created_rule = ACLExtendedRule( access_list=self.extended_acl1, - index=50, + index=130, action="permit", remark="", - source_prefix=None, + source=None, source_ports=None, - destination_prefix=self.prefix1, + destination=self.prefix1, destination_ports=[22, 443], protocol=self.protocol, description="Created rule with destination ports", @@ -190,12 +371,12 @@ def test_acl_extended_rule_destination_ports_creation_success(self): created_rule.full_clean() self.assertTrue(isinstance(created_rule, ACLExtendedRule), True) - self.assertEqual(created_rule.index, 50) + self.assertEqual(created_rule.index, 130) self.assertEqual(created_rule.action, "permit") self.assertEqual(created_rule.remark, "") - self.assertEqual(created_rule.source_prefix, None) + self.assertEqual(created_rule.source, None) self.assertEqual(created_rule.source_ports, None) - self.assertEqual(created_rule.destination_prefix, self.prefix1) + self.assertEqual(created_rule.destination, self.prefix1) self.assertEqual(created_rule.destination_ports, [22, 443]) self.assertEqual(created_rule.protocol, self.protocol) self.assertEqual(created_rule.description, "Created rule with destination ports") @@ -208,12 +389,12 @@ def test_acl_extended_rule_icmp_protocol_creation_success(self): """ created_rule = ACLExtendedRule( access_list=self.extended_acl1, - index=60, + index=140, action="permit", remark="", - source_prefix=self.prefix1, + source=self.prefix1, source_ports=None, - destination_prefix=self.prefix2, + destination=self.prefix2, destination_ports=None, protocol=ACLProtocolChoices.PROTOCOL_ICMP, description="Created rule with ICMP protocol", @@ -221,12 +402,12 @@ def test_acl_extended_rule_icmp_protocol_creation_success(self): created_rule.full_clean() self.assertTrue(isinstance(created_rule, ACLExtendedRule), True) - self.assertEqual(created_rule.index, 60) + self.assertEqual(created_rule.index, 140) self.assertEqual(created_rule.action, "permit") self.assertEqual(created_rule.remark, "") - self.assertEqual(created_rule.source_prefix, self.prefix1) + self.assertEqual(created_rule.source, self.prefix1) self.assertEqual(created_rule.source_ports, None) - self.assertEqual(created_rule.destination_prefix, self.prefix2) + self.assertEqual(created_rule.destination, self.prefix2) self.assertEqual(created_rule.destination_ports, None) self.assertEqual(created_rule.protocol, ACLProtocolChoices.PROTOCOL_ICMP) self.assertEqual(created_rule.description, "Created rule with ICMP protocol") @@ -239,12 +420,12 @@ def test_acl_extended_rule_complete_params_creation_success(self): """ created_rule = ACLExtendedRule( access_list=self.extended_acl1, - index=70, + index=150, action="permit", remark="", - source_prefix=self.prefix1, + source=self.prefix1, source_ports=[4000, 5000], - destination_prefix=self.prefix2, + destination=self.prefix2, destination_ports=[22, 443], protocol=self.protocol, description="Created rule with complete parameters", @@ -252,12 +433,12 @@ def test_acl_extended_rule_complete_params_creation_success(self): created_rule.full_clean() self.assertTrue(isinstance(created_rule, ACLExtendedRule), True) - self.assertEqual(created_rule.index, 70) + self.assertEqual(created_rule.index, 150) self.assertEqual(created_rule.action, "permit") self.assertEqual(created_rule.remark, "") - self.assertEqual(created_rule.source_prefix, self.prefix1) + self.assertEqual(created_rule.source, self.prefix1) self.assertEqual(created_rule.source_ports, [4000, 5000]) - self.assertEqual(created_rule.destination_prefix, self.prefix2) + self.assertEqual(created_rule.destination, self.prefix2) self.assertEqual(created_rule.destination_ports, [22, 443]) self.assertEqual(created_rule.protocol, self.protocol) self.assertEqual(created_rule.description, "Created rule with complete parameters") @@ -270,12 +451,12 @@ def test_acl_extended_rule_remark_creation_success(self): """ created_rule = ACLExtendedRule( access_list=self.extended_acl1, - index=80, + index=160, action="remark", remark="Test remark", - source_prefix=None, + source=None, source_ports=None, - destination_prefix=None, + destination=None, destination_ports=None, protocol=None, description="Created rule with remark", @@ -283,12 +464,12 @@ def test_acl_extended_rule_remark_creation_success(self): created_rule.full_clean() self.assertTrue(isinstance(created_rule, ACLExtendedRule), True) - self.assertEqual(created_rule.index, 80) + self.assertEqual(created_rule.index, 160) self.assertEqual(created_rule.action, "remark") self.assertEqual(created_rule.remark, "Test remark") - self.assertEqual(created_rule.source_prefix, None) + self.assertEqual(created_rule.source, None) self.assertEqual(created_rule.source_ports, None) - self.assertEqual(created_rule.destination_prefix, None) + self.assertEqual(created_rule.destination, None) self.assertEqual(created_rule.destination_ports, None) self.assertEqual(created_rule.protocol, None) self.assertEqual(created_rule.description, "Created rule with remark") @@ -301,19 +482,18 @@ def test_access_list_standard_to_acl_extended_rule_assignment_fail(self): """ standard_acl1 = AccessList.objects.create( name="STANDARD_ACL", - assigned_object=self.device1, type=ACLTypeChoices.TYPE_STANDARD, default_action=self.default_action, comments="STANDARD_ACL", ) extended_rule = ACLExtendedRule( access_list=standard_acl1, - index=80, + index=170, action="remark", remark="Test remark", - source_prefix=None, + source=None, source_ports=None, - destination_prefix=None, + destination=None, destination_ports=None, protocol=None, description="Created rule with remark", @@ -346,9 +526,9 @@ def test_acl_extended_rule_action_permit_with_remark_fail(self): index=10, action="permit", remark="Remark", - source_prefix=None, + source=None, source_ports=None, - destination_prefix=None, + destination=None, destination_ports=None, protocol=None, description="Invalid rule with action 'permit' and remark", @@ -365,9 +545,9 @@ def test_acl_extended_rule_action_remark_with_no_remark_fail(self): index=10, action="remark", remark="", - source_prefix=None, + source=None, source_ports=None, - destination_prefix=None, + destination=None, destination_ports=None, protocol=None, description="Invalid rule with action 'remark' and without remark", @@ -384,9 +564,9 @@ def test_acl_extended_rule_action_remark_with_source_prefix_fail(self): index=10, action="remark", remark="", - source_prefix=self.prefix1, + source=self.prefix1, source_ports=None, - destination_prefix=None, + destination=None, destination_ports=None, protocol=None, description="Invalid rule with action 'remark' and source prefix", @@ -403,9 +583,9 @@ def test_acl_extended_rule_action_remark_with_source_ports_fail(self): index=10, action="remark", remark="", - source_prefix=self.prefix1, + source=self.prefix1, source_ports=[80, 443], - destination_prefix=None, + destination=None, destination_ports=None, protocol=ACLProtocolChoices.PROTOCOL_TCP, description="Invalid rule with action 'remark' and source ports", @@ -422,9 +602,9 @@ def test_acl_extended_rule_action_remark_with_destination_prefix_fail(self): index=10, action="remark", remark="", - source_prefix=None, + source=None, source_ports=None, - destination_prefix=self.prefix1, + destination=self.prefix1, destination_ports=None, protocol=None, description="Invalid rule with action 'remark' and destination prefix", @@ -441,9 +621,9 @@ def test_acl_extended_rule_action_remark_with_destination_ports_fail(self): index=10, action="remark", remark="", - source_prefix=None, + source=None, source_ports=None, - destination_prefix=self.prefix1, + destination=self.prefix1, destination_ports=[80, 443], protocol=ACLProtocolChoices.PROTOCOL_TCP, description="Invalid rule with action 'remark' and destination ports", @@ -460,9 +640,9 @@ def test_acl_extended_rule_action_remark_with_protocol_fail(self): index=10, action="remark", remark="", - source_prefix=None, + source=None, source_ports=None, - destination_prefix=None, + destination=None, destination_ports=None, protocol=ACLProtocolChoices.PROTOCOL_ICMP, description="Invalid rule with action 'remark' and ICMP protocol", @@ -470,6 +650,44 @@ def test_acl_extended_rule_action_remark_with_protocol_fail(self): with self.assertRaises(ValidationError): invalid_rule.full_clean() + def test_invalid_aci_extended_rule_source_object(self): + """ + Test ACLExtendedRule source object validation. + """ + invalid_acl_rule_source_object = ACLExtendedRule( + access_list=self.extended_acl1, + index=10, + action="permit", + remark="", + source=self.device1, + source_ports=None, + destination=None, + destination_ports=None, + protocol=ACLProtocolChoices.PROTOCOL_ICMP, + description="Rule with invalid source object.", + ) + with self.assertRaises(ValidationError): + invalid_acl_rule_source_object.full_clean() + + def test_invalid_aci_extended_rule_destination_object(self): + """ + Test ACLExtendedRule destination object validation. + """ + invalid_acl_rule_destination_object = ACLExtendedRule( + access_list=self.extended_acl1, + index=10, + action="permit", + remark="", + source=None, + source_ports=None, + destination=self.device1, + destination_ports=None, + protocol=ACLProtocolChoices.PROTOCOL_ICMP, + description="Rule with invalid destination object.", + ) + with self.assertRaises(ValidationError): + invalid_acl_rule_destination_object.full_clean() + def test_valid_acl_rule_action_choices(self): """ Test ACLExtendedRule action choices using VALID choices. diff --git a/netbox_acls/tests/models/test_standardrules.py b/netbox_acls/tests/models/test_standardrules.py index dcad4968..dc90b35b 100644 --- a/netbox_acls/tests/models/test_standardrules.py +++ b/netbox_acls/tests/models/test_standardrules.py @@ -24,14 +24,12 @@ def setUpTestData(cls): # AccessLists cls.standard_acl1 = AccessList.objects.create( name="STANDARD_ACL", - assigned_object=cls.device1, type=cls.acl_type, default_action=cls.default_action, comments="STANDARD_ACL", ) cls.standard_acl2 = AccessList.objects.create( name="STANDARD_ACL", - assigned_object=cls.virtual_machine1, type=cls.acl_type, default_action=cls.default_action, comments="STANDARD_ACL", @@ -46,8 +44,8 @@ def test_acl_standard_rule_creation_success(self): index=10, action="permit", remark="", - source_prefix=None, - description="Created rule with any source prefix", + source=None, + description="Created rule with any source", ) created_rule.full_clean() @@ -55,8 +53,8 @@ def test_acl_standard_rule_creation_success(self): self.assertEqual(created_rule.index, 10) self.assertEqual(created_rule.action, "permit") self.assertEqual(created_rule.remark, "") - self.assertEqual(created_rule.source_prefix, None) - self.assertEqual(created_rule.description, "Created rule with any source prefix") + self.assertEqual(created_rule.source, None) + self.assertEqual(created_rule.description, "Created rule with any source") self.assertEqual(isinstance(created_rule.access_list, AccessList), True) self.assertEqual(created_rule.access_list.type, self.acl_type) @@ -69,7 +67,7 @@ def test_acl_standard_rule_source_prefix_creation_success(self): index=20, action="permit", remark="", - source_prefix=self.prefix1, + source=self.prefix1, description="Created rule with source prefix", ) created_rule.full_clean() @@ -78,7 +76,7 @@ def test_acl_standard_rule_source_prefix_creation_success(self): self.assertEqual(created_rule.index, 20) self.assertEqual(created_rule.action, "permit") self.assertEqual(created_rule.remark, "") - self.assertEqual(created_rule.source_prefix, self.prefix1) + self.assertEqual(created_rule.source, self.prefix1) self.assertEqual(created_rule.description, "Created rule with source prefix") self.assertEqual(isinstance(created_rule.access_list, AccessList), True) self.assertEqual(created_rule.access_list.type, self.acl_type) @@ -92,7 +90,7 @@ def test_acl_standard_rule_remark_creation_success(self): index=30, action="remark", remark="Test remark", - source_prefix=None, + source=None, description="Created rule with remark", ) created_rule.full_clean() @@ -101,18 +99,86 @@ def test_acl_standard_rule_remark_creation_success(self): self.assertEqual(created_rule.index, 30) self.assertEqual(created_rule.action, "remark") self.assertEqual(created_rule.remark, "Test remark") - self.assertEqual(created_rule.source_prefix, None) + self.assertEqual(created_rule.source, None) self.assertEqual(created_rule.description, "Created rule with remark") self.assertEqual(isinstance(created_rule.access_list, AccessList), True) self.assertEqual(created_rule.access_list.type, self.acl_type) + def test_acl_standard_rule_source_aggregate_creation_success(self): + """ + Test that ACLStandardRule with source aggregate creation passes validation. + """ + created_rule = ACLStandardRule( + access_list=self.standard_acl1, + index=40, + action="permit", + remark="", + source=self.aggregate1, + description="Created rule with source aggregate", + ) + created_rule.full_clean() + + self.assertTrue(isinstance(created_rule, ACLStandardRule), True) + self.assertEqual(created_rule.index, 40) + self.assertEqual(created_rule.action, "permit") + self.assertEqual(created_rule.remark, "") + self.assertEqual(created_rule.source, self.aggregate1) + self.assertEqual(created_rule.description, "Created rule with source aggregate") + self.assertEqual(isinstance(created_rule.access_list, AccessList), True) + self.assertEqual(created_rule.access_list.type, self.acl_type) + + def test_acl_standard_rule_source_ip_address_creation_success(self): + """ + Test that ACLStandardRule with source ip address creation passes validation. + """ + created_rule = ACLStandardRule( + access_list=self.standard_acl1, + index=50, + action="permit", + remark="", + source=self.ip_address1, + description="Created rule with source ip address", + ) + created_rule.full_clean() + + self.assertTrue(isinstance(created_rule, ACLStandardRule), True) + self.assertEqual(created_rule.index, 50) + self.assertEqual(created_rule.action, "permit") + self.assertEqual(created_rule.remark, "") + self.assertEqual(created_rule.source, self.ip_address1) + self.assertEqual(created_rule.description, "Created rule with source ip address") + self.assertEqual(isinstance(created_rule.access_list, AccessList), True) + self.assertEqual(created_rule.access_list.type, self.acl_type) + + def test_acl_standard_rule_source_ip_range_creation_success(self): + """ + Test that ACLStandardRule with source ip range creation passes validation. + """ + created_rule = ACLStandardRule( + access_list=self.standard_acl1, + index=60, + action="permit", + remark="", + source=self.ip_range1, + description="Created rule with source ip range", + ) + created_rule.full_clean() + + self.assertTrue(isinstance(created_rule, ACLStandardRule), True) + self.assertEqual(created_rule.index, 60) + self.assertEqual(created_rule.action, "permit") + self.assertEqual(created_rule.remark, "") + self.assertEqual(created_rule.source, self.ip_range1) + self.assertEqual(created_rule.description, "Created rule with source ip range") + self.assertEqual(isinstance(created_rule.access_list, AccessList), True) + self.assertEqual(created_rule.access_list.type, self.acl_type) + def test_access_list_extended_to_acl_standard_rule_assignment_fail(self): """ Test that Extended Access List cannot be assigned to ACLStandardRule. """ extended_acl1 = AccessList.objects.create( name="EXTENDED_ACL", - assigned_object=self.device1, type=ACLTypeChoices.TYPE_EXTENDED, default_action=self.default_action, comments="EXTENDED_ACL", @@ -122,7 +188,7 @@ def test_access_list_extended_to_acl_standard_rule_assignment_fail(self): index=30, action="remark", remark="Test remark", - source_prefix=None, + source=None, description="Created rule with remark", ) with self.assertRaises(ValidationError): @@ -153,7 +219,7 @@ def test_acl_standard_rule_action_permit_with_remark_fail(self): index=10, action="permit", remark="Remark", - source_prefix=None, + source=None, description="Invalid rule with action 'permit' and remark", ) with self.assertRaises(ValidationError): @@ -168,7 +234,7 @@ def test_acl_standard_rule_action_remark_with_no_remark_fail(self): index=10, action="remark", remark="", - source_prefix=None, + source=None, description="Invalid rule with action 'remark' and without remark", ) with self.assertRaises(ValidationError): @@ -183,12 +249,27 @@ def test_acl_standard_rule_action_remark_with_source_prefix_fail(self): index=10, action="remark", remark="", - source_prefix=self.prefix1, + source=self.prefix1, description="Invalid rule with action 'remark' and source prefix", ) with self.assertRaises(ValidationError): invalid_rule.full_clean() + def test_invalid_aci_standard_rule_source_object(self): + """ + Test ACLStandardRule source object validation. + """ + invalid_acl_rule_source_object = ACLStandardRule( + access_list=self.standard_acl1, + index=10, + action="permit", + remark="", + source=self.device1, + description="Rule with invalid source object", + ) + with self.assertRaises(ValidationError): + invalid_acl_rule_source_object.full_clean() + def test_valid_acl_rule_action_choices(self): """ Test ACLStandardRule action choices using VALID choices. diff --git a/netbox_acls/urls.py b/netbox_acls/urls.py index 24f8bdf9..2e521af8 100644 --- a/netbox_acls/urls.py +++ b/netbox_acls/urls.py @@ -19,12 +19,12 @@ ), # Access List Interface Assignments path( - "interface-assignments/", - include(get_model_urls("netbox_acls", "aclinterfaceassignment", detail=False)), + "assignments/", + include(get_model_urls("netbox_acls", "aclassignment", detail=False)), ), path( - "interface-assignments//", - include(get_model_urls("netbox_acls", "aclinterfaceassignment")), + "assignments//", + include(get_model_urls("netbox_acls", "aclassignment")), ), # Standard Access List Rules path( diff --git a/netbox_acls/views.py b/netbox_acls/views.py index d6825edf..6c70a8ff 100644 --- a/netbox_acls/views.py +++ b/netbox_acls/views.py @@ -3,6 +3,8 @@ Specifically, all the various interactions with a client. """ +from django.contrib.contenttypes.models import ContentType +from django.utils.translation import gettext_lazy as _ from dcim.models import Device, Interface, VirtualChassis from django.db.models import Count from netbox.views import generic @@ -17,11 +19,11 @@ "AccessListEditView", "AccessListDeleteView", "AccessListBulkDeleteView", - "ACLInterfaceAssignmentView", - "ACLInterfaceAssignmentListView", - "ACLInterfaceAssignmentEditView", - "ACLInterfaceAssignmentDeleteView", - "ACLInterfaceAssignmentBulkDeleteView", + "ACLAssignmentView", + "ACLAssignmentListView", + "ACLAssignmentEditView", + "ACLAssignmentDeleteView", + "ACLAssignmentBulkDeleteView", "ACLStandardRuleView", "ACLStandardRuleListView", "ACLStandardRuleEditView", @@ -34,6 +36,46 @@ "ACLExtendedRuleBulkDeleteView", ) +# +# Base children views +# + + +class ACLAssignmentChildrenView(generic.ObjectChildrenView): + """Base children view for attaching a tab of ACL Assignments.""" + + child_model = models.ACLAssignment + filterset = filtersets.ACLAssignmentFilterSet + tab = ViewTab( + label=_("Access Lists"), + badge=lambda obj: obj.aclassignments.count(), + permission="netbox_acls.view_aclassignment", + weight=1100, + ) + table = tables.ACLAssignmentTable + + def get_children(self, request, parent): + """ + Return all objects of ACLAssignment. + """ + return models.ACLAssignment.objects.restrict(request.user, "view").prefetch_related( + "access_list", + "assigned_object_type", + "assigned_object", + "tags", + ) + + def get_extra_context(self, request, instance) -> dict: + """ + Return ContentType as extra context. + """ + assigned_object_type = ContentType.objects.get_for_model(self.queryset.model) + + return super().get_extra_context(request, instance) | { + "add_url": "plugins:netbox_acls:aclassignment_add", + "content_type_id": assigned_object_type.id, + } + # # AccessList views @@ -112,118 +154,50 @@ class AccessListBulkDeleteView(generic.BulkDeleteView): table = tables.AccessListTable -class AccessListChildView(generic.ObjectChildrenView): - """ - Defines the child view for the AccessLists model. - """ - - child_model = models.AccessList - table = tables.AccessListTable - filterset = filtersets.AccessListFilterSet - template_name = "inc/view_tab.html" - - def get_extra_context(self, request, instance): - return { - "table_config": self.table.__name__, - "model_type": self.queryset.model._meta.verbose_name.replace(" ", "_"), - "add_url": "plugins:netbox_acls:accesslist_add", - } - - def prep_table_data(self, request, queryset, parent): - return queryset.annotate( - rule_count=Count("aclextendedrules") + Count("aclstandardrules"), - ) - - -@register_model_view(Device, "access_lists") -class DeviceAccessListView(AccessListChildView): - queryset = Device.objects.prefetch_related("tags") - tab = ViewTab( - label="Access Lists", - badge=lambda obj: models.AccessList.objects.filter(device=obj).count(), - permission="netbox_acls.view_accesslist", - ) - - def get_children(self, request, parent): - return self.child_model.objects.restrict(request.user, "view").filter( - device=parent, - ) - - -@register_model_view(VirtualChassis, "access_lists") -class VirtualChassisAccessListView(AccessListChildView): - queryset = VirtualChassis.objects.prefetch_related("tags") - tab = ViewTab( - label="Access Lists", - badge=lambda obj: models.AccessList.objects.filter(virtual_chassis=obj).count(), - permission="netbox_acls.view_accesslist", - ) - - def get_children(self, request, parent): - return self.child_model.objects.restrict(request.user, "view").filter( - virtual_chassis=parent, - ) - - -@register_model_view(VirtualMachine, "access_lists") -class VirtualMachineAccessListView(AccessListChildView): - queryset = VirtualMachine.objects.prefetch_related("tags") - tab = ViewTab( - label="Access Lists", - badge=lambda obj: models.AccessList.objects.filter(virtual_machine=obj).count(), - permission="netbox_acls.view_accesslist", - ) - - def get_children(self, request, parent): - return self.child_model.objects.restrict(request.user, "view").filter( - virtual_machine=parent, - ) - - # -# ACLInterfaceAssignment views +# ACLAssignment views # -@register_model_view(models.ACLInterfaceAssignment) -class ACLInterfaceAssignmentView(generic.ObjectView): +@register_model_view(models.ACLAssignment) +class ACLAssignmentView(generic.ObjectView): """ - Defines the view for the ACLInterfaceAssignments django model. + Defines the view for the ACLAssignments django model. """ - queryset = models.ACLInterfaceAssignment.objects.prefetch_related( + queryset = models.ACLAssignment.objects.prefetch_related( "access_list", "tags", ) -@register_model_view(models.ACLInterfaceAssignment, "list", path="", detail=False) -class ACLInterfaceAssignmentListView(generic.ObjectListView): +@register_model_view(models.ACLAssignment, "list", path="", detail=False) +class ACLAssignmentListView(generic.ObjectListView): """ - Defines the list view for the ACLInterfaceAssignments django model. + Defines the list view for the ACLAssignments django model. """ - queryset = models.ACLInterfaceAssignment.objects.prefetch_related( + queryset = models.ACLAssignment.objects.prefetch_related( "access_list", "tags", ) - table = tables.ACLInterfaceAssignmentTable - filterset = filtersets.ACLInterfaceAssignmentFilterSet - filterset_form = forms.ACLInterfaceAssignmentFilterForm + table = tables.ACLAssignmentTable + filterset = filtersets.ACLAssignmentFilterSet + filterset_form = forms.ACLAssignmentFilterForm -@register_model_view(models.ACLInterfaceAssignment, "add", detail=False) -@register_model_view(models.ACLInterfaceAssignment, "edit") -class ACLInterfaceAssignmentEditView(generic.ObjectEditView): +@register_model_view(models.ACLAssignment, "add", detail=False) +@register_model_view(models.ACLAssignment, "edit") +class ACLAssignmentEditView(generic.ObjectEditView): """ - Defines the edit view for the ACLInterfaceAssignments django model. + Defines the edit view for the ACLAssignments django model. """ - queryset = models.ACLInterfaceAssignment.objects.prefetch_related( + queryset = models.ACLAssignment.objects.prefetch_related( "access_list", "tags", ) - form = forms.ACLInterfaceAssignmentForm + form = forms.ACLAssignmentForm def get_extra_addanother_params(self, request): """ @@ -236,78 +210,157 @@ def get_extra_addanother_params(self, request): } -@register_model_view(models.ACLInterfaceAssignment, "delete") -class ACLInterfaceAssignmentDeleteView(generic.ObjectDeleteView): +@register_model_view(models.ACLAssignment, "delete") +class ACLAssignmentDeleteView(generic.ObjectDeleteView): """ - Defines delete view for the ACLInterfaceAssignments django model. + Defines delete view for the ACLAssignments django model. """ - queryset = models.ACLInterfaceAssignment.objects.prefetch_related( + queryset = models.ACLAssignment.objects.prefetch_related( "access_list", "tags", ) -@register_model_view(models.ACLInterfaceAssignment, "bulk_delete", path="delete", detail=False) -class ACLInterfaceAssignmentBulkDeleteView(generic.BulkDeleteView): - queryset = models.ACLInterfaceAssignment.objects.prefetch_related( +@register_model_view(models.ACLAssignment, "bulk_delete", path="delete", detail=False) +class ACLAssignmentBulkDeleteView(generic.BulkDeleteView): + queryset = models.ACLAssignment.objects.prefetch_related( "access_list", "tags", ) - filterset = filtersets.ACLInterfaceAssignmentFilterSet - table = tables.ACLInterfaceAssignmentTable + filterset = filtersets.ACLAssignmentFilterSet + table = tables.ACLAssignmentTable -class ACLInterfaceAssignmentChildView(generic.ObjectChildrenView): +@register_model_view(Device, "aclassignments", path="access-lists") +class DeviceACLAssignmentView(ACLAssignmentChildrenView): """ - Defines the child view for the ACLInterfaceAssignments model. + Children view of ACL Assignment of Devices. """ - child_model = models.ACLInterfaceAssignment - table = tables.ACLInterfaceAssignmentTable - filterset = filtersets.ACLInterfaceAssignmentFilterSet + queryset = Device.objects.all() template_name = "inc/view_tab.html" - def get_extra_context(self, request, instance): - return { - "table_config": self.table.__name__, - "model_type": self.queryset.model._meta.verbose_name.replace(" ", "_"), - "add_url": "plugins:netbox_acls:aclinterfaceassignment_add", - } + def get_children(self, request, parent): + """Return all children objects to the current parent object.""" + return super().get_children(request, parent).filter(device=parent.pk) + def get_table(self, *args, **kwargs): + """Return the table with the assigned object colum hidden.""" + table = super().get_table(*args, **kwargs) -@register_model_view(Interface, "acl_interface_assignments") -class InterfaceACLInterfaceAssignmentView(ACLInterfaceAssignmentChildView): - queryset = Interface.objects.prefetch_related("device", "tags") - tab = ViewTab( - label="ACL Interface Assignments", - badge=lambda obj: models.ACLInterfaceAssignment.objects.filter( - interface=obj, - ).count(), - permission="netbox_acls.view_aclinterfaceassignment", - ) + # Hide the assigned object type column + table.columns.hide("assigned_object_type") + # Hide the assigned object column + table.columns.hide("assigned_object") + # Hide the direction column + table.columns.hide("direction") + + return table + + +@register_model_view(Interface, "aclassignments", path="access-lists") +class InterfaceACLAssignmentView(ACLAssignmentChildrenView): + """ + Children view of ACL Assignment of Interfaces. + """ + + queryset = Interface.objects.all() + template_name = "inc/view_tab.html" def get_children(self, request, parent): - return self.child_model.objects.restrict(request.user, "view").filter( - interface=parent, - ) + """Return all children objects to the current parent object.""" + return super().get_children(request, parent).filter(interface=parent.pk) + def get_table(self, *args, **kwargs): + """Return the table with the assigned object colum hidden.""" + table = super().get_table(*args, **kwargs) + + # Hide the assigned object type column + table.columns.hide("assigned_object_type") + # Hide the assigned object column + table.columns.hide("assigned_object") + + return table -@register_model_view(VMInterface, "acl_interface_assignments") -class VirtualMachineInterfaceACLInterfaceAssignmentView(ACLInterfaceAssignmentChildView): - queryset = VMInterface.objects.prefetch_related("virtual_machine", "tags") - tab = ViewTab( - label="ACL Interface Assignments", - badge=lambda obj: models.ACLInterfaceAssignment.objects.filter( - vminterface=obj, - ).count(), - permission="netbox_acls.view_aclinterfaceassignment", - ) + +@register_model_view(VirtualChassis, "aclassignments", path="access-lists") +class VirtualChassisACLAssignmentView(ACLAssignmentChildrenView): + """ + Children view of ACL Assignment of VirtualChassiss. + """ + + queryset = VirtualChassis.objects.all() + template_name = "inc/view_tab.html" def get_children(self, request, parent): - return self.child_model.objects.restrict(request.user, "view").filter( - vminterface=parent, - ) + """Return all children objects to the current parent object.""" + return super().get_children(request, parent).filter(virtual_chassis=parent.pk) + + def get_table(self, *args, **kwargs): + """Return the table with the assigned object colum hidden.""" + table = super().get_table(*args, **kwargs) + + # Hide the assigned object type column + table.columns.hide("assigned_object_type") + # Hide the assigned object column + table.columns.hide("assigned_object") + # Hide the direction column + table.columns.hide("direction") + + return table + + +@register_model_view(VirtualMachine, "aclassignments", path="access-lists") +class VirtualMachineACLAssignmentView(ACLAssignmentChildrenView): + """ + Children view of ACL Assignment of VirtualMachines. + """ + + queryset = VirtualMachine.objects.all() + template_name = "inc/view_tab.html" + + def get_children(self, request, parent): + """Return all children objects to the current parent object.""" + return super().get_children(request, parent).filter(virtual_machine=parent.pk) + + def get_table(self, *args, **kwargs): + """Return the table with the assigned object colum hidden.""" + table = super().get_table(*args, **kwargs) + + # Hide the assigned object type column + table.columns.hide("assigned_object_type") + # Hide the assigned object column + table.columns.hide("assigned_object") + # Hide the direction column + table.columns.hide("direction") + + return table + + +@register_model_view(VMInterface, "aclassignments", path="access-lists") +class VMInterfaceACLAssignmentView(ACLAssignmentChildrenView): + """ + Children view of ACL Assignment of VMInterfaces. + """ + + queryset = VMInterface.objects.all() + template_name = "inc/view_tab.html" + + def get_children(self, request, parent): + """Return all children objects to the current parent object.""" + return super().get_children(request, parent).filter(vminterface=parent.pk) + + def get_table(self, *args, **kwargs): + """Return the table with the assigned object colum hidden.""" + table = super().get_table(*args, **kwargs) + + # Hide the assigned object type column + table.columns.hide("assigned_object_type") + # Hide the assigned object column + table.columns.hide("assigned_object") + + return table # @@ -323,8 +376,8 @@ class ACLStandardRuleView(generic.ObjectView): queryset = models.ACLStandardRule.objects.prefetch_related( "access_list", + "source", "tags", - "source_prefix", ) @@ -336,8 +389,8 @@ class ACLStandardRuleListView(generic.ObjectListView): queryset = models.ACLStandardRule.objects.prefetch_related( "access_list", + "source", "tags", - "source_prefix", ) table = tables.ACLStandardRuleTable filterset = filtersets.ACLStandardRuleFilterSet @@ -353,8 +406,8 @@ class ACLStandardRuleEditView(generic.ObjectEditView): queryset = models.ACLStandardRule.objects.prefetch_related( "access_list", + "source", "tags", - "source_prefix", ) form = forms.ACLStandardRuleForm @@ -376,8 +429,8 @@ class ACLStandardRuleDeleteView(generic.ObjectDeleteView): queryset = models.ACLStandardRule.objects.prefetch_related( "access_list", + "source", "tags", - "source_prefix", ) @@ -385,8 +438,8 @@ class ACLStandardRuleDeleteView(generic.ObjectDeleteView): class ACLStandardRuleBulkDeleteView(generic.BulkDeleteView): queryset = models.ACLStandardRule.objects.prefetch_related( "access_list", + "source", "tags", - "source_prefix", ) filterset = filtersets.ACLStandardRuleFilterSet table = tables.ACLStandardRuleTable @@ -405,9 +458,9 @@ class ACLExtendedRuleView(generic.ObjectView): queryset = models.ACLExtendedRule.objects.prefetch_related( "access_list", + "source", + "destination", "tags", - "source_prefix", - "destination_prefix", ) @@ -419,9 +472,9 @@ class ACLExtendedRuleListView(generic.ObjectListView): queryset = models.ACLExtendedRule.objects.prefetch_related( "access_list", + "source", + "destination", "tags", - "source_prefix", - "destination_prefix", ) table = tables.ACLExtendedRuleTable filterset = filtersets.ACLExtendedRuleFilterSet @@ -437,9 +490,9 @@ class ACLExtendedRuleEditView(generic.ObjectEditView): queryset = models.ACLExtendedRule.objects.prefetch_related( "access_list", + "source", + "destination", "tags", - "source_prefix", - "destination_prefix", ) form = forms.ACLExtendedRuleForm @@ -461,9 +514,9 @@ class ACLExtendedRuleDeleteView(generic.ObjectDeleteView): queryset = models.ACLExtendedRule.objects.prefetch_related( "access_list", + "source", + "destination", "tags", - "source_prefix", - "destination_prefix", ) @@ -471,9 +524,9 @@ class ACLExtendedRuleDeleteView(generic.ObjectDeleteView): class ACLExtendedRuleBulkDeleteView(generic.BulkDeleteView): queryset = models.ACLExtendedRule.objects.prefetch_related( "access_list", + "source", + "destination", "tags", - "source_prefix", - "destination_prefix", ) filterset = filtersets.ACLExtendedRuleFilterSet table = tables.ACLExtendedRuleTable