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 %} + + + + + {% endfor %} + {% for action in registered_actions %} + + + + + {% endfor %} + {% if additional_actions %} + + + + + {% endif %} +
{{ label }}{% checkmark enabled %}
{{ action.help_text|default:action.name }} +
+ {% checkmark action.enabled %} + {% if action.models %} + {{ action.models|join:", "|title }} + {% endif %} +
+
{% trans "Additional actions" %}{{ additional_actions|join:", " }}
+{% 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