diff --git a/docs/administration/permissions.md b/docs/administration/permissions.md
index a73eddcd8..0b7b88bcd 100644
--- a/docs/administration/permissions.md
+++ b/docs/administration/permissions.md
@@ -20,7 +20,9 @@ There are four core actions that can be permitted for each type of object within
* **Change** - Modify an existing object
* **Delete** - Delete an existing object
-In addition to these, permissions can also grant custom actions that may be required by a specific model or plugin. For example, the `run` permission for scripts allows a user to execute custom scripts. These can be specified when granting a permission in the "additional actions" field.
+In addition to these, permissions can also grant custom actions that may be required by a specific model or plugin. For example, the `sync` action for data sources allows a user to synchronize data from a remote source, and the `render_config` action for devices and virtual machines allows rendering configuration templates.
+
+Some models have registered actions that appear as checkboxes in the "Actions" section when creating or editing a permission. These are shown in a flat list alongside the built-in CRUD actions. Additional actions (such as those not yet registered by a plugin, or for backwards compatibility) can be entered manually in the "Additional actions" field.
!!! note
Internally, all actions granted by a permission (both built-in and custom) are stored as strings in an array field named `actions`.
diff --git a/docs/plugins/development/permissions.md b/docs/plugins/development/permissions.md
new file mode 100644
index 000000000..62a1ede1b
--- /dev/null
+++ b/docs/plugins/development/permissions.md
@@ -0,0 +1,24 @@
+# Custom Model Actions
+
+Plugins can register custom permission actions for their models. These actions appear as checkboxes in the ObjectPermission form, making it easy for administrators to grant or restrict access to plugin-specific functionality without manually entering action names.
+
+For example, a plugin might define a "sync" action for a model that syncs data from an external source, or a "bypass" action that allows users to bypass certain restrictions.
+
+## Registering Model Actions
+
+The preferred way to register custom actions is via Django's `Meta.permissions` on the model class. NetBox will automatically register these as model actions when the app is loaded:
+
+```python
+from netbox.models import NetBoxModel
+
+class MyModel(NetBoxModel):
+ # ...
+
+ class Meta:
+ permissions = [
+ ('sync', 'Synchronize data from external source'),
+ ('export', 'Export data to external system'),
+ ]
+```
+
+Once registered, these actions appear as checkboxes in a flat list when creating or editing an ObjectPermission.
diff --git a/mkdocs.yml b/mkdocs.yml
index 37ec0947f..c4f288ad7 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -152,6 +152,7 @@ nav:
- Filters & Filter Sets: 'plugins/development/filtersets.md'
- Search: 'plugins/development/search.md'
- Event Types: 'plugins/development/event-types.md'
+ - Permissions: 'plugins/development/permissions.md'
- Data Backends: 'plugins/development/data-backends.md'
- Webhooks: 'plugins/development/webhooks.md'
- User Interface: 'plugins/development/user-interface.md'
diff --git a/netbox/core/migrations/0023_datasource_sync_permission.py b/netbox/core/migrations/0023_datasource_sync_permission.py
new file mode 100644
index 000000000..8e0ce214a
--- /dev/null
+++ b/netbox/core/migrations/0023_datasource_sync_permission.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.2.11 on 2026-03-31 21:19
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0022_default_ordering_indexes'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='datasource',
+ options={'ordering': ('name',), 'permissions': [('sync', 'Synchronize data from remote source')]},
+ ),
+ ]
diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py
index 5555c655e..845317ee7 100644
--- a/netbox/core/models/data.py
+++ b/netbox/core/models/data.py
@@ -87,6 +87,9 @@ class DataSource(JobsMixin, PrimaryModel):
ordering = ('name',)
verbose_name = _('data source')
verbose_name_plural = _('data sources')
+ permissions = [
+ ('sync', 'Synchronize data from remote source'),
+ ]
def __str__(self):
return f'{self.name}'
diff --git a/netbox/dcim/migrations/0232_device_render_config_permission.py b/netbox/dcim/migrations/0232_device_render_config_permission.py
new file mode 100644
index 000000000..862e5ec21
--- /dev/null
+++ b/netbox/dcim/migrations/0232_device_render_config_permission.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.2.11 on 2026-03-31 21:19
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0231_default_ordering_indexes'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='device',
+ options={'ordering': ('name', 'pk'), 'permissions': [('render_config', 'Render configuration')]},
+ ),
+ ]
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index 53cde6471..88d094b97 100644
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -771,6 +771,9 @@ class Device(
)
verbose_name = _('device')
verbose_name_plural = _('devices')
+ permissions = [
+ ('render_config', 'Render configuration'),
+ ]
def __str__(self):
if self.label and self.asset_tag:
diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py
index 920c8e0c1..e4dbf9112 100644
--- a/netbox/netbox/models/features.py
+++ b/netbox/netbox/models/features.py
@@ -25,6 +25,7 @@ from netbox.registry import registry
from netbox.signals import post_clean
from netbox.utils import register_model_feature
from utilities.json import CustomFieldJSONEncoder
+from utilities.permissions import ModelAction, register_model_actions
from utilities.serialization import serialize_object
__all__ = (
@@ -752,3 +753,12 @@ def register_models(*models):
register_model_view(model, 'sync', kwargs={'model': model})(
'netbox.views.generic.ObjectSyncDataView'
)
+
+ # Auto-register custom permission actions declared in Meta.permissions
+ if meta_permissions := getattr(model._meta, 'permissions', None):
+ actions = [
+ ModelAction(codename, help_text=_(name))
+ for codename, name in meta_permissions
+ ]
+ if actions:
+ register_model_actions(model, actions)
diff --git a/netbox/netbox/registry.py b/netbox/netbox/registry.py
index 829d0de93..d7e5988ce 100644
--- a/netbox/netbox/registry.py
+++ b/netbox/netbox/registry.py
@@ -28,6 +28,7 @@ registry = Registry({
'denormalized_fields': collections.defaultdict(list),
'event_types': dict(),
'filtersets': dict(),
+ 'model_actions': collections.defaultdict(set),
'model_features': dict(),
'models': collections.defaultdict(set),
'plugins': dict(),
diff --git a/netbox/templates/users/panels/actions.html b/netbox/templates/users/panels/actions.html
new file mode 100644
index 000000000..d6ce07856
--- /dev/null
+++ b/netbox/templates/users/panels/actions.html
@@ -0,0 +1,32 @@
+{% extends "ui/panels/_base.html" %}
+{% load helpers i18n %}
+
+{% block panel_content %}
+
+ {% for label, enabled in crud_actions %}
+
+ | {{ label }} |
+ {% checkmark enabled %} |
+
+ {% endfor %}
+ {% for action in registered_actions %}
+
+ | {{ action.help_text|default:action.name }} |
+
+
+ {% checkmark action.enabled %}
+ {% if action.models %}
+ {{ action.models|join:", "|title }}
+ {% endif %}
+
+ |
+
+ {% endfor %}
+ {% if additional_actions %}
+
+ | {% trans "Additional actions" %} |
+ {{ additional_actions|join:", " }} |
+
+ {% endif %}
+
+{% endblock panel_content %}
diff --git a/netbox/users/constants.py b/netbox/users/constants.py
index 18add80cb..744ec0f92 100644
--- a/netbox/users/constants.py
+++ b/netbox/users/constants.py
@@ -10,6 +10,11 @@ OBJECTPERMISSION_OBJECT_TYPES = (
CONSTRAINT_TOKEN_USER = '$user'
+# Django's four default model permissions. These receive special handling
+# (dedicated checkboxes, model properties) and should not be registered
+# as custom model actions.
+RESERVED_ACTIONS = ('view', 'add', 'change', 'delete')
+
# API tokens
TOKEN_PREFIX = 'nbt_' # Used for v2 tokens only
TOKEN_KEY_LENGTH = 12
diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py
index 997142aa2..808bac6cb 100644
--- a/netbox/users/forms/model_forms.py
+++ b/netbox/users/forms/model_forms.py
@@ -14,6 +14,7 @@ from ipam.formfields import IPNetworkFormField
from ipam.validators import prefix_validator
from netbox.config import get_config
from netbox.preferences import PREFERENCES
+from netbox.registry import registry
from users.choices import TokenVersionChoices
from users.constants import *
from users.models import *
@@ -346,7 +347,7 @@ class ObjectPermissionForm(forms.ModelForm):
label=_('Additional actions'),
base_field=forms.CharField(),
required=False,
- help_text=_('Actions granted in addition to those listed above')
+ help_text=_('Additional actions for models which have not yet registered their own actions')
)
users = DynamicModelMultipleChoiceField(
label=_('Users'),
@@ -370,8 +371,11 @@ class ObjectPermissionForm(forms.ModelForm):
fieldsets = (
FieldSet('name', 'description', 'enabled'),
- FieldSet('can_view', 'can_add', 'can_change', 'can_delete', 'actions', name=_('Actions')),
FieldSet('object_types', name=_('Objects')),
+ FieldSet(
+ 'can_view', 'can_add', 'can_change', 'can_delete', 'actions',
+ name=_('Actions')
+ ),
FieldSet('groups', 'users', name=_('Assignment')),
FieldSet('constraints', name=_('Constraints')),
)
@@ -385,6 +389,39 @@ class ObjectPermissionForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
+ # Build dynamic BooleanFields for registered actions (deduplicated, sorted by name)
+ seen = {}
+ for model_actions in registry['model_actions'].values():
+ for action in model_actions:
+ if action.name not in seen:
+ seen[action.name] = action
+ registered_action_names = sorted(seen)
+
+ action_field_names = []
+ for action_name in registered_action_names:
+ field_name = f'action_{action_name}'
+ self.fields[field_name] = forms.BooleanField(
+ required=False,
+ label=action_name,
+ help_text=seen[action_name].help_text,
+ )
+ action_field_names.append(field_name)
+
+ # Rebuild the Actions fieldset to include dynamic fields
+ if action_field_names:
+ self.fieldsets = (
+ FieldSet('name', 'description', 'enabled'),
+ FieldSet('object_types', name=_('Objects')),
+ FieldSet(
+ 'can_view', 'can_add', 'can_change', 'can_delete',
+ *action_field_names,
+ 'actions',
+ name=_('Actions')
+ ),
+ FieldSet('groups', 'users', name=_('Assignment')),
+ FieldSet('constraints', name=_('Constraints')),
+ )
+
# Make the actions field optional since the form uses it only for non-CRUD actions
self.fields['actions'].required = False
@@ -394,11 +431,23 @@ class ObjectPermissionForm(forms.ModelForm):
self.fields['groups'].initial = self.instance.groups.values_list('id', flat=True)
self.fields['users'].initial = self.instance.users.values_list('id', flat=True)
- # Check the appropriate checkboxes when editing an existing ObjectPermission
- for action in ['view', 'add', 'change', 'delete']:
- if action in self.instance.actions:
+ # Work with a copy to avoid mutating the instance
+ remaining_actions = list(self.instance.actions)
+
+ # Check the appropriate CRUD checkboxes
+ for action in RESERVED_ACTIONS:
+ if action in remaining_actions:
self.fields[f'can_{action}'].initial = True
- self.instance.actions.remove(action)
+ remaining_actions.remove(action)
+
+ # Pre-select registered action checkboxes
+ for action_name in registered_action_names:
+ if action_name in remaining_actions:
+ self.fields[f'action_{action_name}'].initial = True
+ remaining_actions.remove(action_name)
+
+ # Remaining actions go to the additional actions field
+ self.initial['actions'] = remaining_actions
# Populate initial data for a new ObjectPermission
elif self.initial:
@@ -408,10 +457,15 @@ class ObjectPermissionForm(forms.ModelForm):
if isinstance(self.initial['actions'], str):
self.initial['actions'] = [self.initial['actions']]
if cloned_actions := self.initial['actions']:
- for action in ['view', 'add', 'change', 'delete']:
+ for action in RESERVED_ACTIONS:
if action in cloned_actions:
self.fields[f'can_{action}'].initial = True
self.initial['actions'].remove(action)
+ # Pre-select registered action checkboxes from cloned data
+ for action_name in registered_action_names:
+ if action_name in cloned_actions:
+ self.fields[f'action_{action_name}'].initial = True
+ self.initial['actions'].remove(action_name)
# Convert data delivered via initial data to JSON data
if 'constraints' in self.initial:
if type(self.initial['constraints']) is str:
@@ -420,15 +474,27 @@ class ObjectPermissionForm(forms.ModelForm):
def clean(self):
super().clean()
- object_types = self.cleaned_data.get('object_types')
+ object_types = self.cleaned_data.get('object_types', [])
constraints = self.cleaned_data.get('constraints')
- # Append any of the selected CRUD checkboxes to the actions list
- if not self.cleaned_data.get('actions'):
- self.cleaned_data['actions'] = list()
- for action in ['view', 'add', 'change', 'delete']:
- if self.cleaned_data[f'can_{action}'] and action not in self.cleaned_data['actions']:
- self.cleaned_data['actions'].append(action)
+ # Merge all actions: registered action checkboxes, CRUD checkboxes, and additional
+ final_actions = []
+ for key, value in self.cleaned_data.items():
+ if key.startswith('action_') and value:
+ action_name = key[7:]
+ if action_name not in final_actions:
+ final_actions.append(action_name)
+
+ for action in RESERVED_ACTIONS:
+ if self.cleaned_data.get(f'can_{action}') and action not in final_actions:
+ final_actions.append(action)
+
+ if additional_actions := self.cleaned_data.get('actions'):
+ for action in additional_actions:
+ if action not in final_actions:
+ final_actions.append(action)
+
+ self.cleaned_data['actions'] = final_actions
# At least one action must be specified
if not self.cleaned_data['actions']:
diff --git a/netbox/users/models/permissions.py b/netbox/users/models/permissions.py
index 3bd373ce3..d8a5eeb96 100644
--- a/netbox/users/models/permissions.py
+++ b/netbox/users/models/permissions.py
@@ -1,9 +1,12 @@
+from django.apps import apps
from django.contrib.postgres.fields import ArrayField
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from netbox.models.features import CloningMixin
+from netbox.registry import registry
+from users.constants import RESERVED_ACTIONS
from utilities.querysets import RestrictedQuerySet
__all__ = (
@@ -85,5 +88,54 @@ class ObjectPermission(CloningMixin, models.Model):
return [self.constraints]
return self.constraints
+ def get_registered_actions(self):
+ """
+ Return a list of dicts for all registered actions:
+ name: The action identifier
+ help_text: Human-friendly description (first registration wins)
+ enabled: Whether this action is enabled on this permission
+ models: Sorted list of human-friendly model verbose names
+ """
+ enabled_actions = set(self.actions) - set(RESERVED_ACTIONS)
+
+ action_info = {}
+ action_models = {}
+ for model_key, model_actions in registry['model_actions'].items():
+ app_label, model_name = model_key.split('.')
+ try:
+ verbose_name = str(apps.get_model(app_label, model_name)._meta.verbose_name)
+ except LookupError:
+ verbose_name = model_key
+ for action in model_actions:
+ # First registration's help_text wins for shared action names
+ if action.name not in action_info:
+ action_info[action.name] = action
+ action_models.setdefault(action.name, []).append(verbose_name)
+
+ return [
+ {
+ 'name': name,
+ 'help_text': action_info[name].help_text,
+ 'enabled': name in enabled_actions,
+ 'models': sorted(action_models[name]),
+ }
+ for name in sorted(action_models)
+ ]
+
+ def get_additional_actions(self):
+ """
+ Return a sorted list of actions that are neither CRUD nor registered.
+ These are manually-entered actions from the "Additional actions" field.
+ """
+ registered_names = set()
+ for model_actions in registry['model_actions'].values():
+ for action in model_actions:
+ registered_names.add(action.name)
+
+ return sorted(
+ a for a in self.actions
+ if a not in RESERVED_ACTIONS and a not in registered_names
+ )
+
def get_absolute_url(self):
return reverse('users:objectpermission', args=[self.pk])
diff --git a/netbox/users/ui/panels.py b/netbox/users/ui/panels.py
index 9ee27f4a8..35a40b88b 100644
--- a/netbox/users/ui/panels.py
+++ b/netbox/users/ui/panels.py
@@ -45,13 +45,26 @@ class ObjectPermissionPanel(panels.ObjectAttributesPanel):
enabled = attrs.BooleanAttr('enabled')
-class ObjectPermissionActionsPanel(panels.ObjectAttributesPanel):
+class ObjectPermissionActionsPanel(panels.ObjectPanel):
+ template_name = 'users/panels/actions.html'
title = _('Actions')
- can_view = attrs.BooleanAttr('can_view', label=_('View'))
- can_add = attrs.BooleanAttr('can_add', label=_('Add'))
- can_change = attrs.BooleanAttr('can_change', label=_('Change'))
- can_delete = attrs.BooleanAttr('can_delete', label=_('Delete'))
+ def get_context(self, context):
+ obj = context['object']
+
+ crud_actions = [
+ (_('View'), 'view' in obj.actions),
+ (_('Add'), 'add' in obj.actions),
+ (_('Change'), 'change' in obj.actions),
+ (_('Delete'), 'delete' in obj.actions),
+ ]
+
+ return {
+ **super().get_context(context),
+ 'crud_actions': crud_actions,
+ 'registered_actions': obj.get_registered_actions(),
+ 'additional_actions': obj.get_additional_actions(),
+ }
class OwnerPanel(panels.ObjectAttributesPanel):
diff --git a/netbox/utilities/forms/widgets/select.py b/netbox/utilities/forms/widgets/select.py
index 4e0c06211..7297f483f 100644
--- a/netbox/utilities/forms/widgets/select.py
+++ b/netbox/utilities/forms/widgets/select.py
@@ -150,14 +150,16 @@ class SplitMultiSelectWidget(forms.MultiWidget):
be enabled only if the order of the selected choices is significant.
"""
template_name = 'widgets/splitmultiselect.html'
+ available_widget_class = AvailableOptions
+ selected_widget_class = SelectedOptions
def __init__(self, choices, attrs=None, ordering=False):
widgets = [
- AvailableOptions(
+ self.available_widget_class(
attrs={'size': 8},
choices=choices
),
- SelectedOptions(
+ self.selected_widget_class(
attrs={'size': 8, 'class': 'select-all'},
choices=choices
),
diff --git a/netbox/utilities/permissions.py b/netbox/utilities/permissions.py
index 81a1ff70b..0cee1db9d 100644
--- a/netbox/utilities/permissions.py
+++ b/netbox/utilities/permissions.py
@@ -1,11 +1,15 @@
+from dataclasses import dataclass
+
from django.apps import apps
from django.conf import settings
-from django.db.models import Q
+from django.db.models import Model, Q
from django.utils.translation import gettext_lazy as _
-from users.constants import CONSTRAINT_TOKEN_USER
+from netbox.registry import registry
+from users.constants import CONSTRAINT_TOKEN_USER, RESERVED_ACTIONS
__all__ = (
+ 'ModelAction',
'get_permission_for_model',
'permission_is_exempt',
'qs_filter_from_constraints',
@@ -14,6 +18,49 @@ __all__ = (
)
+@dataclass
+class ModelAction:
+ """
+ Represents a custom permission action for a model.
+
+ Attributes:
+ name: The action identifier (e.g. 'sync', 'render_config')
+ help_text: Optional description displayed in the ObjectPermission form
+ """
+ name: str
+ help_text: str = ''
+
+ def __post_init__(self):
+ if not self.name:
+ raise ValueError("Action name must not be empty.")
+ if self.name in RESERVED_ACTIONS:
+ raise ValueError(f"'{self.name}' is a reserved action and cannot be registered.")
+
+ def __hash__(self):
+ return hash(self.name)
+
+ def __eq__(self, other):
+ if isinstance(other, ModelAction):
+ return self.name == other.name
+ return self.name == other
+
+
+def register_model_actions(model: type[Model], actions: list[ModelAction | str]):
+ """
+ Register custom permission actions for a model. These actions will appear as
+ checkboxes in the ObjectPermission form when the model is selected.
+
+ Args:
+ model: The model class to register actions for
+ actions: A list of ModelAction instances or action name strings
+ """
+ label = f'{model._meta.app_label}.{model._meta.model_name}'
+ for action in actions:
+ if isinstance(action, str):
+ action = ModelAction(name=action)
+ registry['model_actions'][label].add(action)
+
+
def get_permission_for_model(model, action):
"""
Resolve the named permission for a given model (or instance) and action (e.g. view or add).
diff --git a/netbox/utilities/tests/test_permissions.py b/netbox/utilities/tests/test_permissions.py
new file mode 100644
index 000000000..dd4dd7cad
--- /dev/null
+++ b/netbox/utilities/tests/test_permissions.py
@@ -0,0 +1,199 @@
+from django.test import TestCase
+
+from core.models import ObjectType
+from dcim.models import Device, Site
+from netbox.registry import registry
+from users.forms.model_forms import ObjectPermissionForm
+from users.models import ObjectPermission
+from utilities.permissions import ModelAction, register_model_actions
+from virtualization.models import VirtualMachine
+
+
+class ModelActionTest(TestCase):
+
+ def test_hash(self):
+ action1 = ModelAction(name='sync')
+ action2 = ModelAction(name='sync', help_text='Different help')
+ self.assertEqual(hash(action1), hash(action2))
+
+ def test_equality_with_model_action(self):
+ action1 = ModelAction(name='sync')
+ action2 = ModelAction(name='sync', help_text='Different help')
+ action3 = ModelAction(name='merge')
+ self.assertEqual(action1, action2)
+ self.assertNotEqual(action1, action3)
+
+ def test_equality_with_string(self):
+ action = ModelAction(name='sync')
+ self.assertEqual(action, 'sync')
+ self.assertNotEqual(action, 'merge')
+
+ def test_usable_in_set(self):
+ action1 = ModelAction(name='sync')
+ action2 = ModelAction(name='sync', help_text='Different')
+ action3 = ModelAction(name='merge')
+ actions = {action1, action2, action3}
+ self.assertEqual(len(actions), 2)
+
+
+class RegisterModelActionsTest(TestCase):
+
+ def setUp(self):
+ self._original_actions = dict(registry['model_actions'])
+ registry['model_actions'].clear()
+
+ def tearDown(self):
+ registry['model_actions'].clear()
+ registry['model_actions'].update(self._original_actions)
+
+ def test_register_model_action_objects(self):
+ register_model_actions(Site, [
+ ModelAction('test_action', help_text='Test help'),
+ ])
+ actions = registry['model_actions']['dcim.site']
+ self.assertEqual(len(actions), 1)
+ action = next(iter(actions))
+ self.assertEqual(action.name, 'test_action')
+ self.assertEqual(action.help_text, 'Test help')
+
+ def test_register_string_actions(self):
+ register_model_actions(Site, ['action1', 'action2'])
+ actions = registry['model_actions']['dcim.site']
+ self.assertEqual(len(actions), 2)
+ action_names = {a.name for a in actions}
+ self.assertEqual(action_names, {'action1', 'action2'})
+ self.assertTrue(all(isinstance(a, ModelAction) for a in actions))
+
+ def test_register_mixed_actions(self):
+ register_model_actions(Site, [
+ ModelAction('with_help', help_text='Has help'),
+ 'without_help',
+ ])
+ actions = registry['model_actions']['dcim.site']
+ self.assertEqual(len(actions), 2)
+ actions_by_name = {a.name: a for a in actions}
+ self.assertEqual(actions_by_name['with_help'].help_text, 'Has help')
+ self.assertEqual(actions_by_name['without_help'].help_text, '')
+
+ def test_multiple_registrations_append(self):
+ register_model_actions(Site, [ModelAction('first')])
+ register_model_actions(Site, [ModelAction('second')])
+ actions = registry['model_actions']['dcim.site']
+ self.assertEqual(len(actions), 2)
+ action_names = {a.name for a in actions}
+ self.assertEqual(action_names, {'first', 'second'})
+
+ def test_duplicate_registration_ignored(self):
+ register_model_actions(Site, [ModelAction('sync')])
+ register_model_actions(Site, [ModelAction('sync', help_text='Different help')])
+ actions = registry['model_actions']['dcim.site']
+ self.assertEqual(len(actions), 1)
+
+ def test_reserved_action_rejected(self):
+ for action_name in ('view', 'add', 'change', 'delete'):
+ with self.assertRaises(ValueError):
+ register_model_actions(Site, [ModelAction(action_name)])
+
+ def test_empty_action_name_rejected(self):
+ with self.assertRaises(ValueError):
+ register_model_actions(Site, [ModelAction('')])
+
+ def test_no_duplicate_action_fields(self):
+ register_model_actions(Device, [ModelAction('render_config')])
+ register_model_actions(VirtualMachine, [ModelAction('render_config')])
+
+ form = ObjectPermissionForm()
+ action_fields = [k for k in form.fields if k.startswith('action_')]
+ self.assertEqual(action_fields.count('action_render_config'), 1)
+
+
+class ObjectPermissionFormTest(TestCase):
+
+ def setUp(self):
+ self._original_actions = dict(registry['model_actions'])
+ registry['model_actions'].clear()
+
+ def tearDown(self):
+ registry['model_actions'].clear()
+ registry['model_actions'].update(self._original_actions)
+
+ def test_shared_action_preselection(self):
+ register_model_actions(Device, [ModelAction('render_config')])
+ register_model_actions(VirtualMachine, [ModelAction('render_config')])
+
+ device_ct = ObjectType.objects.get_for_model(Device)
+ vm_ct = ObjectType.objects.get_for_model(VirtualMachine)
+
+ permission = ObjectPermission.objects.create(
+ name='Test Permission',
+ actions=['view', 'render_config'],
+ )
+ permission.object_types.set([device_ct, vm_ct])
+
+ form = ObjectPermissionForm(instance=permission)
+
+ self.assertTrue(form.fields['action_render_config'].initial)
+
+ self.assertEqual(form.initial['actions'], [])
+
+ permission.delete()
+
+ def test_clean_accepts_valid_registered_action(self):
+ register_model_actions(Device, [ModelAction('render_config')])
+
+ device_ct = ObjectType.objects.get_for_model(Device)
+ form = ObjectPermissionForm(data={
+ 'name': 'test perm',
+ 'object_types_0': [],
+ 'object_types_1': [device_ct.pk],
+ 'action_render_config': True,
+ 'can_view': True,
+ 'actions': '',
+ })
+ self.assertTrue(form.is_valid(), form.errors)
+ self.assertIn('render_config', form.cleaned_data['actions'])
+
+ def test_get_registered_actions(self):
+ register_model_actions(Device, [ModelAction('render_config')])
+ register_model_actions(VirtualMachine, [ModelAction('render_config')])
+
+ device_ct = ObjectType.objects.get_for_model(Device)
+
+ permission = ObjectPermission.objects.create(
+ name='Test Registered Actions',
+ actions=['view', 'render_config'],
+ )
+ permission.object_types.set([device_ct])
+
+ registered = permission.get_registered_actions()
+ self.assertEqual(len(registered), 1)
+ action = registered[0]
+ self.assertEqual(action['name'], 'render_config')
+ self.assertEqual(action['help_text'], '')
+ self.assertTrue(action['enabled'])
+ self.assertEqual(action['models'], ['device', 'virtual machine'])
+
+ permission.delete()
+
+ def test_form_with_no_registered_actions(self):
+ device_ct = ObjectType.objects.get_for_model(Device)
+ form = ObjectPermissionForm(data={
+ 'name': 'test perm',
+ 'object_types_0': [],
+ 'object_types_1': [device_ct.pk],
+ 'can_view': True,
+ 'actions': '',
+ })
+ self.assertTrue(form.is_valid(), form.errors)
+ self.assertIn('view', form.cleaned_data['actions'])
+ action_fields = [k for k in form.fields if k.startswith('action_')]
+ self.assertEqual(action_fields, [])
+
+ def test_clone_preselects_registered_actions(self):
+ register_model_actions(Device, [ModelAction('render_config')])
+
+ form = ObjectPermissionForm(initial={
+ 'actions': ['view', 'render_config'],
+ })
+ self.assertTrue(form.fields['action_render_config'].initial)
+ self.assertNotIn('render_config', form.initial['actions'])
diff --git a/netbox/virtualization/migrations/0056_virtualmachine_render_config_permission.py b/netbox/virtualization/migrations/0056_virtualmachine_render_config_permission.py
new file mode 100644
index 000000000..70ee520a6
--- /dev/null
+++ b/netbox/virtualization/migrations/0056_virtualmachine_render_config_permission.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.2.11 on 2026-04-01 16:46
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('virtualization', '0055_default_ordering_indexes'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='virtualmachine',
+ options={'ordering': ('name', 'pk'), 'permissions': [('render_config', 'Render configuration')]},
+ ),
+ ]
diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py
index c5137b5a7..b47f231bf 100644
--- a/netbox/virtualization/models/virtualmachines.py
+++ b/netbox/virtualization/models/virtualmachines.py
@@ -282,6 +282,9 @@ class VirtualMachine(
)
verbose_name = _('virtual machine')
verbose_name_plural = _('virtual machines')
+ permissions = [
+ ('render_config', 'Render configuration'),
+ ]
def __str__(self):
return self.name