mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-10 11:23:58 +02:00
Compare commits
33 Commits
feature-me
...
21357-regi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c69463e4b | ||
|
|
822d0c3748 | ||
|
|
94612fd728 | ||
|
|
1ec9b5b135 | ||
|
|
2e19ee6961 | ||
|
|
fa50e0220c | ||
|
|
181c1abedd | ||
|
|
a57a538b92 | ||
|
|
b5839d5ac4 | ||
|
|
4c291f0463 | ||
|
|
e9be6e4178 | ||
|
|
84c2acb1f9 | ||
|
|
002cf25a2c | ||
|
|
2fb562fe50 | ||
|
|
2db5976184 | ||
|
|
6ac5afc0e9 | ||
|
|
cf6599d9f8 | ||
|
|
2bd8f9d677 | ||
|
|
de41d0d3ae | ||
|
|
667702e0c2 | ||
|
|
e6314e3971 | ||
|
|
80595c0f67 | ||
|
|
3f2734d5b8 | ||
|
|
637ebf642c | ||
|
|
92301949df | ||
|
|
0f5198e1b1 | ||
|
|
7541554d36 | ||
|
|
83888db109 | ||
|
|
02b85765d9 | ||
|
|
b2e0116302 | ||
|
|
8926445ea2 | ||
|
|
5cfdf6ab6a | ||
|
|
2cfecd7052 |
@@ -20,7 +20,9 @@ There are four core actions that can be permitted for each type of object within
|
|||||||
* **Change** - Modify an existing object
|
* **Change** - Modify an existing object
|
||||||
* **Delete** - Delete 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
|
!!! note
|
||||||
Internally, all actions granted by a permission (both built-in and custom) are stored as strings in an array field named `actions`.
|
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'
|
- Filters & Filter Sets: 'plugins/development/filtersets.md'
|
||||||
- Search: 'plugins/development/search.md'
|
- Search: 'plugins/development/search.md'
|
||||||
- Event Types: 'plugins/development/event-types.md'
|
- Event Types: 'plugins/development/event-types.md'
|
||||||
|
- Permissions: 'plugins/development/permissions.md'
|
||||||
- Data Backends: 'plugins/development/data-backends.md'
|
- Data Backends: 'plugins/development/data-backends.md'
|
||||||
- Webhooks: 'plugins/development/webhooks.md'
|
- Webhooks: 'plugins/development/webhooks.md'
|
||||||
- User Interface: 'plugins/development/user-interface.md'
|
- User Interface: 'plugins/development/user-interface.md'
|
||||||
|
|||||||
17
netbox/core/migrations/0022_datasource_sync_permission.py
Normal file
17
netbox/core/migrations/0022_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', '0021_job_queue_name'),
|
||||||
|
]
|
||||||
|
|
||||||
|
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',)
|
ordering = ('name',)
|
||||||
verbose_name = _('data source')
|
verbose_name = _('data source')
|
||||||
verbose_name_plural = _('data sources')
|
verbose_name_plural = _('data sources')
|
||||||
|
permissions = [
|
||||||
|
('sync', 'Synchronize data from remote source'),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'{self.name}'
|
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', '0230_interface_rf_channel_frequency_precision'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='device',
|
||||||
|
options={'ordering': ('name', 'pk'), 'permissions': [('render_config', 'Render configuration')]},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -759,6 +759,9 @@ class Device(
|
|||||||
)
|
)
|
||||||
verbose_name = _('device')
|
verbose_name = _('device')
|
||||||
verbose_name_plural = _('devices')
|
verbose_name_plural = _('devices')
|
||||||
|
permissions = [
|
||||||
|
('render_config', 'Render configuration'),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
if self.label and self.asset_tag:
|
if self.label and self.asset_tag:
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from netbox.registry import registry
|
|||||||
from netbox.signals import post_clean
|
from netbox.signals import post_clean
|
||||||
from netbox.utils import register_model_feature
|
from netbox.utils import register_model_feature
|
||||||
from utilities.json import CustomFieldJSONEncoder
|
from utilities.json import CustomFieldJSONEncoder
|
||||||
|
from utilities.permissions import ModelAction, register_model_actions
|
||||||
from utilities.serialization import serialize_object
|
from utilities.serialization import serialize_object
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@@ -752,3 +753,12 @@ def register_models(*models):
|
|||||||
register_model_view(model, 'sync', kwargs={'model': model})(
|
register_model_view(model, 'sync', kwargs={'model': model})(
|
||||||
'netbox.views.generic.ObjectSyncDataView'
|
'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),
|
'denormalized_fields': collections.defaultdict(list),
|
||||||
'event_types': dict(),
|
'event_types': dict(),
|
||||||
'filtersets': dict(),
|
'filtersets': dict(),
|
||||||
|
'model_actions': collections.defaultdict(set),
|
||||||
'model_features': dict(),
|
'model_features': dict(),
|
||||||
'models': collections.defaultdict(set),
|
'models': collections.defaultdict(set),
|
||||||
'plugins': dict(),
|
'plugins': dict(),
|
||||||
|
|||||||
26
netbox/templates/users/panels/actions.html
Normal file
26
netbox/templates/users/panels/actions.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{% extends "ui/panels/_base.html" %}
|
||||||
|
{% load helpers %}
|
||||||
|
|
||||||
|
{% 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, enabled, models in registered_actions %}
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{{ action }}</th>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
{% checkmark enabled %}
|
||||||
|
{% if models %}
|
||||||
|
<small class="text-muted">{{ models }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% endblock panel_content %}
|
||||||
@@ -10,6 +10,11 @@ OBJECTPERMISSION_OBJECT_TYPES = (
|
|||||||
|
|
||||||
CONSTRAINT_TOKEN_USER = '$user'
|
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
|
# API tokens
|
||||||
TOKEN_PREFIX = 'nbt_' # Used for v2 tokens only
|
TOKEN_PREFIX = 'nbt_' # Used for v2 tokens only
|
||||||
TOKEN_KEY_LENGTH = 12
|
TOKEN_KEY_LENGTH = 12
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from ipam.formfields import IPNetworkFormField
|
|||||||
from ipam.validators import prefix_validator
|
from ipam.validators import prefix_validator
|
||||||
from netbox.config import get_config
|
from netbox.config import get_config
|
||||||
from netbox.preferences import PREFERENCES
|
from netbox.preferences import PREFERENCES
|
||||||
|
from netbox.registry import registry
|
||||||
from users.choices import TokenVersionChoices
|
from users.choices import TokenVersionChoices
|
||||||
from users.constants import *
|
from users.constants import *
|
||||||
from users.models import *
|
from users.models import *
|
||||||
@@ -346,7 +347,7 @@ class ObjectPermissionForm(forms.ModelForm):
|
|||||||
label=_('Additional actions'),
|
label=_('Additional actions'),
|
||||||
base_field=forms.CharField(),
|
base_field=forms.CharField(),
|
||||||
required=False,
|
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(
|
users = DynamicModelMultipleChoiceField(
|
||||||
label=_('Users'),
|
label=_('Users'),
|
||||||
@@ -370,8 +371,11 @@ class ObjectPermissionForm(forms.ModelForm):
|
|||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('name', 'description', 'enabled'),
|
FieldSet('name', 'description', 'enabled'),
|
||||||
FieldSet('can_view', 'can_add', 'can_change', 'can_delete', 'actions', name=_('Actions')),
|
|
||||||
FieldSet('object_types', name=_('Objects')),
|
FieldSet('object_types', name=_('Objects')),
|
||||||
|
FieldSet(
|
||||||
|
'can_view', 'can_add', 'can_change', 'can_delete', 'actions',
|
||||||
|
name=_('Actions')
|
||||||
|
),
|
||||||
FieldSet('groups', 'users', name=_('Assignment')),
|
FieldSet('groups', 'users', name=_('Assignment')),
|
||||||
FieldSet('constraints', name=_('Constraints')),
|
FieldSet('constraints', name=_('Constraints')),
|
||||||
)
|
)
|
||||||
@@ -385,6 +389,39 @@ class ObjectPermissionForm(forms.ModelForm):
|
|||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*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
|
# Make the actions field optional since the form uses it only for non-CRUD actions
|
||||||
self.fields['actions'].required = False
|
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['groups'].initial = self.instance.groups.values_list('id', flat=True)
|
||||||
self.fields['users'].initial = self.instance.users.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
|
# Work with a copy to avoid mutating the instance
|
||||||
for action in ['view', 'add', 'change', 'delete']:
|
remaining_actions = list(self.instance.actions)
|
||||||
if action in 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.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
|
# Populate initial data for a new ObjectPermission
|
||||||
elif self.initial:
|
elif self.initial:
|
||||||
@@ -408,10 +457,15 @@ class ObjectPermissionForm(forms.ModelForm):
|
|||||||
if isinstance(self.initial['actions'], str):
|
if isinstance(self.initial['actions'], str):
|
||||||
self.initial['actions'] = [self.initial['actions']]
|
self.initial['actions'] = [self.initial['actions']]
|
||||||
if cloned_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:
|
if action in cloned_actions:
|
||||||
self.fields[f'can_{action}'].initial = True
|
self.fields[f'can_{action}'].initial = True
|
||||||
self.initial['actions'].remove(action)
|
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
|
# Convert data delivered via initial data to JSON data
|
||||||
if 'constraints' in self.initial:
|
if 'constraints' in self.initial:
|
||||||
if type(self.initial['constraints']) is str:
|
if type(self.initial['constraints']) is str:
|
||||||
@@ -420,15 +474,27 @@ class ObjectPermissionForm(forms.ModelForm):
|
|||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
object_types = self.cleaned_data.get('object_types')
|
object_types = self.cleaned_data.get('object_types', [])
|
||||||
constraints = self.cleaned_data.get('constraints')
|
constraints = self.cleaned_data.get('constraints')
|
||||||
|
|
||||||
# Append any of the selected CRUD checkboxes to the actions list
|
# Merge all actions: registered action checkboxes, CRUD checkboxes, and additional
|
||||||
if not self.cleaned_data.get('actions'):
|
final_actions = []
|
||||||
self.cleaned_data['actions'] = list()
|
for key, value in self.cleaned_data.items():
|
||||||
for action in ['view', 'add', 'change', 'delete']:
|
if key.startswith('action_') and value:
|
||||||
if self.cleaned_data[f'can_{action}'] and action not in self.cleaned_data['actions']:
|
action_name = key[7:]
|
||||||
self.cleaned_data['actions'].append(action)
|
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
|
# At least one action must be specified
|
||||||
if not self.cleaned_data['actions']:
|
if not self.cleaned_data['actions']:
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ from django.urls import reverse
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from netbox.models.features import CloningMixin
|
from netbox.models.features import CloningMixin
|
||||||
|
from netbox.registry import registry
|
||||||
|
from users.constants import RESERVED_ACTIONS
|
||||||
from utilities.querysets import RestrictedQuerySet
|
from utilities.querysets import RestrictedQuerySet
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@@ -82,5 +84,22 @@ class ObjectPermission(CloningMixin, models.Model):
|
|||||||
return [self.constraints]
|
return [self.constraints]
|
||||||
return self.constraints
|
return self.constraints
|
||||||
|
|
||||||
|
def get_registered_actions(self):
|
||||||
|
"""
|
||||||
|
Return a list of (action_name, is_enabled, model_keys_csv) tuples for all
|
||||||
|
registered actions, indicating which are enabled on this permission.
|
||||||
|
"""
|
||||||
|
enabled_actions = set(self.actions) - set(RESERVED_ACTIONS)
|
||||||
|
|
||||||
|
action_models = {}
|
||||||
|
for model_key, model_actions in registry['model_actions'].items():
|
||||||
|
for action in model_actions:
|
||||||
|
action_models.setdefault(action.name, []).append(model_key)
|
||||||
|
|
||||||
|
return [
|
||||||
|
(name, name in enabled_actions, ', '.join(sorted(action_models[name])))
|
||||||
|
for name in sorted(action_models)
|
||||||
|
]
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse('users:objectpermission', args=[self.pk])
|
return reverse('users:objectpermission', args=[self.pk])
|
||||||
|
|||||||
@@ -45,13 +45,25 @@ class ObjectPermissionPanel(panels.ObjectAttributesPanel):
|
|||||||
enabled = attrs.BooleanAttr('enabled')
|
enabled = attrs.BooleanAttr('enabled')
|
||||||
|
|
||||||
|
|
||||||
class ObjectPermissionActionsPanel(panels.ObjectAttributesPanel):
|
class ObjectPermissionActionsPanel(panels.ObjectPanel):
|
||||||
|
template_name = 'users/panels/actions.html'
|
||||||
title = _('Actions')
|
title = _('Actions')
|
||||||
|
|
||||||
can_view = attrs.BooleanAttr('can_view', label=_('View'))
|
def get_context(self, context):
|
||||||
can_add = attrs.BooleanAttr('can_add', label=_('Add'))
|
obj = context['object']
|
||||||
can_change = attrs.BooleanAttr('can_change', label=_('Change'))
|
|
||||||
can_delete = attrs.BooleanAttr('can_delete', label=_('Delete'))
|
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(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class OwnerPanel(panels.ObjectAttributesPanel):
|
class OwnerPanel(panels.ObjectAttributesPanel):
|
||||||
|
|||||||
@@ -150,14 +150,16 @@ class SplitMultiSelectWidget(forms.MultiWidget):
|
|||||||
be enabled only if the order of the selected choices is significant.
|
be enabled only if the order of the selected choices is significant.
|
||||||
"""
|
"""
|
||||||
template_name = 'widgets/splitmultiselect.html'
|
template_name = 'widgets/splitmultiselect.html'
|
||||||
|
available_widget_class = AvailableOptions
|
||||||
|
selected_widget_class = SelectedOptions
|
||||||
|
|
||||||
def __init__(self, choices, attrs=None, ordering=False):
|
def __init__(self, choices, attrs=None, ordering=False):
|
||||||
widgets = [
|
widgets = [
|
||||||
AvailableOptions(
|
self.available_widget_class(
|
||||||
attrs={'size': 8},
|
attrs={'size': 8},
|
||||||
choices=choices
|
choices=choices
|
||||||
),
|
),
|
||||||
SelectedOptions(
|
self.selected_widget_class(
|
||||||
attrs={'size': 8, 'class': 'select-all'},
|
attrs={'size': 8, 'class': 'select-all'},
|
||||||
choices=choices
|
choices=choices
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.conf import settings
|
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 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__ = (
|
__all__ = (
|
||||||
|
'ModelAction',
|
||||||
'get_permission_for_model',
|
'get_permission_for_model',
|
||||||
'permission_is_exempt',
|
'permission_is_exempt',
|
||||||
'qs_filter_from_constraints',
|
'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):
|
def get_permission_for_model(model, action):
|
||||||
"""
|
"""
|
||||||
Resolve the named permission for a given model (or instance) and action (e.g. view or add).
|
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_name, is_enabled, models_csv = registered[0]
|
||||||
|
self.assertEqual(action_name, 'render_config')
|
||||||
|
self.assertTrue(is_enabled)
|
||||||
|
self.assertIn('dcim.device', models_csv)
|
||||||
|
self.assertIn('virtualization.virtualmachine', models_csv)
|
||||||
|
|
||||||
|
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', '0054_virtualmachinetype'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='virtualmachine',
|
||||||
|
options={'ordering': ('name', 'pk'), 'permissions': [('render_config', 'Render configuration')]},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -276,6 +276,9 @@ class VirtualMachine(
|
|||||||
)
|
)
|
||||||
verbose_name = _('virtual machine')
|
verbose_name = _('virtual machine')
|
||||||
verbose_name_plural = _('virtual machines')
|
verbose_name_plural = _('virtual machines')
|
||||||
|
permissions = [
|
||||||
|
('render_config', 'Render configuration'),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|||||||
Reference in New Issue
Block a user