mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-16 22:19:53 +02:00
* Add ModelAction and register_model_actions() API for custom permission actions * Add ObjectTypeSplitMultiSelectWidget and RegisteredActionsWidget * Integrate registered actions into ObjectPermissionForm * Add JavaScript for registered actions show/hide * Register custom actions for DataSource, Device, and VirtualMachine * Add tests for ModelAction and register_model_actions * Refine registered actions widget UI - Use verbose labels (App | Model) for action group headers - Simplify template layout with h5 headers instead of cards - Consolidate Standard/Custom/Additional Actions into single Actions fieldset * Hide custom actions field when no applicable models selected The entire field row is now hidden when no selected object types have registered custom actions, avoiding an empty "Custom actions" label. * Add documentation for custom model actions - Add plugin development guide for registering custom actions - Update admin permissions docs to mention custom actions UI - Add docstrings to ModelAction and register_model_actions * Add RESERVED_ACTIONS constant and fix dedup in registered actions - Define RESERVED_ACTIONS in users/constants.py for the four built-in permission actions (view, add, change, delete) - Replace hardcoded action lists in ObjectPermissionForm with the constant - Fix duplicate action names in clean() when the same action is registered across multiple models (e.g. render_config for Device and VirtualMachine) - Fix template substring matching bug in objectpermission.html detail view by passing RESERVED_ACTIONS through view context for proper list membership * Fix shared action pre-selection and additional actions leakage on edit * Prevent duplicate action registration in register_model_actions() * Remove stale comment in RegisteredActionsWidget * Rebuild frontend assets after rebase onto feature * Refactor SplitMultiSelectWidget to use class attributes for widget classes * Reject reserved action names in register_model_actions() * Show all registered actions with enable/disable instead of show/hide * Validate action name is not empty and clarify RESERVED_ACTIONS origin * Adapt custom actions panel for declarative layout system Convert the ObjectPermission detail view to use the new panel-based layout from #21568. Add ObjectPermissionCustomActionsPanel that cross-references assigned object types with the model_actions registry to display which models each custom action applies to. Also fix dark-mode visibility of disabled action checkboxes in the permission form by overriding Bootstrap's disabled opacity. * Flatten registered actions UI and declare via Meta.permissions Implement two changes requested in review of #21560: 1. Use Meta.permissions for action declaration - Add Meta.permissions to DataSource, Device, and VirtualMachine - register_models() auto-registers actions from Meta.permissions - Remove explicit register_model_actions() calls from apps.py - Add get_action_model_map() utility to utilities/permissions.py 2. Flatten the ObjectPermission form UI - Show a single deduplicated list of action checkboxes (one per unique action name) instead of grouped-by-model checkboxes - RegisteredActionsWidget uses create_option() to inject model_keys and help_text; JS enables/disables based on selected object types - render_field.html bypasses outer wrapper for registeredactionswidget so widget emits rows with identical DOM structure to CRUD checkboxes - Unchecking a model now also unchecks unsupported action checkboxes Fixes #21357 * Address review feedback on registered actions - Sort model_keys in data-models attribute for deterministic output - Rename registered_actions field label to 'Registered actions' - Target object_types selected list via data-object-types-selected attribute instead of hardcoded DOM ID - Reduce setTimeout delay to 0ms since moveOption() is synchronous * Consolidate ObjectPermission detail view actions panel Merge ObjectPermissionActionsPanel and ObjectPermissionCustomActionsPanel into a single Actions panel that shows CRUD booleans and all registered actions in one table, matching the form's consolidated layout. Also fix data-object-types-selected attribute value (True -> 'true') and update plugin docs to show Meta.permissions as the primary registration approach. * Address additional bot review feedback - clean() collects all validation errors before raising instead of stopping at the first - Fix stale admin docs (still referenced "Custom actions" and "grouped by model") * Update netbox/netbox/registry.py Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com> * Fix model_actions registry to use set operations The registry was changed to defaultdict(set) but the registration code still used list methods. Update .append() to .add() and fix tests to use set-compatible access patterns. * Rename permission migrations for clarity * Move ModelAction validation into __post_init__ * Drop model name from permission descriptions * Simplify ObjectPermission form and remove custom widgets Replace the dynamic UI with standard BooleanField checkboxes for each registered action. No custom widgets, no JavaScript, no template changes. - Remove RegisteredActionsWidget, ObjectTypeSplitMultiSelectWidget, and registeredActions.ts - Use dynamic BooleanFields for registered actions (renders identically to CRUD checkboxes) - Move action-resolution logic from panel to ObjectPermission model - Remove object-type cross-validation from form clean() - Remove unused get_action_model_map utility * Remove register_model_actions from public API Meta.permissions is the documented approach for plugins. The register_model_actions function is now an internal implementation detail. * Sort registered actions and improve test coverage Sort action names alphabetically for stable display order. Add tests for cloning, empty registry, and models_csv output. * Add help_text to registered action checkboxes * Return model_keys as list from get_registered_actions() Move string joining to the template so callers get native list data instead of a pre-formatted CSV string. * Improve detail view: human-friendly descriptions and additional actions Return dicts from get_registered_actions() with help_text and verbose model names. Add get_additional_actions() for manually-entered actions that aren't CRUD or registered. Show both in the Actions panel. * Renumber permission migrations after feature merge Resolve migration conflicts with default_ordering_indexes migrations. Renumber to 0023 (core), 0232 (dcim), 0056 (virtualization) and update dependencies. --------- Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
This commit is contained in:
@@ -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`.
|
||||
|
||||
24
docs/plugins/development/permissions.md
Normal file
24
docs/plugins/development/permissions.md
Normal file
@@ -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.
|
||||
@@ -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'
|
||||
|
||||
17
netbox/core/migrations/0023_datasource_sync_permission.py
Normal file
17
netbox/core/migrations/0023_datasource_sync_permission.py
Normal file
@@ -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')]},
|
||||
),
|
||||
]
|
||||
@@ -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}'
|
||||
|
||||
@@ -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')]},
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
32
netbox/templates/users/panels/actions.html
Normal file
32
netbox/templates/users/panels/actions.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{% extends "ui/panels/_base.html" %}
|
||||
{% load helpers i18n %}
|
||||
|
||||
{% block panel_content %}
|
||||
<table class="table table-hover attr-table">
|
||||
{% for label, enabled in crud_actions %}
|
||||
<tr>
|
||||
<th scope="row">{{ label }}</th>
|
||||
<td>{% checkmark enabled %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% for action in registered_actions %}
|
||||
<tr>
|
||||
<th scope="row">{{ action.help_text|default:action.name }}</th>
|
||||
<td>
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
{% checkmark action.enabled %}
|
||||
{% if action.models %}
|
||||
<small class="text-muted">{{ action.models|join:", "|title }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if additional_actions %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Additional actions" %}</th>
|
||||
<td>{{ additional_actions|join:", " }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
{% endblock panel_content %}
|
||||
@@ -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
|
||||
|
||||
@@ -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']:
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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).
|
||||
|
||||
199
netbox/utilities/tests/test_permissions.py
Normal file
199
netbox/utilities/tests/test_permissions.py
Normal file
@@ -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'])
|
||||
@@ -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')]},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user