- {% if perms.virtualization.change_virtualmachine %}
+ {% if 'bulk_edit' in actions %}
Edit
{% endif %}
- {% if perms.virtualization.delete_virtualmachine %}
+ {% if 'bulk_delete' in actions %}
Delete
diff --git a/netbox/users/admin/forms.py b/netbox/users/admin/forms.py
index bc3d44862..540735ecc 100644
--- a/netbox/users/admin/forms.py
+++ b/netbox/users/admin/forms.py
@@ -3,11 +3,11 @@ from django.contrib.auth.models import Group, User
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldError, ValidationError
-from django.db.models import Q
-from users.constants import OBJECTPERMISSION_OBJECT_TYPES
+from users.constants import CONSTRAINT_TOKEN_USER, OBJECTPERMISSION_OBJECT_TYPES
from users.models import ObjectPermission, Token
from utilities.forms.fields import ContentTypeMultipleChoiceField
+from utilities.permissions import qs_filter_from_constraints
__all__ = (
'GroupAdminForm',
@@ -125,7 +125,10 @@ class ObjectPermissionForm(forms.ModelForm):
for ct in object_types:
model = ct.model_class()
try:
- model.objects.filter(*[Q(**c) for c in constraints]).exists()
+ tokens = {
+ CONSTRAINT_TOKEN_USER: 0, # Replace token with a null user ID
+ }
+ model.objects.filter(qs_filter_from_constraints(constraints, tokens)).exists()
except FieldError as e:
raise ValidationError({
'constraints': f'Invalid filter for {model}: {e}'
diff --git a/netbox/users/constants.py b/netbox/users/constants.py
index e6917c482..1e6e7c71c 100644
--- a/netbox/users/constants.py
+++ b/netbox/users/constants.py
@@ -6,3 +6,5 @@ OBJECTPERMISSION_OBJECT_TYPES = Q(
Q(app_label='auth', model__in=['group', 'user']) |
Q(app_label='users', model__in=['objectpermission', 'token'])
)
+
+CONSTRAINT_TOKEN_USER = '$user'
diff --git a/netbox/utilities/exceptions.py b/netbox/utilities/exceptions.py
index 4ba62bc01..657e90745 100644
--- a/netbox/utilities/exceptions.py
+++ b/netbox/utilities/exceptions.py
@@ -1,6 +1,13 @@
from rest_framework import status
from rest_framework.exceptions import APIException
+__all__ = (
+ 'AbortRequest',
+ 'AbortTransaction',
+ 'PermissionsViolation',
+ 'RQWorkerNotRunningException',
+)
+
class AbortTransaction(Exception):
"""
@@ -9,12 +16,20 @@ class AbortTransaction(Exception):
pass
+class AbortRequest(Exception):
+ """
+ Raised to cleanly abort a request (for example, by a pre_save signal receiver).
+ """
+ def __init__(self, message):
+ self.message = message
+
+
class PermissionsViolation(Exception):
"""
Raised when an operation was prevented because it would violate the
allowed permissions.
"""
- pass
+ message = "Operation failed due to object-level permissions violation"
class RQWorkerNotRunningException(APIException):
diff --git a/netbox/utilities/management/commands/__init__.py b/netbox/utilities/management/commands/__init__.py
index bdd4face6..2c261b0d3 100644
--- a/netbox/utilities/management/commands/__init__.py
+++ b/netbox/utilities/management/commands/__init__.py
@@ -1,6 +1,8 @@
from django.db import models
from timezone_field import TimeZoneField
+from netbox.config import ConfigItem
+
SKIP_FIELDS = (
TimeZoneField,
@@ -26,4 +28,9 @@ def custom_deconstruct(field):
for attr in EXEMPT_ATTRS:
kwargs.pop(attr, None)
+ # Ignore any field defaults which reference a ConfigItem
+ kwargs = {
+ k: v for k, v in kwargs.items() if not isinstance(v, ConfigItem)
+ }
+
return name, path, args, kwargs
diff --git a/netbox/utilities/permissions.py b/netbox/utilities/permissions.py
index b11bf504a..b20aafce0 100644
--- a/netbox/utilities/permissions.py
+++ b/netbox/utilities/permissions.py
@@ -1,5 +1,14 @@
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
+from django.db.models import Q
+
+__all__ = (
+ 'get_permission_for_model',
+ 'permission_is_exempt',
+ 'qs_filter_from_constraints',
+ 'resolve_permission',
+ 'resolve_permission_ct',
+)
def get_permission_for_model(model, action):
@@ -69,3 +78,29 @@ def permission_is_exempt(name):
return True
return False
+
+
+def qs_filter_from_constraints(constraints, tokens=None):
+ """
+ Construct a Q filter object from an iterable of ObjectPermission constraints.
+
+ Args:
+ tokens: A dictionary mapping string tokens to be replaced with a value.
+ """
+ if tokens is None:
+ tokens = {}
+
+ def _replace_tokens(value, tokens):
+ if type(value) is list:
+ return list(map(lambda v: tokens.get(v, v), value))
+ return tokens.get(value, value)
+
+ params = Q()
+ for constraint in constraints:
+ if constraint:
+ params |= Q(**{k: _replace_tokens(v, tokens) for k, v in constraint.items()})
+ else:
+ # Found null constraint; permit model-level access
+ return Q()
+
+ return params
diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py
index 97d2e8779..955a10d64 100644
--- a/netbox/utilities/querysets.py
+++ b/netbox/utilities/querysets.py
@@ -1,6 +1,7 @@
-from django.db.models import Q, QuerySet
+from django.db.models import QuerySet
-from utilities.permissions import permission_is_exempt
+from users.constants import CONSTRAINT_TOKEN_USER
+from utilities.permissions import permission_is_exempt, qs_filter_from_constraints
class RestrictedQuerySet(QuerySet):
@@ -28,23 +29,13 @@ class RestrictedQuerySet(QuerySet):
# Filter the queryset to include only objects with allowed attributes
else:
- attrs = Q()
- for perm_attrs in user._object_perm_cache[permission_required]:
- if type(perm_attrs) is list:
- for p in perm_attrs:
- attrs |= Q(**p)
- elif perm_attrs:
- attrs |= Q(**perm_attrs)
- else:
- # Any permission with null constraints grants access to _all_ instances
- attrs = Q()
- break
- else:
- # for else, when no break
- # avoid duplicates when JOIN on many-to-many fields without using DISTINCT.
- # DISTINCT acts globally on the entire request, which may not be desirable.
- allowed_objects = self.model.objects.filter(attrs)
- attrs = Q(pk__in=allowed_objects)
- qs = self.filter(attrs)
+ tokens = {
+ CONSTRAINT_TOKEN_USER: user,
+ }
+ attrs = qs_filter_from_constraints(user._object_perm_cache[permission_required], tokens)
+ # #8715: Avoid duplicates when JOIN on many-to-many fields without using DISTINCT.
+ # DISTINCT acts globally on the entire request, which may not be desirable.
+ allowed_objects = self.model.objects.filter(attrs)
+ qs = self.filter(pk__in=allowed_objects)
return qs
diff --git a/netbox/utilities/templates/builtins/customfield_value.html b/netbox/utilities/templates/builtins/customfield_value.html
new file mode 100644
index 000000000..8fedb03d5
--- /dev/null
+++ b/netbox/utilities/templates/builtins/customfield_value.html
@@ -0,0 +1,27 @@
+{% if field.type == 'integer' and value is not None %}
+ {{ value }}
+{% elif field.type == 'longtext' and value %}
+ {{ value|markdown }}
+{% elif field.type == 'boolean' and value == True %}
+ {% checkmark value true="True" %}
+{% elif field.type == 'boolean' and value == False %}
+ {% checkmark value false="False" %}
+{% elif field.type == 'url' and value %}
+
{{ value|truncatechars:70 }}
+{% elif field.type == 'json' and value %}
+
{{ value|json }}
+{% elif field.type == 'multiselect' and value %}
+ {{ value|join:", " }}
+{% elif field.type == 'object' and value %}
+ {{ value|linkify }}
+{% elif field.type == 'multiobject' and value %}
+ {% for object in value %}
+ {{ object|linkify }}{% if not forloop.last %}
{% endif %}
+ {% endfor %}
+{% elif value %}
+ {{ value }}
+{% elif field.required %}
+
Not defined
+{% else %}
+ {{ ''|placeholder }}
+{% endif %}
diff --git a/netbox/utilities/templates/navigation/menu.html b/netbox/utilities/templates/navigation/menu.html
index dfc85968a..33a476081 100644
--- a/netbox/utilities/templates/navigation/menu.html
+++ b/netbox/utilities/templates/navigation/menu.html
@@ -1,58 +1,43 @@
{% load helpers %}
diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py
index 666b6a31c..ed464b332 100644
--- a/netbox/utilities/templatetags/builtins/tags.py
+++ b/netbox/utilities/templatetags/builtins/tags.py
@@ -18,6 +18,21 @@ def tag(value, viewname=None):
}
+@register.inclusion_tag('builtins/customfield_value.html')
+def customfield_value(customfield, value):
+ """
+ Render a custom field value according to the field type.
+
+ Args:
+ customfield: A CustomField instance
+ value: The custom field value applied to an object
+ """
+ return {
+ 'customfield': customfield,
+ 'value': value,
+ }
+
+
@register.inclusion_tag('builtins/badge.html')
def badge(value, bg_color=None, show_empty=False):
"""
diff --git a/netbox/utilities/templatetags/navigation.py b/netbox/utilities/templatetags/navigation.py
index ede8792fa..ef0657446 100644
--- a/netbox/utilities/templatetags/navigation.py
+++ b/netbox/utilities/templatetags/navigation.py
@@ -13,7 +13,26 @@ def nav(context: Context) -> Dict:
"""
Render the navigation menu.
"""
+ user = context['request'].user
+ nav_items = []
+
+ # Construct the navigation menu based upon the current user's permissions
+ for menu in MENUS:
+ groups = []
+ for group in menu.groups:
+ items = []
+ for item in group.items:
+ if user.has_perms(item.permissions):
+ buttons = [
+ button for button in item.buttons if user.has_perms(button.permissions)
+ ]
+ items.append((item, buttons))
+ if items:
+ groups.append((group, items))
+ if groups:
+ nav_items.append((menu, groups))
+
return {
- "nav_items": MENUS,
+ "nav_items": nav_items,
"request": context["request"]
}
diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py
index 731b67e43..51c411004 100644
--- a/netbox/utilities/utils.py
+++ b/netbox/utilities/utils.py
@@ -286,7 +286,7 @@ def prepare_cloned_fields(instance):
"""
# Generate the clone attributes from the instance
if not hasattr(instance, 'clone'):
- return None
+ return QueryDict()
attrs = instance.clone()
# Prepare querydict parameters
diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py
index bd01b5533..c5816dca8 100644
--- a/netbox/virtualization/api/serializers.py
+++ b/netbox/virtualization/api/serializers.py
@@ -5,7 +5,9 @@ from dcim.api.nested_serializers import (
NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer,
)
from dcim.choices import InterfaceModeChoices
-from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer
+from ipam.api.nested_serializers import (
+ NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer, NestedVRFSerializer,
+)
from ipam.models import VLAN
from netbox.api import ChoiceField, SerializedPKRelatedField
from netbox.api.serializers import NetBoxModelSerializer
@@ -121,6 +123,7 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
many=True
)
vrf = NestedVRFSerializer(required=False, allow_null=True)
+ l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True)
count_ipaddresses = serializers.IntegerField(read_only=True)
count_fhrp_groups = serializers.IntegerField(read_only=True)
@@ -128,8 +131,8 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
model = VMInterface
fields = [
'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu', 'mac_address',
- 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', 'custom_fields', 'created',
- 'last_updated', 'count_ipaddresses', 'count_fhrp_groups',
+ 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'vrf', 'l2vpn_termination', 'tags', 'custom_fields',
+ 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups',
]
def validate(self, data):
diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py
index 02560a962..98321976f 100644
--- a/netbox/virtualization/models.py
+++ b/netbox/virtualization/models.py
@@ -440,6 +440,12 @@ class VMInterface(NetBoxModel, BaseInterface):
object_id_field='interface_id',
related_query_name='+'
)
+ l2vpn_terminations = GenericRelation(
+ to='ipam.L2VPNTermination',
+ content_type_field='assigned_object_type',
+ object_id_field='assigned_object_id',
+ related_query_name='vminterface',
+ )
class Meta:
verbose_name = 'interface'
@@ -498,3 +504,7 @@ class VMInterface(NetBoxModel, BaseInterface):
@property
def parent_object(self):
return self.virtual_machine
+
+ @property
+ def l2vpn_termination(self):
+ return self.l2vpn_terminations.first()