From c9e974edfeb7c3db0a39001421ba7f7d63fe7f06 Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Thu, 4 Jun 2026 20:54:30 +0200 Subject: [PATCH] feat(forms): Preserve restricted related values on edit forms Add read-only preservation of related object values hidden by object permissions. Single-value fields are disabled entirely; multi-value fields remain editable for visible values while restricted current values appear as disabled options and are merged back on save. Fixes data loss when users edit objects with related values they cannot view, such as tags or tenants constrained by permissions. --- docs/administration/permissions.md | 4 + netbox/circuits/forms/model_forms.py | 8 + netbox/dcim/forms/mixins.py | 4 + netbox/dcim/forms/model_forms.py | 6 + netbox/extras/forms/model_forms.py | 5 + netbox/ipam/forms/model_forms.py | 15 + netbox/netbox/forms/model_forms.py | 317 +++++++ .../tests/test_restricted_form_fields.py | 866 ++++++++++++++++++ netbox/utilities/forms/utils.py | 24 +- .../templates/widgets/select_option.html | 4 + netbox/vpn/forms/model_forms.py | 14 + 11 files changed, 1265 insertions(+), 2 deletions(-) create mode 100644 netbox/netbox/tests/test_restricted_form_fields.py 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