diff --git a/docs/administration/permissions.md b/docs/administration/permissions.md
index 0b7b88bcde3..750d291c78d 100644
--- a/docs/administration/permissions.md
+++ b/docs/administration/permissions.md
@@ -113,3 +113,7 @@ Device.objects.filter(
### Creating and Modifying Objects
The same sort of logic is in play when a user attempts to create or modify an object in NetBox, with a twist. Once validation has completed, NetBox starts an atomic database transaction to facilitate the change, and the object is created or saved normally. Next, still within the transaction, NetBox issues a second query to retrieve the newly created/updated object, filtering the restricted queryset with the object's primary key. If this query fails to return the object, NetBox knows that the new revision does not match the constraints imposed by the permission. The transaction is then rolled back, leaving the database in its original state prior to the change, and the user is informed of the violation.
+
+#### Preserving Restricted Related Values
+
+When editing an object that references related objects the user is not permitted to view (for example a tag, tenant, or custom field value constrained away by a permission), those current values are shown read-only on the edit form and preserved when the form is saved. Single-value fields are disabled entirely; multi-value fields (such as tags) remain editable for the values the user can see, while the restricted current values are rendered as disabled options. This prevents a user from unintentionally clearing related objects they cannot see, without exposing any other restricted objects or allowing the restricted values to be replaced.
diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py
index 94e43f0ce7a..2e0387bc17f 100644
--- a/netbox/circuits/forms/model_forms.py
+++ b/netbox/circuits/forms/model_forms.py
@@ -213,6 +213,10 @@ class CircuitTerminationForm(NetBoxModelForm):
FieldSet('port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', name=_('Termination Details')),
)
+ restricted_related_selectors = {
+ 'termination': {'path': 'termination', 'lock_fields': ('termination_type',)},
+ }
+
class Meta:
model = CircuitTermination
fields = [
@@ -307,6 +311,10 @@ class CircuitGroupAssignmentForm(NetBoxModelForm):
FieldSet('group', 'member_type', 'member', 'priority', 'tags', name=_('Group Assignment')),
)
+ restricted_related_selectors = {
+ 'member': {'path': 'member', 'lock_fields': ('member_type',)},
+ }
+
class Meta:
model = CircuitGroupAssignment
fields = [
diff --git a/netbox/dcim/forms/mixins.py b/netbox/dcim/forms/mixins.py
index 709d6122235..ffcbc5a6ee8 100644
--- a/netbox/dcim/forms/mixins.py
+++ b/netbox/dcim/forms/mixins.py
@@ -39,6 +39,10 @@ class ScopedForm(forms.Form):
selector=True
)
+ restricted_related_selectors = {
+ 'scope': {'path': 'scope', 'lock_fields': ('scope_type',)},
+ }
+
def __init__(self, *args, **kwargs):
instance = kwargs.get('instance')
initial = kwargs.get('initial', {})
diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py
index 7745deb60c2..be607bc17ee 100644
--- a/netbox/dcim/forms/model_forms.py
+++ b/netbox/dcim/forms/model_forms.py
@@ -2009,6 +2009,12 @@ class MACAddressForm(PrimaryModelForm):
),
)
+ restricted_related_selectors = {
+ # The selectors are stored as the assigned_object GenericForeignKey; the model picks the matching one.
+ 'interface': {'path': 'assigned_object', 'model': Interface},
+ 'vminterface': {'path': 'assigned_object', 'model': VMInterface},
+ }
+
class Meta:
model = MACAddress
fields = [
diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py
index f1b23e0d54e..3706a4b7707 100644
--- a/netbox/extras/forms/model_forms.py
+++ b/netbox/extras/forms/model_forms.py
@@ -559,6 +559,11 @@ class EventRuleForm(OwnerMixin, NetBoxModelForm):
FieldSet('action_type', 'action_choice', 'action_data', name=_('Action')),
)
+ # action_object_type/action_object_id are recomputed in clean() from these two fields.
+ restricted_related_selectors = {
+ 'action_choice': {'path': 'action_object', 'lock_fields': ('action_type',)},
+ }
+
class Meta:
model = EventRule
fields = (
diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py
index bffe7a1eb99..b0d3d9785ff 100644
--- a/netbox/ipam/forms/model_forms.py
+++ b/netbox/ipam/forms/model_forms.py
@@ -355,6 +355,13 @@ class IPAddressForm(TenancyForm, PrimaryModelForm):
FieldSet('nat_inside', name=_('NAT IP (Inside)')),
)
+ restricted_related_selectors = {
+ # The selectors are stored as the assigned_object GenericForeignKey; the model picks the matching one.
+ 'interface': {'path': 'assigned_object', 'model': Interface},
+ 'vminterface': {'path': 'assigned_object', 'model': VMInterface},
+ 'fhrpgroup': {'path': 'assigned_object', 'model': FHRPGroup},
+ }
+
class Meta:
model = IPAddress
fields = [
@@ -651,6 +658,10 @@ class Meta:
'tags',
]
+ restricted_related_selectors = {
+ 'scope': {'path': 'scope', 'lock_fields': ('scope_type',)},
+ }
+
def __init__(self, *args, **kwargs):
instance = kwargs.get('instance')
initial = kwargs.get('initial', {})
@@ -813,6 +824,10 @@ class ServiceForm(PrimaryModelForm):
),
)
+ restricted_related_selectors = {
+ 'parent': {'path': 'parent', 'lock_fields': ('parent_object_type',)},
+ }
+
class Meta:
model = Service
fields = [
diff --git a/netbox/netbox/forms/model_forms.py b/netbox/netbox/forms/model_forms.py
index cfca172550b..df36d99c6bb 100644
--- a/netbox/netbox/forms/model_forms.py
+++ b/netbox/netbox/forms/model_forms.py
@@ -2,7 +2,11 @@
from django import forms
from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import FieldDoesNotExist
+from django.db.models import Model
from django.db.models.fields.related import ManyToManyRel
+from django.utils.html import format_html
+from django.utils.translation import gettext_lazy as _
from extras.choices import *
from utilities.forms.fields import CommentField, SlugField
@@ -18,6 +22,21 @@
)
+class RestrictedChoiceLabel:
+ """
+ Wraps a choice label so widgets/select_option.html renders that single as disabled. Used to identify a
+ restricted current value as read-only in widgets which honor disabled options. Preservation itself is enforced
+ server-side, not by the widget.
+ """
+ disabled = True
+
+ def __init__(self, label):
+ self.label = label
+
+ def __str__(self):
+ return str(self.label)
+
+
class NetBoxModelForm(
ChangelogMessageMixin,
CheckLastUpdatedMixin,
@@ -34,6 +53,19 @@ class NetBoxModelForm(
"""
fieldsets = ()
+ restricted_value_help_text = _(
+ 'This field includes one or more restricted values that cannot be changed. '
+ 'They will be preserved when this form is saved.'
+ )
+
+ # Maps selector form fields whose current value is stored under a different instance attribute (e.g. a
+ # GenericForeignKey). Keys are form field names; entries may declare:
+ # path: dotted attribute path on the instance holding the current value (read from the instance only)
+ # model: expected model class; set when several selector fields share one path (picks the matching field)
+ # lock_fields: controller field names locked alongside the selector when its current value is hidden
+ # Subclasses override (not merge) this attribute.
+ restricted_related_selectors = {}
+
def _get_content_type(self):
return ContentType.objects.get_for_model(self._meta.model)
@@ -49,7 +81,292 @@ def _get_form_field(self, customfield):
return customfield.to_form_field()
+ def prepare_restricted_queryset_fields(self, restricted_fields, user=None, action='view'):
+ """
+ Render assigned values which were removed by object-permission filtering as read-only.
+
+ `restricted_fields` maps each restricted field name to its original (pre-restriction) queryset. `user` and
+ `action` allow re-checking visibility for GenericForeignKey values whose model differs from the field.
+
+ Security rules:
+
+ * Only values already assigned to the current instance are added back; the rest of the original queryset is
+ never exposed.
+ * A value is preserved only when the user cannot view it; values excluded by the form's own base queryset
+ are left untouched.
+ * Restricted current values are read-only and submitted or tampered values for them are ignored.
+
+ Scalar fields (and fields already disabled, e.g. read-only custom fields) are disabled entirely. Editable
+ multi-value fields stay editable so the user can manage permitted values, while restricted current members
+ are shown as disabled options and preserved server-side in clean().
+ """
+ # Guard against running twice on the same form (which would double-wrap labels and duplicate help text).
+ if getattr(self, '_restricted_queryset_fields_prepared', False) or not self.instance.pk:
+ return
+ self._restricted_queryset_fields_prepared = True
+ self._restricted_preserved_members = {}
+
+ for field_name, original_queryset in restricted_fields.items():
+ field = self.fields.get(field_name)
+ if field is None:
+ continue
+
+ restricted_queryset = getattr(field, 'queryset', None)
+ if restricted_queryset is None:
+ continue
+
+ current_objects = self._get_restricted_queryset_field_current_objects(field_name, original_queryset)
+ if not current_objects:
+ continue
+
+ hidden_objects = [
+ obj for obj in current_objects
+ if self._restricted_value_is_hidden(field, restricted_queryset, original_queryset, obj, user, action)
+ ]
+ if not hidden_objects:
+ continue
+
+ if isinstance(field, forms.ModelMultipleChoiceField) and not field.disabled:
+ self._prepare_restricted_multiple_field(
+ field_name, field, original_queryset, restricted_queryset, current_objects, hidden_objects
+ )
+ else:
+ self._lock_restricted_queryset_field(field_name, field, current_objects, hidden_objects)
+
+ def _lock_restricted_queryset_field(self, field_name, field, current_objects, hidden_objects):
+ """
+ Show only the current value(s) and make the whole field read-only for this request.
+
+ Disabling the field makes Django ignore submitted data and clean the initial value instead. The queryset is
+ narrowed to the current objects using their own model, so a GenericForeignKey value is preserved even when
+ the submitted type field points at a different model. Controller fields declared for the selector
+ (lock_fields) are locked too so construct_instance() and clean() cannot corrupt the preserved value.
+ """
+ multiple = isinstance(field, forms.ModelMultipleChoiceField)
+ field.disabled = True
+ field.required = False
+ field.widget.is_required = False
+
+ field.queryset = self._queryset_for_objects(current_objects)
+
+ if multiple:
+ initial = [field.prepare_value(obj) for obj in current_objects]
+ else:
+ initial = field.prepare_value(current_objects[0])
+ field.initial = initial
+ self.initial[field_name] = initial
+
+ self._lock_controller_fields(field_name, hidden_objects)
+ self._append_restricted_value_help_text(field)
+
+ def _queryset_for_objects(self, objects):
+ """
+ Build a queryset over the objects' own model restricted to their PKs. A scalar selector holds one type at a
+ time, so all objects share a model.
+ """
+ model = type(objects[0])
+ return model.objects.filter(pk__in=[obj.pk for obj in objects])
+
+ def _lock_controller_fields(self, field_name, hidden_objects):
+ """
+ Lock the controller fields declared for a selector (lock_fields) so a submitted value cannot corrupt the
+ preserved assignment. A content type controller is pinned to the current object's own type; any other
+ controller is pinned to the value stored on the instance.
+ """
+ selector = self.restricted_related_selectors.get(field_name) or {}
+ for controller_name in selector.get('lock_fields', ()):
+ controller = self.fields.get(controller_name)
+ if controller is None:
+ continue
+ queryset = getattr(controller, 'queryset', None)
+ if queryset is not None and issubclass(queryset.model, ContentType):
+ content_type = ContentType.objects.get_for_model(type(hidden_objects[0]))
+ controller.queryset = queryset.model.objects.filter(pk=content_type.pk)
+ initial = content_type.pk
+ else:
+ initial = getattr(self.instance, controller_name, None)
+ if isinstance(initial, Model):
+ initial = initial.pk
+ controller.disabled = True
+ controller.required = False
+ controller.widget.is_required = False
+ controller.initial = initial
+ self.initial[controller_name] = initial
+
+ def _prepare_restricted_multiple_field(
+ self, field_name, field, original_queryset, restricted_queryset, current_objects, hidden_objects
+ ):
+ """
+ Keep an editable multi-value field editable while rendering restricted current members as disabled options.
+
+ The user can still add or remove permitted values. Restricted current members are added back to the
+ queryset (so the current values validate and render selected) and shown as disabled options as a read-only
+ hint. Preservation is enforced in clean(): the hidden members are merged back into the field value
+ regardless of what the widget submits. Forged values are still rejected, because only the already-assigned
+ restricted members are added to the queryset.
+ """
+ hidden_pks = [obj.pk for obj in hidden_objects]
+
+ field.required = False
+ field.widget.is_required = False
+
+ # Allow the visible choices plus the already-assigned restricted members; expose nothing else. The two sides
+ # are disjoint by PK (hidden members are exactly those absent from the restricted queryset), so no DISTINCT.
+ hidden_queryset = original_queryset.filter(pk__in=hidden_pks)
+ field.queryset = restricted_queryset | hidden_queryset
+
+ # Show all current members selected on render.
+ initial = [field.prepare_value(obj) for obj in current_objects]
+ field.initial = initial
+ self.initial[field_name] = initial
+
+ # Render the restricted members as disabled options (a read-only hint; preservation is enforced server-side).
+ self._mark_restricted_choice_labels_disabled(field, hidden_objects)
+
+ # Preserve the hidden members on save without mutating submitted data.
+ self._restricted_preserved_members[field_name] = hidden_objects
+
+ self._append_restricted_value_help_text(field)
+
+ def _mark_restricted_choice_labels_disabled(self, field, hidden_objects):
+ """
+ Wrap label_from_instance so the restricted members render as disabled options.
+ """
+ hidden_values = {str(field.prepare_value(obj)) for obj in hidden_objects}
+ label_from_instance = field.label_from_instance
+
+ def label_with_restricted_marker(obj):
+ label = label_from_instance(obj)
+ if str(field.prepare_value(obj)) in hidden_values:
+ return RestrictedChoiceLabel(label)
+ return label
+
+ field.label_from_instance = label_with_restricted_marker
+
+ def _append_restricted_value_help_text(self, field):
+ if field.help_text:
+ field.help_text = format_html('{} {}', field.help_text, self.restricted_value_help_text)
+ else:
+ field.help_text = self.restricted_value_help_text
+
+ def _get_objects_from_queryset(self, queryset, pks):
+ """
+ Return objects for `pks` from `queryset`, preserving order. Used for selectors whose current PKs are known
+ to belong to the queryset's model.
+ """
+ if not pks:
+ return []
+ objects_by_pk = {str(obj.pk): obj for obj in queryset.filter(pk__in=pks)}
+ return [objects_by_pk[str(pk)] for pk in pks if str(pk) in objects_by_pk]
+
+ def _restricted_value_is_hidden(self, field, restricted_queryset, original_queryset, obj, user, action):
+ """
+ Return True if `obj` is an assigned value the user cannot view (and was a valid choice before restriction).
+
+ Objects whose model matches the field are checked against the field's own restricted/original querysets.
+ Objects of a different model (a GenericForeignKey whose paired type field was changed) are checked against
+ their own model, so a same-PK object of another model is never mistaken for the current value.
+ """
+ field_model = getattr(restricted_queryset, 'model', None)
+ if field_model is not None and type(obj) is field_model:
+ was_choice = original_queryset.filter(pk=obj.pk).exists()
+ is_visible = restricted_queryset.filter(pk=obj.pk).exists()
+ return was_choice and not is_visible
+
+ manager = type(obj).objects
+ if user is not None and hasattr(manager, 'restrict'):
+ return not manager.restrict(user, action).filter(pk=obj.pk).exists()
+ # Without a user we cannot re-check visibility for a different model, so preserve to avoid data loss.
+ return True
+
+ def _get_restricted_queryset_field_current_objects(self, field_name, original_queryset):
+ """
+ Return the current assigned objects for a restricted form field, read from the instance (never submitted
+ data). Objects carry their true model, so a GenericForeignKey value is never looked up against a model
+ chosen from submitted data.
+ """
+ if selector := self.restricted_related_selectors.get(field_name):
+ return self._get_restricted_selector_current_objects(selector)
+
+ if field_name in getattr(self, 'custom_fields', {}):
+ pks = self._get_restricted_custom_field_current_pks(field_name)
+ return self._get_objects_from_queryset(original_queryset, pks)
+
+ return self._get_current_objects_from_instance(field_name)
+
+ def _get_restricted_selector_current_objects(self, selector):
+ """
+ Resolve a declared selector's current value by walking its dotted path from the instance. When the
+ declaration names a model, a value of any other model belongs to a sibling selector and is skipped.
+ """
+ obj = self.instance
+ for attr in selector['path'].split('.'):
+ obj = getattr(obj, attr, None)
+ if obj is None:
+ return []
+ model = selector.get('model')
+ if model is not None and type(obj) is not model:
+ return []
+ return [obj]
+
+ def _get_current_objects_from_instance(self, field_name):
+ """
+ Resolve current assigned objects from the instance for forward/reverse M2M, FK/O2O, and same-named
+ GenericForeignKey fields. Returns model instances of their true type.
+ """
+ try:
+ model_field = self.instance._meta.get_field(field_name)
+ except FieldDoesNotExist:
+ model_field = None
+
+ # Forward many-to-many (includes django-taggit tags).
+ if model_field is not None and getattr(model_field, 'many_to_many', False):
+ return list(getattr(self.instance, field_name).all())
+
+ attr = getattr(self.instance, field_name, None)
+
+ # Reverse many-to-many exposed directly on a form.
+ if hasattr(attr, 'all') and hasattr(attr, 'values_list'):
+ return list(attr.all())
+
+ # Forward FK/O2O or a same-named GenericForeignKey: a single related object.
+ if isinstance(attr, Model):
+ return [attr]
+
+ return []
+
+ def _get_restricted_custom_field_current_pks(self, field_name):
+ """
+ Return current serialized PKs for object and multi-object custom fields.
+ """
+ customfield = self.custom_fields[field_name]
+ value = self.instance.custom_field_data.get(customfield.name)
+
+ if customfield.type == CustomFieldTypeChoices.TYPE_OBJECT:
+ if value is None:
+ return []
+ return [value.pk if isinstance(value, Model) else value]
+
+ if customfield.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
+ return [
+ obj.pk if isinstance(obj, Model) else obj
+ for obj in value or []
+ ]
+
+ return []
+
+ def _merge_restricted_preserved_members(self):
+ for field_name, objects in getattr(self, '_restricted_preserved_members', {}).items():
+ if field_name not in self.cleaned_data:
+ continue
+ existing = list(self.cleaned_data[field_name])
+ existing_pks = {obj.pk for obj in existing}
+ self.cleaned_data[field_name] = existing + [obj for obj in objects if obj.pk not in existing_pks]
+
def clean(self):
+ # Merge restricted current members the user could not see back into multi-value fields so they survive on
+ # save (their options are disabled in the widget and so are not submitted).
+ self._merge_restricted_preserved_members()
# Save custom field data on instance
for cf_name, customfield in self.custom_fields.items():
diff --git a/netbox/netbox/tests/test_restricted_form_fields.py b/netbox/netbox/tests/test_restricted_form_fields.py
new file mode 100644
index 00000000000..707fa8b386f
--- /dev/null
+++ b/netbox/netbox/tests/test_restricted_form_fields.py
@@ -0,0 +1,866 @@
+from django.test import TestCase as DjangoTestCase
+from django.urls import reverse
+
+from circuits.forms import CircuitGroupAssignmentForm, CircuitTerminationForm
+from circuits.models import (
+ Circuit,
+ CircuitGroup,
+ CircuitGroupAssignment,
+ CircuitTermination,
+ CircuitType,
+ Provider,
+ ProviderNetwork,
+ VirtualCircuit,
+)
+from core.models import ObjectType
+from dcim.forms import MACAddressForm
+from dcim.models import Device, DeviceRole, DeviceType, Interface, MACAddress, Manufacturer, Site
+from extras.choices import CustomFieldTypeChoices, CustomFieldUIEditableChoices, EventRuleActionChoices
+from extras.forms import EventRuleForm
+from extras.models import CustomField, EventRule, NotificationGroup, Tag, Webhook
+from ipam.forms import IPAddressForm, ServiceForm, VLANGroupForm
+from ipam.models import IPAddress, Service, VLANGroup
+from netbox.forms import NetBoxModelForm
+from netbox.forms.model_forms import RestrictedChoiceLabel
+from tenancy.models import Tenant
+from users.models import ObjectPermission, User
+from utilities.forms.utils import restrict_form_fields
+from utilities.testing import TestCase, create_test_device, create_test_virtualmachine
+from virtualization.models import VirtualMachine, VMInterface
+from vpn.choices import TunnelTerminationTypeChoices
+from vpn.forms import L2VPNTerminationForm, TunnelTerminationForm
+from vpn.models import L2VPN, L2VPNTermination, Tunnel, TunnelTermination
+
+
+def simulate_restrict(form, field_name, restricted_queryset, original_queryset=None):
+ """
+ Stand in for restrict_form_fields(): record the original queryset, swap in the restricted one, then prepare the
+ read-only display. `original_queryset` defaults to the field's current (pre-restriction) queryset.
+ """
+ if original_queryset is None:
+ original_queryset = form.fields[field_name].queryset
+ form.fields[field_name].queryset = restricted_queryset
+ form.prepare_restricted_queryset_fields({field_name: original_queryset})
+
+
+class SiteTagsForm(NetBoxModelForm):
+ class Meta:
+ model = Site
+ fields = ('name', 'slug', 'status', 'tags')
+
+
+class SiteTenantForm(NetBoxModelForm):
+ class Meta:
+ model = Site
+ fields = ('name', 'slug', 'status', 'tenant')
+
+
+class SiteBaseForm(NetBoxModelForm):
+ class Meta:
+ model = Site
+ fields = ('name', 'slug', 'status')
+
+
+class RestrictedScalarFieldTest(DjangoTestCase):
+
+ def setUp(self):
+ self.visible_tenant = Tenant.objects.create(name='Visible Tenant', slug='visible-tenant')
+ self.hidden_tenant = Tenant.objects.create(name='Hidden Tenant', slug='hidden-tenant')
+
+ def test_restricted_value_is_shown_disabled_and_preserved(self):
+ """A scalar value removed by permissions is shown read-only and preserved on save."""
+ site = Site.objects.create(name='Site 1', slug='site-1', tenant=self.hidden_tenant)
+
+ form = SiteTenantForm(
+ data={'name': site.name, 'slug': site.slug, 'status': 'active'},
+ instance=site
+ )
+ simulate_restrict(form, 'tenant', Tenant.objects.filter(pk=self.visible_tenant.pk))
+
+ self.assertTrue(form.fields['tenant'].disabled)
+ self.assertEqual(form['tenant'].value(), self.hidden_tenant.pk)
+ self.assertIn(self.hidden_tenant.pk, set(form.fields['tenant'].queryset.values_list('pk', flat=True)))
+
+ self.assertTrue(form.is_valid(), form.errors)
+ site = form.save()
+ self.assertEqual(site.tenant, self.hidden_tenant)
+
+ def test_replacement_is_ignored(self):
+ """A submitted replacement for a disabled scalar field is ignored."""
+ site = Site.objects.create(name='Site 1', slug='site-1', tenant=self.hidden_tenant)
+
+ form = SiteTenantForm(
+ data={'name': site.name, 'slug': site.slug, 'status': 'active', 'tenant': self.visible_tenant.pk},
+ instance=site
+ )
+ simulate_restrict(form, 'tenant', Tenant.objects.filter(pk=self.visible_tenant.pk))
+
+ self.assertTrue(form.fields['tenant'].disabled)
+ self.assertTrue(form.is_valid(), form.errors)
+ site = form.save()
+ self.assertEqual(site.tenant, self.hidden_tenant)
+
+ def test_visible_value_is_not_locked(self):
+ """A visible scalar value remains editable (the field is not disabled)."""
+ site = Site.objects.create(name='Site 1', slug='site-1', tenant=self.visible_tenant)
+
+ form = SiteTenantForm(
+ data={'name': site.name, 'slug': site.slug, 'status': 'active', 'tenant': self.visible_tenant.pk},
+ instance=site
+ )
+ simulate_restrict(form, 'tenant', Tenant.objects.filter(pk=self.visible_tenant.pk))
+
+ self.assertFalse(form.fields['tenant'].disabled)
+ self.assertTrue(form.is_valid(), form.errors)
+
+ def test_business_excluded_value_is_not_locked(self):
+ """A value absent from the original queryset for non-permission reasons is not locked."""
+ site = Site.objects.create(name='Site 1', slug='site-1', tenant=self.hidden_tenant)
+
+ form = SiteTenantForm(
+ data={'name': site.name, 'slug': site.slug, 'status': 'active', 'tenant': self.visible_tenant.pk},
+ instance=site
+ )
+ # The original queryset already excludes the current tenant (business filter); nothing further is removed.
+ business_queryset = Tenant.objects.filter(pk=self.visible_tenant.pk)
+ simulate_restrict(form, 'tenant', business_queryset, original_queryset=business_queryset)
+
+ self.assertFalse(form.fields['tenant'].disabled)
+ self.assertTrue(form.is_valid(), form.errors)
+ site = form.save()
+ self.assertEqual(site.tenant, self.visible_tenant)
+
+ def test_locked_field_keeps_existing_help_text(self):
+ """Locking a field appends to, rather than replaces, any existing help text."""
+ site = Site.objects.create(name='Site 1', slug='site-1', tenant=self.hidden_tenant)
+
+ form = SiteTenantForm(
+ data={'name': site.name, 'slug': site.slug, 'status': 'active'},
+ instance=site
+ )
+ form.fields['tenant'].help_text = 'Existing help.'
+ simulate_restrict(form, 'tenant', Tenant.objects.filter(pk=self.visible_tenant.pk))
+
+ help_text = str(form.fields['tenant'].help_text)
+ self.assertIn('Existing help.', help_text)
+ self.assertIn('restricted value', help_text)
+
+ def test_unrestricted_user_does_not_lock_fields(self):
+ """When restrict() leaves every field unchanged (e.g. a superuser), preparation is skipped entirely."""
+ user = User.objects.create_user(username='superuser', is_superuser=True)
+ site = Site.objects.create(name='Site 1', slug='site-1', tenant=self.hidden_tenant)
+
+ form = SiteTenantForm(instance=site)
+ restrict_form_fields(form, user)
+
+ self.assertFalse(form.fields['tenant'].disabled)
+ self.assertFalse(getattr(form, '_restricted_queryset_fields_prepared', False))
+
+ def test_preparation_is_idempotent(self):
+ """Running the preparation twice does not duplicate help text or re-wrap labels."""
+ site = Site.objects.create(name='Site 1', slug='site-1', tenant=self.hidden_tenant)
+
+ form = SiteTenantForm(
+ data={'name': site.name, 'slug': site.slug, 'status': 'active'},
+ instance=site
+ )
+ simulate_restrict(form, 'tenant', Tenant.objects.filter(pk=self.visible_tenant.pk))
+ help_text = str(form.fields['tenant'].help_text)
+
+ form.prepare_restricted_queryset_fields({'tenant': Tenant.objects.all()})
+ self.assertEqual(str(form.fields['tenant'].help_text), help_text)
+
+
+class RestrictedMultiValueFieldTest(DjangoTestCase):
+
+ def setUp(self):
+ self.visible_tag = Tag.objects.create(name='Visible Tag', slug='visible-tag')
+ self.hidden_tag = Tag.objects.create(name='Hidden Tag', slug='hidden-tag')
+ self.other_hidden_tag = Tag.objects.create(name='Other Hidden Tag', slug='other-hidden-tag')
+
+ def test_restricted_member_is_read_only_but_field_stays_editable(self):
+ """A restricted current tag is preserved while the field stays editable; removing the visible tag works."""
+ site = Site.objects.create(name='Site 1', slug='site-1')
+ site.tags.set([self.visible_tag, self.hidden_tag])
+
+ form = SiteTagsForm(
+ # The user removes the visible tag. The restricted tag's option is disabled and not submitted.
+ data={'name': site.name, 'slug': site.slug, 'status': 'active', 'tags': []},
+ instance=site
+ )
+ simulate_restrict(form, 'tags', Tag.objects.filter(pk=self.visible_tag.pk))
+
+ self.assertFalse(form.fields['tags'].disabled)
+ self.assertTrue(form.is_valid(), form.errors)
+ site = form.save()
+ self.assertEqual(set(site.tags.values_list('pk', flat=True)), {self.hidden_tag.pk})
+
+ def test_visible_tag_can_be_added_while_restricted_member_preserved(self):
+ """A visible tag can be added while the restricted current tag is preserved."""
+ site = Site.objects.create(name='Site 1', slug='site-1')
+ site.tags.set([self.hidden_tag])
+
+ form = SiteTagsForm(
+ data={'name': site.name, 'slug': site.slug, 'status': 'active', 'tags': [self.visible_tag.pk]},
+ instance=site
+ )
+ simulate_restrict(form, 'tags', Tag.objects.filter(pk=self.visible_tag.pk))
+
+ self.assertFalse(form.fields['tags'].disabled)
+ self.assertTrue(form.is_valid(), form.errors)
+ site = form.save()
+ self.assertEqual(
+ set(site.tags.values_list('pk', flat=True)),
+ {self.hidden_tag.pk, self.visible_tag.pk}
+ )
+
+ def test_restricted_member_preserved_on_prefixed_form(self):
+ """Preservation works for a prefixed form: hidden members are merged in clean(), not into submitted data."""
+ site = Site.objects.create(name='Site 1', slug='site-1')
+ site.tags.set([self.visible_tag, self.hidden_tag])
+
+ form = SiteTagsForm(
+ data={
+ 'quickadd-name': site.name,
+ 'quickadd-slug': site.slug,
+ 'quickadd-status': 'active',
+ 'quickadd-tags': [],
+ },
+ instance=site,
+ prefix='quickadd'
+ )
+ simulate_restrict(form, 'tags', Tag.objects.filter(pk=self.visible_tag.pk))
+
+ self.assertFalse(form.fields['tags'].disabled)
+ self.assertTrue(form.is_valid(), form.errors)
+ site = form.save()
+ self.assertEqual(set(site.tags.values_list('pk', flat=True)), {self.hidden_tag.pk})
+
+ def test_forged_non_current_restricted_value_is_rejected(self):
+ """A restricted tag not already assigned cannot be added."""
+ site = Site.objects.create(name='Site 1', slug='site-1')
+ site.tags.set([self.hidden_tag])
+
+ form = SiteTagsForm(
+ data={'name': site.name, 'slug': site.slug, 'status': 'active', 'tags': [self.other_hidden_tag.pk]},
+ instance=site
+ )
+ simulate_restrict(form, 'tags', Tag.objects.filter(pk=self.visible_tag.pk))
+
+ self.assertFalse(form.fields['tags'].disabled)
+ self.assertFalse(form.is_valid())
+
+ def test_restricted_choice_label(self):
+ """RestrictedChoiceLabel is disabled and stringifies to its wrapped label."""
+ label = RestrictedChoiceLabel('Tag 1')
+ self.assertTrue(label.disabled)
+ self.assertEqual(str(label), 'Tag 1')
+
+ def test_restricted_member_renders_as_disabled_option(self):
+ """The restricted member renders as a disabled while the field itself is not disabled."""
+ site = Site.objects.create(name='Site 1', slug='site-1')
+ site.tags.set([self.visible_tag, self.hidden_tag])
+
+ form = SiteTagsForm(instance=site)
+ simulate_restrict(form, 'tags', Tag.objects.filter(pk=self.visible_tag.pk))
+
+ rendered = str(form['tags'])
+ # The itself is not disabled; only the restricted option carries disabled="disabled".
+ self.assertFalse(form.fields['tags'].disabled)
+ self.assertIn(f'value="{self.hidden_tag.pk}"', rendered)
+ self.assertIn('disabled="disabled"', rendered)
+
+ def test_visible_field_remains_editable(self):
+ """A fully visible tags field stays editable and can be cleared."""
+ site = Site.objects.create(name='Site 1', slug='site-1')
+ site.tags.set([self.visible_tag])
+
+ form = SiteTagsForm(
+ data={'name': site.name, 'slug': site.slug, 'status': 'active', 'tags': []},
+ instance=site
+ )
+ simulate_restrict(form, 'tags', Tag.objects.filter(pk=self.visible_tag.pk))
+
+ self.assertFalse(form.fields['tags'].disabled)
+ self.assertTrue(form.is_valid(), form.errors)
+ site = form.save()
+ self.assertEqual(site.tags.count(), 0)
+
+ def test_editable_field_rejects_forged_value(self):
+ """An editable field (no hidden current value) still validates submitted values against the restricted qs."""
+ site = Site.objects.create(name='Site 1', slug='site-1')
+ site.tags.set([self.visible_tag])
+
+ form = SiteTagsForm(
+ data={
+ 'name': site.name,
+ 'slug': site.slug,
+ 'status': 'active',
+ 'tags': [self.visible_tag.pk, self.other_hidden_tag.pk],
+ },
+ instance=site
+ )
+ simulate_restrict(form, 'tags', Tag.objects.filter(pk=self.visible_tag.pk))
+
+ self.assertFalse(form.fields['tags'].disabled)
+ self.assertFalse(form.is_valid())
+
+
+class RestrictedCustomFieldTest(DjangoTestCase):
+
+ def setUp(self):
+ site_type = ObjectType.objects.get_for_model(Site)
+ tenant_type = ObjectType.objects.get_for_model(Tenant)
+
+ self.cf_object = CustomField.objects.create(
+ name='primary_vendor',
+ type=CustomFieldTypeChoices.TYPE_OBJECT,
+ related_object_type=tenant_type
+ )
+ self.cf_object.object_types.set([site_type])
+
+ self.cf_multiobject = CustomField.objects.create(
+ name='vendors',
+ type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
+ related_object_type=tenant_type
+ )
+ self.cf_multiobject.object_types.set([site_type])
+
+ self.visible_tenant = Tenant.objects.create(name='Visible Tenant', slug='visible-tenant')
+ self.hidden_tenant = Tenant.objects.create(name='Hidden Tenant', slug='hidden-tenant')
+
+ def test_editable_multiobject_restricted_member_is_preserved(self):
+ """An editable multi-object custom field stays editable; the restricted member is preserved."""
+ site = Site.objects.create(name='Site 1', slug='site-1')
+ site.custom_field_data['vendors'] = [self.hidden_tenant.pk, self.visible_tenant.pk]
+ site.save()
+
+ form = SiteBaseForm(
+ # The user removes the visible tenant; the restricted tenant's option is disabled and not submitted.
+ data={'name': site.name, 'slug': site.slug, 'status': 'active', 'cf_vendors': []},
+ instance=site
+ )
+ simulate_restrict(form, 'cf_vendors', Tenant.objects.filter(pk=self.visible_tenant.pk))
+
+ self.assertFalse(form.fields['cf_vendors'].disabled)
+ self.assertTrue(form.is_valid(), form.errors)
+ site = form.save()
+ self.assertEqual(set(site.custom_field_data['vendors']), {self.hidden_tenant.pk})
+
+ def test_two_restricted_multi_value_fields_are_both_preserved(self):
+ """Two restricted multi-value fields on one bound form both preserve their restricted members."""
+ visible_tag = Tag.objects.create(name='Visible Tag', slug='visible-tag-multi')
+ hidden_tag = Tag.objects.create(name='Hidden Tag', slug='hidden-tag-multi')
+ site = Site.objects.create(name='Site 1', slug='site-1')
+ site.tags.set([visible_tag, hidden_tag])
+ site.custom_field_data['vendors'] = [self.hidden_tenant.pk, self.visible_tenant.pk]
+ site.save()
+
+ form = SiteBaseForm(
+ data={'name': site.name, 'slug': site.slug, 'status': 'active', 'tags': [], 'cf_vendors': []},
+ instance=site
+ )
+ form.fields['tags'].queryset = Tag.objects.filter(pk=visible_tag.pk)
+ form.fields['cf_vendors'].queryset = Tenant.objects.filter(pk=self.visible_tenant.pk)
+ form.prepare_restricted_queryset_fields({
+ 'tags': Tag.objects.all(),
+ 'cf_vendors': Tenant.objects.all(),
+ })
+
+ self.assertFalse(form.fields['tags'].disabled)
+ self.assertFalse(form.fields['cf_vendors'].disabled)
+ self.assertTrue(form.is_valid(), form.errors)
+ site = form.save()
+ self.assertEqual(set(site.tags.values_list('pk', flat=True)), {hidden_tag.pk})
+ self.assertEqual(set(site.custom_field_data['vendors']), {self.hidden_tenant.pk})
+
+ def test_hidden_object_value_is_shown_disabled_and_preserved(self):
+ """An object custom field with a hidden value is disabled and preserved."""
+ site = Site.objects.create(name='Site 1', slug='site-1')
+ site.custom_field_data['primary_vendor'] = self.hidden_tenant.pk
+ site.save()
+
+ form = SiteBaseForm(
+ data={'name': site.name, 'slug': site.slug, 'status': 'active'},
+ instance=site
+ )
+ simulate_restrict(form, 'cf_primary_vendor', Tenant.objects.filter(pk=self.visible_tenant.pk))
+
+ self.assertTrue(form.fields['cf_primary_vendor'].disabled)
+ self.assertEqual(form['cf_primary_vendor'].value(), self.hidden_tenant.pk)
+ self.assertTrue(form.is_valid(), form.errors)
+ site = form.save()
+ self.assertEqual(site.custom_field_data['primary_vendor'], self.hidden_tenant.pk)
+
+ def test_readonly_multiobject_hidden_value_is_preserved(self):
+ """A read-only (ui_editable='no') multi-object custom field with a hidden member is preserved."""
+ cf = CustomField.objects.create(
+ name='readonly_vendors',
+ type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
+ related_object_type=ObjectType.objects.get_for_model(Tenant),
+ ui_editable=CustomFieldUIEditableChoices.NO
+ )
+ cf.object_types.set([ObjectType.objects.get_for_model(Site)])
+
+ site = Site.objects.create(name='Site 1', slug='site-1')
+ site.custom_field_data['readonly_vendors'] = [self.hidden_tenant.pk, self.visible_tenant.pk]
+ site.save()
+
+ form = SiteBaseForm(
+ data={'name': site.name, 'slug': site.slug, 'status': 'active'},
+ instance=site
+ )
+ self.assertTrue(form.fields['cf_readonly_vendors'].disabled)
+ simulate_restrict(form, 'cf_readonly_vendors', Tenant.objects.filter(pk=self.visible_tenant.pk))
+
+ self.assertTrue(form.is_valid(), form.errors)
+ site = form.save()
+ self.assertEqual(
+ set(site.custom_field_data['readonly_vendors']),
+ {self.hidden_tenant.pk, self.visible_tenant.pk}
+ )
+
+
+class RestrictedSyntheticSelectorTest(DjangoTestCase):
+
+ @classmethod
+ def setUpTestData(cls):
+ manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+ device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
+ role = DeviceRole.objects.create(name='Role 1', slug='role-1')
+ site = Site.objects.create(name='Site 1', slug='site-1')
+ cls.device = Device.objects.create(name='Device 1', device_type=device_type, role=role, site=site)
+ cls.interface = Interface.objects.create(device=cls.device, name='eth0', type='1000base-t')
+
+ def test_ipaddress_selector_resolves_current_assignment(self):
+ """The declared selectors resolve the assigned interface only for the matching field."""
+ ip = IPAddress(address='192.0.2.1/24')
+ ip.assigned_object = self.interface
+ ip.save()
+ ip = IPAddress.objects.get(pk=ip.pk)
+
+ form = IPAddressForm(instance=ip)
+ original = Interface.objects.all()
+ self.assertEqual(form._get_restricted_queryset_field_current_objects('interface', original), [self.interface])
+ self.assertEqual(form._get_restricted_queryset_field_current_objects('vminterface', original), [])
+ self.assertEqual(form._get_restricted_queryset_field_current_objects('fhrpgroup', original), [])
+
+ def test_macaddress_selector_resolves_current_assignment(self):
+ """The declared selectors resolve the assigned interface only for the matching field."""
+ mac = MACAddress(mac_address='00:11:22:33:44:55')
+ mac.assigned_object = self.interface
+ mac.save()
+
+ form = MACAddressForm(instance=mac)
+ original = Interface.objects.all()
+ self.assertEqual(form._get_restricted_queryset_field_current_objects('interface', original), [self.interface])
+ self.assertEqual(form._get_restricted_queryset_field_current_objects('vminterface', original), [])
+
+ # An unassigned object resolves no current value for any selector.
+ unassigned = MACAddress.objects.create(mac_address='00:11:22:33:44:66')
+ form = MACAddressForm(instance=unassigned)
+ self.assertEqual(form._get_restricted_queryset_field_current_objects('interface', original), [])
+
+ def test_l2vpntermination_selector_resolves_current_assignment(self):
+ """The declared selectors resolve the assigned interface only for the matching field."""
+ l2vpn = L2VPN.objects.create(name='L2VPN 1', slug='l2vpn-1', type='vxlan')
+ termination = L2VPNTermination(l2vpn=l2vpn)
+ termination.assigned_object = self.interface
+ termination.save()
+
+ form = L2VPNTerminationForm(instance=termination)
+ original = Interface.objects.all()
+ self.assertEqual(form._get_restricted_queryset_field_current_objects('interface', original), [self.interface])
+ self.assertEqual(form._get_restricted_queryset_field_current_objects('vminterface', original), [])
+ self.assertEqual(form._get_restricted_queryset_field_current_objects('vlan', original), [])
+
+ def test_ipaddress_cross_selector_submission_cannot_replace_hidden_assignment(self):
+ """A value submitted for a sibling selector cannot silently replace a hidden assignment."""
+ ip = IPAddress(address='192.0.2.1/24')
+ ip.assigned_object = self.interface
+ ip.save()
+ ip = IPAddress.objects.get(pk=ip.pk)
+ vm = create_test_virtualmachine('VM 1')
+ vminterface = VMInterface.objects.create(virtual_machine=vm, name='eth0')
+
+ form = IPAddressForm(
+ data={'address': '192.0.2.1/24', 'status': 'active', 'vminterface': vminterface.pk},
+ instance=ip,
+ )
+ simulate_restrict(form, 'interface', Interface.objects.none())
+
+ # The locked hidden interface plus the submitted sibling trip the single-assignment validation.
+ self.assertFalse(form.is_valid())
+ self.assertIn('vminterface', form.errors)
+ self.assertEqual(IPAddress.objects.get(pk=ip.pk).assigned_object, self.interface)
+
+ def test_tunneltermination_parent_resolves_through_termination(self):
+ """The auxiliary parent selector resolves the termination's owner via its dotted path."""
+ tunnel = Tunnel.objects.create(name='Tunnel 1', encapsulation='gre', status='active')
+ termination = TunnelTermination.objects.create(tunnel=tunnel, role='peer', termination=self.interface)
+
+ form = TunnelTerminationForm(instance=termination)
+ self.assertEqual(
+ form._get_restricted_queryset_field_current_objects('parent', Device.objects.all()),
+ [self.device],
+ )
+
+ def test_hidden_assigned_object_is_shown_disabled_and_preserved(self):
+ """Editing a MAC address whose assigned interface is hidden shows it disabled and preserves it."""
+ mac = MACAddress(mac_address='00:11:22:33:44:55')
+ mac.assigned_object = self.interface
+ mac.save()
+
+ form = MACAddressForm(data={'mac_address': '00:11:22:33:44:55'}, instance=mac)
+ simulate_restrict(form, 'interface', Interface.objects.none())
+
+ self.assertTrue(form.fields['interface'].disabled)
+ self.assertEqual(form['interface'].value(), self.interface.pk)
+ self.assertTrue(form.is_valid(), form.errors)
+ mac = form.save()
+ self.assertEqual(mac.assigned_object, self.interface)
+
+
+class RestrictedEditViewTest(TestCase):
+
+ def setUp(self):
+ super().setUp()
+ self.visible_tag = Tag.objects.create(name='VisibleTagAlpha', slug='visible-tag-alpha')
+ self.hidden_tag = Tag.objects.create(name='HiddenTagBravo', slug='hidden-tag-bravo')
+ self.site = Site.objects.create(name='Site 1', slug='site-1', status='active')
+ self.site.tags.set([self.visible_tag, self.hidden_tag])
+
+ def _grant(self):
+ # Allow editing the site, but only viewing the single visible tag (all tenants remain hidden).
+ self.add_permissions('dcim.view_site', 'dcim.change_site')
+ obj_perm = ObjectPermission(
+ name='View visible tag only',
+ constraints={'pk': self.visible_tag.pk},
+ actions=['view'],
+ )
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+ obj_perm.object_types.add(ObjectType.objects.get_for_model(Tag))
+
+ def _edit_url(self):
+ return reverse('dcim:site_edit', kwargs={'pk': self.site.pk})
+
+ def test_edit_preserves_restricted_tag(self):
+ """Keeping the visible tag preserves the restricted tag too."""
+ self._grant()
+ data = {'name': self.site.name, 'slug': self.site.slug, 'status': 'active', 'tags': [self.visible_tag.pk]}
+ response = self.client.post(self._edit_url(), data)
+
+ self.assertEqual(response.status_code, 302)
+ self.site.refresh_from_db()
+ self.assertEqual(
+ set(self.site.tags.values_list('pk', flat=True)),
+ {self.visible_tag.pk, self.hidden_tag.pk}
+ )
+
+ def test_edit_can_remove_visible_tag_while_restricted_preserved(self):
+ """Removing the visible tag drops only it; the restricted tag is preserved."""
+ self._grant()
+ data = {'name': self.site.name, 'slug': self.site.slug, 'status': 'active', 'tags': []}
+ response = self.client.post(self._edit_url(), data)
+
+ self.assertEqual(response.status_code, 302)
+ self.site.refresh_from_db()
+ self.assertEqual(set(self.site.tags.values_list('pk', flat=True)), {self.hidden_tag.pk})
+
+ def test_edit_preserves_hidden_tenant(self):
+ """A scalar value the user cannot view is preserved across an edit."""
+ tenant = Tenant.objects.create(name='SecretTenantDelta', slug='secret-tenant-delta')
+ self.site.tenant = tenant
+ self.site.save()
+ self._grant()
+
+ data = {'name': self.site.name, 'slug': self.site.slug, 'status': 'active', 'tags': [self.visible_tag.pk]}
+ response = self.client.post(self._edit_url(), data)
+
+ self.assertEqual(response.status_code, 302)
+ self.site.refresh_from_db()
+ self.assertEqual(self.site.tenant, tenant)
+
+ def test_get_shows_restricted_values_disabled(self):
+ """The edit form renders the restricted current values read-only with explanatory help text."""
+ tenant = Tenant.objects.create(name='SecretTenantDelta', slug='secret-tenant-delta')
+ self.site.tenant = tenant
+ self.site.save()
+ self._grant()
+
+ response = self.client.get(self._edit_url())
+ self.assertEqual(response.status_code, 200)
+ content = response.content.decode()
+
+ # The current restricted values are now shown read-only.
+ self.assertIn('HiddenTagBravo', content)
+ self.assertIn('SecretTenantDelta', content)
+ self.assertIn('disabled="disabled"', content)
+ self.assertIn('restricted values that cannot be changed', content)
+
+
+class RestrictedTypeDrivenSelectorTest(TestCase):
+ """Forms whose selector model is chosen by a paired content-type field preserve hidden current values."""
+
+ def setUp(self):
+ super().setUp()
+ # The user may edit, but has no view permission on the related models, so their values are hidden.
+ self.add_permissions(
+ 'ipam.add_service', 'ipam.change_service',
+ 'ipam.add_vlangroup', 'ipam.change_vlangroup',
+ 'vpn.add_tunneltermination', 'vpn.change_tunneltermination',
+ 'circuits.add_circuittermination', 'circuits.change_circuittermination',
+ 'circuits.add_circuitgroupassignment', 'circuits.change_circuitgroupassignment',
+ 'extras.add_eventrule', 'extras.change_eventrule',
+ )
+ self.device = create_test_device('Device 1')
+ self.interface = Interface.objects.create(device=self.device, name='eth0', type='1000base-t')
+
+ def test_service_parent_hidden_is_preserved(self):
+ """A Service whose parent device is hidden keeps that parent on save."""
+ service = Service.objects.create(name='svc', protocol='tcp', ports=[80], parent=self.device)
+ device_ct = ObjectType.objects.get_for_model(Device)
+
+ form = ServiceForm(
+ data={'name': 'svc', 'protocol': 'tcp', 'ports': '80', 'parent_object_type': device_ct.pk},
+ instance=service,
+ )
+ restrict_form_fields(form, self.user)
+
+ self.assertTrue(form.fields['parent'].disabled)
+ self.assertTrue(form.is_valid(), form.errors)
+ service = form.save()
+ self.assertEqual(service.parent, self.device)
+
+ def test_service_parent_type_tamper_is_blocked(self):
+ """Switching parent_object_type while the current parent is hidden cannot drop or remap the parent."""
+ service = Service.objects.create(name='svc', protocol='tcp', ports=[80], parent=self.device)
+ vm_ct = ObjectType.objects.get_for_model(VirtualMachine)
+
+ form = ServiceForm(
+ # Tampered: claim the parent is now a VirtualMachine, with no parent selected.
+ data={'name': 'svc', 'protocol': 'tcp', 'ports': '80', 'parent_object_type': vm_ct.pk, 'parent': ''},
+ instance=service,
+ )
+ restrict_form_fields(form, self.user)
+
+ self.assertTrue(form.fields['parent'].disabled)
+ self.assertTrue(form.fields['parent_object_type'].disabled)
+ self.assertTrue(form.is_valid(), form.errors)
+ service = form.save()
+ self.assertEqual(service.parent, self.device)
+ self.assertEqual(service.parent_object_type.model_class(), Device)
+
+ def test_vlangroup_scope_hidden_is_preserved(self):
+ """A VLANGroup whose scope site is hidden keeps that scope on save."""
+ site = Site.objects.create(name='Scope Site', slug='scope-site')
+ group = VLANGroup.objects.create(name='Group 1', slug='group-1', scope=site)
+ site_ct = ObjectType.objects.get_for_model(Site)
+
+ form = VLANGroupForm(
+ data={'name': 'Group 1', 'slug': 'group-1', 'vid_ranges': '1-100', 'scope_type': site_ct.pk},
+ instance=group,
+ )
+ restrict_form_fields(form, self.user)
+
+ self.assertTrue(form.fields['scope'].disabled)
+ self.assertTrue(form.is_valid(), form.errors)
+ group = form.save()
+ self.assertEqual(group.scope, site)
+
+ def test_tunneltermination_hidden_is_preserved(self):
+ """A TunnelTermination whose interface is hidden keeps that termination on save."""
+ tunnel = Tunnel.objects.create(name='Tunnel 1', encapsulation='gre', status='active')
+ termination = TunnelTermination.objects.create(tunnel=tunnel, role='peer', termination=self.interface)
+
+ form = TunnelTerminationForm(
+ data={'tunnel': tunnel.pk, 'role': 'peer', 'type': TunnelTerminationTypeChoices.TYPE_DEVICE},
+ instance=termination,
+ )
+ restrict_form_fields(form, self.user)
+
+ self.assertTrue(form.fields['termination'].disabled)
+ self.assertTrue(form.is_valid(), form.errors)
+ termination = form.save()
+ self.assertEqual(termination.termination, self.interface)
+
+ def test_tunneltermination_type_tamper_is_blocked(self):
+ """Switching the termination type while the interface is hidden cannot drop or remap it."""
+ tunnel = Tunnel.objects.create(name='Tunnel 1', encapsulation='gre', status='active')
+ termination = TunnelTermination.objects.create(tunnel=tunnel, role='peer', termination=self.interface)
+
+ form = TunnelTerminationForm(
+ # Tampered: claim a virtual-machine termination, with nothing selected.
+ data={
+ 'tunnel': tunnel.pk, 'role': 'peer',
+ 'type': TunnelTerminationTypeChoices.TYPE_VIRTUALMACHINE, 'parent': '', 'termination': '',
+ },
+ instance=termination,
+ )
+ restrict_form_fields(form, self.user)
+
+ self.assertTrue(form.fields['termination'].disabled)
+ self.assertTrue(form.is_valid(), form.errors)
+ termination = form.save()
+ self.assertEqual(termination.termination, self.interface)
+
+ def test_circuittermination_hidden_is_preserved(self):
+ """A CircuitTermination whose terminating site is hidden keeps that termination on save."""
+ self.add_permissions('circuits.view_circuit')
+ provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+ circuit_type = CircuitType.objects.create(name='Type 1', slug='type-1')
+ circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1')
+ site = Site.objects.create(name='Term Site', slug='term-site')
+ termination = CircuitTermination.objects.create(circuit=circuit, term_side='A', termination=site)
+ site_ct = ObjectType.objects.get_for_model(Site)
+
+ form = CircuitTerminationForm(
+ data={'circuit': circuit.pk, 'term_side': 'A', 'termination_type': site_ct.pk},
+ instance=termination,
+ )
+ restrict_form_fields(form, self.user)
+
+ self.assertTrue(form.fields['termination'].disabled)
+ self.assertTrue(form.fields['termination_type'].disabled)
+ self.assertTrue(form.is_valid(), form.errors)
+ termination = form.save()
+ self.assertEqual(termination.termination, site)
+
+ def test_circuittermination_type_tamper_is_blocked(self):
+ """Switching termination_type while the current termination is hidden cannot drop or remap it."""
+ self.add_permissions('circuits.view_circuit')
+ provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+ circuit_type = CircuitType.objects.create(name='Type 1', slug='type-1')
+ circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1')
+ site = Site.objects.create(name='Term Site', slug='term-site')
+ termination = CircuitTermination.objects.create(circuit=circuit, term_side='A', termination=site)
+ providernetwork_ct = ObjectType.objects.get_for_model(ProviderNetwork)
+
+ form = CircuitTerminationForm(
+ # Tampered: claim a provider-network termination, with nothing selected.
+ data={
+ 'circuit': circuit.pk, 'term_side': 'A',
+ 'termination_type': providernetwork_ct.pk, 'termination': '',
+ },
+ instance=termination,
+ )
+ restrict_form_fields(form, self.user)
+
+ self.assertTrue(form.fields['termination'].disabled)
+ self.assertTrue(form.fields['termination_type'].disabled)
+ self.assertTrue(form.is_valid(), form.errors)
+ termination = form.save()
+ self.assertEqual(termination.termination, site)
+ self.assertEqual(termination.termination_type.model_class(), Site)
+
+ def test_circuitgroupassignment_hidden_is_preserved(self):
+ """A group assignment whose member circuit is hidden keeps that member on save."""
+ self.add_permissions('circuits.view_circuitgroup')
+ provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+ circuit_type = CircuitType.objects.create(name='Type 1', slug='type-1')
+ circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1')
+ group = CircuitGroup.objects.create(name='Group 1', slug='group-1')
+ assignment = CircuitGroupAssignment.objects.create(group=group, member=circuit)
+ circuit_ct = ObjectType.objects.get_for_model(Circuit)
+
+ form = CircuitGroupAssignmentForm(
+ data={'group': group.pk, 'member_type': circuit_ct.pk},
+ instance=assignment,
+ )
+ restrict_form_fields(form, self.user)
+
+ self.assertTrue(form.fields['member'].disabled)
+ self.assertTrue(form.fields['member_type'].disabled)
+ self.assertTrue(form.is_valid(), form.errors)
+ assignment = form.save()
+ self.assertEqual(assignment.member, circuit)
+
+ def test_circuitgroupassignment_type_tamper_is_blocked(self):
+ """Switching member_type while the current member is hidden cannot drop or remap it."""
+ self.add_permissions('circuits.view_circuitgroup')
+ provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+ circuit_type = CircuitType.objects.create(name='Type 1', slug='type-1')
+ circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1')
+ group = CircuitGroup.objects.create(name='Group 1', slug='group-1')
+ assignment = CircuitGroupAssignment.objects.create(group=group, member=circuit)
+ virtualcircuit_ct = ObjectType.objects.get_for_model(VirtualCircuit)
+
+ form = CircuitGroupAssignmentForm(
+ # Tampered: claim a virtual-circuit member, with nothing selected.
+ data={'group': group.pk, 'member_type': virtualcircuit_ct.pk, 'member': ''},
+ instance=assignment,
+ )
+ restrict_form_fields(form, self.user)
+
+ self.assertTrue(form.fields['member'].disabled)
+ self.assertTrue(form.fields['member_type'].disabled)
+ self.assertTrue(form.is_valid(), form.errors)
+ assignment = form.save()
+ self.assertEqual(assignment.member, circuit)
+ self.assertEqual(assignment.member_type.model_class(), Circuit)
+
+ def test_eventrule_hidden_webhook_is_preserved(self):
+ """An EventRule whose webhook is hidden keeps its action object on save."""
+ webhook = Webhook.objects.create(name='Webhook 1', payload_url='http://example.com/')
+ rule = EventRule.objects.create(
+ name='Rule 1',
+ action_type=EventRuleActionChoices.WEBHOOK,
+ action_object=webhook,
+ event_types=['object_created'],
+ )
+ site_ot = ObjectType.objects.get_for_model(Site)
+ rule.object_types.set([site_ot])
+
+ form = EventRuleForm(
+ data={
+ 'name': 'Rule 1',
+ 'object_types': [site_ot.pk],
+ 'event_types': ['object_created'],
+ 'action_type': EventRuleActionChoices.WEBHOOK,
+ },
+ instance=rule,
+ )
+ restrict_form_fields(form, self.user)
+
+ self.assertTrue(form.fields['action_choice'].disabled)
+ self.assertTrue(form.fields['action_type'].disabled)
+ self.assertTrue(form.is_valid(), form.errors)
+ form.save()
+ rule = EventRule.objects.get(pk=rule.pk)
+ self.assertEqual(rule.action_object, webhook)
+
+ def test_eventrule_action_type_tamper_is_blocked(self):
+ """Switching action_type while the action object is hidden cannot remap the action."""
+ webhook = Webhook.objects.create(name='Webhook 1', payload_url='http://example.com/')
+ rule = EventRule.objects.create(
+ name='Rule 1',
+ action_type=EventRuleActionChoices.WEBHOOK,
+ action_object=webhook,
+ event_types=['object_created'],
+ )
+ site_ot = ObjectType.objects.get_for_model(Site)
+ rule.object_types.set([site_ot])
+ group = NotificationGroup.objects.create(name='Group 1')
+
+ form = EventRuleForm(
+ # Tampered: claim a notification action targeting another object.
+ data={
+ 'name': 'Rule 1',
+ 'object_types': [site_ot.pk],
+ 'event_types': ['object_created'],
+ 'action_type': EventRuleActionChoices.NOTIFICATION,
+ 'action_choice': group.pk,
+ },
+ instance=rule,
+ )
+ restrict_form_fields(form, self.user)
+
+ self.assertTrue(form.fields['action_choice'].disabled)
+ self.assertTrue(form.fields['action_type'].disabled)
+ self.assertTrue(form.is_valid(), form.errors)
+ form.save()
+ rule = EventRule.objects.get(pk=rule.pk)
+ self.assertEqual(rule.action_object, webhook)
+ self.assertEqual(rule.action_object_type.model_class(), Webhook)
+ self.assertEqual(rule.action_object_id, webhook.pk)
diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py
index b40ffa77c99..491a2cd85c5 100644
--- a/netbox/utilities/forms/utils.py
+++ b/netbox/utilities/forms/utils.py
@@ -219,10 +219,30 @@ def restrict_form_fields(form, user, action='view'):
"""
Restrict all form fields which reference a RestrictedQuerySet. This ensures that users see only permitted objects
as available choices.
+
+ Forms may optionally expose already-assigned values which were removed by permission filtering as read-only. This
+ is handled by the form: only values already assigned to the current instance are added back (never the rest of the
+ original queryset), they are rendered read-only, and submitted/tampered values for them are ignored.
"""
- for field in form.fields.values():
+ restricted_fields = {}
+
+ for name, field in form.fields.items():
if hasattr(field, 'queryset') and issubclass(field.queryset.__class__, RestrictedQuerySet):
- field.queryset = field.queryset.restrict(user, action)
+ original_queryset = field.queryset
+ restricted_queryset = original_queryset.restrict(user, action)
+ field.queryset = restricted_queryset
+ # restrict() returns the same queryset object only when no restriction applies (e.g. superusers). A
+ # non-superuser with unconstrained permissions gets an equivalent but new queryset; the per-value checks
+ # in prepare_restricted_queryset_fields() ensure nothing is locked when no current value is hidden.
+ if restricted_queryset is not original_queryset:
+ restricted_fields[name] = original_queryset
+
+ if (
+ restricted_fields and
+ getattr(getattr(form, 'instance', None), 'pk', None) and
+ hasattr(form, 'prepare_restricted_queryset_fields')
+ ):
+ form.prepare_restricted_queryset_fields(restricted_fields, user=user, action=action)
def parse_csv(reader):
diff --git a/netbox/utilities/templates/widgets/select_option.html b/netbox/utilities/templates/widgets/select_option.html
index f6a52e52546..de74f176880 100644
--- a/netbox/utilities/templates/widgets/select_option.html
+++ b/netbox/utilities/templates/widgets/select_option.html
@@ -1 +1,5 @@
+{% comment %}
+A RestrictedChoiceLabel (netbox.forms.model_forms) sets label.disabled=True to render its single as
+disabled, marking a restricted current value read-only. Preservation is enforced server-side, not by this attribute.
+{% endcomment %}
{{ widget.label.label|default:widget.label }}
diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py
index 82bc79e4e81..29a7814a2d9 100644
--- a/netbox/vpn/forms/model_forms.py
+++ b/netbox/vpn/forms/model_forms.py
@@ -253,6 +253,13 @@ class TunnelTerminationForm(NetBoxModelForm):
FieldSet('tunnel', 'role', 'type', 'parent', 'termination', 'outside_ip', 'tags'),
)
+ # type is not locked: clean() writes instance.termination from the locked selector and never branches on it.
+ restricted_related_selectors = {
+ 'termination': {'path': 'termination'},
+ # parent is an auxiliary selector for the device or VM owning the termination.
+ 'parent': {'path': 'termination.parent_object'},
+ }
+
class Meta:
model = TunnelTermination
fields = [
@@ -455,6 +462,13 @@ class L2VPNTerminationForm(NetBoxModelForm):
),
)
+ restricted_related_selectors = {
+ # The selectors are stored as the assigned_object GenericForeignKey; the model picks the matching one.
+ 'interface': {'path': 'assigned_object', 'model': Interface},
+ 'vminterface': {'path': 'assigned_object', 'model': VMInterface},
+ 'vlan': {'path': 'assigned_object', 'model': VLAN},
+ }
+
class Meta:
model = L2VPNTermination
fields = ('l2vpn', 'tags')