mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-05 00:47:17 +02:00
Compare commits
25 Commits
21455-sql-
...
21357-regi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
* **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`.
|
||||
|
||||
51
docs/plugins/development/permissions.md
Normal file
51
docs/plugins/development/permissions.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# 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'),
|
||||
]
|
||||
```
|
||||
|
||||
For dynamic registration (e.g. when actions depend on runtime state), you can call `register_model_actions()` directly, typically in your plugin's `ready()` method:
|
||||
|
||||
```python
|
||||
# __init__.py
|
||||
from netbox.plugins import PluginConfig
|
||||
|
||||
class MyPluginConfig(PluginConfig):
|
||||
name = 'my_plugin'
|
||||
# ...
|
||||
|
||||
def ready(self):
|
||||
super().ready()
|
||||
from utilities.permissions import ModelAction, register_model_actions
|
||||
from .models import MyModel
|
||||
|
||||
register_model_actions(MyModel, [
|
||||
ModelAction('sync', help_text='Synchronize data from external source'),
|
||||
ModelAction('export', help_text='Export data to external system'),
|
||||
])
|
||||
|
||||
config = MyPluginConfig
|
||||
```
|
||||
|
||||
Once registered, these actions appear as checkboxes in a flat list when creating or editing an ObjectPermission.
|
||||
|
||||
::: utilities.permissions.ModelAction
|
||||
|
||||
::: utilities.permissions.register_model_actions
|
||||
@@ -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/0022_alter_datasource_options.py
Normal file
17
netbox/core/migrations/0022_alter_datasource_options.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',)
|
||||
verbose_name = _('data source')
|
||||
verbose_name_plural = _('data sources')
|
||||
permissions = [
|
||||
('sync', 'Synchronize data from remote source'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name}'
|
||||
|
||||
17
netbox/dcim/migrations/0231_alter_device_options.py
Normal file
17
netbox/dcim/migrations/0231_alter_device_options.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 = [
|
||||
('dcim', '0230_interface_rf_channel_frequency_precision'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='device',
|
||||
options={'ordering': ('name', 'pk'), 'permissions': [('render_config', 'Render device configuration')]},
|
||||
),
|
||||
]
|
||||
@@ -759,6 +759,9 @@ class Device(
|
||||
)
|
||||
verbose_name = _('device')
|
||||
verbose_name_plural = _('devices')
|
||||
permissions = [
|
||||
('render_config', 'Render device 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(),
|
||||
|
||||
8
netbox/project-static/dist/netbox.js
vendored
8
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
8
netbox/project-static/dist/netbox.js.map
vendored
8
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -1,10 +1,17 @@
|
||||
import { initClearField } from './clearField';
|
||||
import { initFormElements } from './elements';
|
||||
import { initFilterModifiers } from './filterModifiers';
|
||||
import { initRegisteredActions } from './registeredActions';
|
||||
import { initSpeedSelector } from './speedSelector';
|
||||
|
||||
export function initForms(): void {
|
||||
for (const func of [initFormElements, initSpeedSelector, initFilterModifiers, initClearField]) {
|
||||
for (const func of [
|
||||
initFormElements,
|
||||
initSpeedSelector,
|
||||
initFilterModifiers,
|
||||
initClearField,
|
||||
initRegisteredActions,
|
||||
]) {
|
||||
func();
|
||||
}
|
||||
}
|
||||
|
||||
62
netbox/project-static/src/forms/registeredActions.ts
Normal file
62
netbox/project-static/src/forms/registeredActions.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { getElements } from '../util';
|
||||
|
||||
/**
|
||||
* Enable/disable registered action checkboxes based on selected object_types.
|
||||
*/
|
||||
export function initRegisteredActions(): void {
|
||||
const selectedList = document.querySelector<HTMLSelectElement>(
|
||||
'select[data-object-types-selected]',
|
||||
);
|
||||
|
||||
if (!selectedList) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actionCheckboxes = Array.from(
|
||||
document.querySelectorAll<HTMLInputElement>('input[type="checkbox"][data-models]'),
|
||||
);
|
||||
|
||||
if (actionCheckboxes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
function updateState(): void {
|
||||
const selectedModels = new Set<string>();
|
||||
|
||||
// Get model keys from selected options
|
||||
for (const option of Array.from(selectedList!.options)) {
|
||||
const modelKey = option.dataset.modelKey;
|
||||
if (modelKey) {
|
||||
selectedModels.add(modelKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Enable a checkbox if any of its supported models is selected
|
||||
for (const checkbox of actionCheckboxes) {
|
||||
const modelKeys = (checkbox.dataset.models ?? '').split(',').filter(Boolean);
|
||||
const enabled = modelKeys.some(m => selectedModels.has(m));
|
||||
checkbox.disabled = !enabled;
|
||||
if (!enabled) {
|
||||
checkbox.checked = false;
|
||||
}
|
||||
checkbox.style.opacity = enabled ? '' : '0.75';
|
||||
|
||||
// Fade the label text when disabled
|
||||
const label = checkbox.nextElementSibling as HTMLElement | null;
|
||||
if (label) {
|
||||
label.style.opacity = enabled ? '' : '0.5';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial update
|
||||
updateState();
|
||||
|
||||
// Listen to move button clicks
|
||||
for (const btn of getElements<HTMLButtonElement>('.move-option')) {
|
||||
btn.addEventListener('click', () => {
|
||||
// Wait for DOM update
|
||||
setTimeout(updateState, 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
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'
|
||||
|
||||
# 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 *
|
||||
@@ -25,8 +26,8 @@ from utilities.forms.fields import (
|
||||
JSONField,
|
||||
)
|
||||
from utilities.forms.rendering import FieldSet
|
||||
from utilities.forms.widgets import DateTimePicker, SplitMultiSelectWidget
|
||||
from utilities.permissions import qs_filter_from_constraints
|
||||
from utilities.forms.widgets import DateTimePicker, ObjectTypeSplitMultiSelectWidget, RegisteredActionsWidget
|
||||
from utilities.permissions import get_action_model_map, qs_filter_from_constraints
|
||||
from utilities.string import title
|
||||
|
||||
__all__ = (
|
||||
@@ -325,7 +326,7 @@ class ObjectPermissionForm(forms.ModelForm):
|
||||
object_types = ContentTypeMultipleChoiceField(
|
||||
label=_('Object types'),
|
||||
queryset=ObjectType.objects.all(),
|
||||
widget=SplitMultiSelectWidget(
|
||||
widget=ObjectTypeSplitMultiSelectWidget(
|
||||
choices=get_object_types_choices
|
||||
),
|
||||
help_text=_('Select the types of objects to which the permission will apply.')
|
||||
@@ -342,11 +343,16 @@ class ObjectPermissionForm(forms.ModelForm):
|
||||
can_delete = forms.BooleanField(
|
||||
required=False
|
||||
)
|
||||
registered_actions = forms.MultipleChoiceField(
|
||||
required=False,
|
||||
widget=RegisteredActionsWidget(),
|
||||
label=_('Registered actions'),
|
||||
)
|
||||
actions = SimpleArrayField(
|
||||
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 +376,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', 'registered_actions', 'actions',
|
||||
name=_('Actions')
|
||||
),
|
||||
FieldSet('groups', 'users', name=_('Assignment')),
|
||||
FieldSet('constraints', name=_('Constraints')),
|
||||
)
|
||||
@@ -385,6 +394,25 @@ class ObjectPermissionForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Build PK to model key mapping for object_types widget
|
||||
pk_to_model_key = {
|
||||
ot.pk: f'{ot.app_label}.{ot.model}'
|
||||
for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES)
|
||||
}
|
||||
self.fields['object_types'].widget.set_model_key_map(pk_to_model_key)
|
||||
|
||||
# Configure registered_actions widget and field choices (flat, deduplicated by name)
|
||||
model_actions = dict(registry['model_actions'])
|
||||
self.fields['registered_actions'].widget.set_model_actions(model_actions)
|
||||
seen = set()
|
||||
choices = []
|
||||
for actions in model_actions.values():
|
||||
for action in actions:
|
||||
if action.name not in seen:
|
||||
choices.append((action.name, action.name))
|
||||
seen.add(action.name)
|
||||
self.fields['registered_actions'].choices = choices
|
||||
|
||||
# Make the actions field optional since the form uses it only for non-CRUD actions
|
||||
self.fields['actions'].required = False
|
||||
|
||||
@@ -394,11 +422,32 @@ 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 actions: action is checked if it's in instance.actions
|
||||
# AND at least one assigned object type supports it
|
||||
selected_registered = []
|
||||
consumed_actions = set()
|
||||
for ct in self.instance.object_types.all():
|
||||
model_key = f'{ct.app_label}.{ct.model}'
|
||||
if model_key in model_actions:
|
||||
for ma in model_actions[model_key]:
|
||||
if ma.name in remaining_actions and ma.name not in consumed_actions:
|
||||
selected_registered.append(ma.name)
|
||||
consumed_actions.add(ma.name)
|
||||
self.fields['registered_actions'].initial = selected_registered
|
||||
|
||||
# Remaining actions go to the additional actions field
|
||||
self.initial['actions'] = [
|
||||
a for a in remaining_actions if a not in consumed_actions
|
||||
]
|
||||
|
||||
# Populate initial data for a new ObjectPermission
|
||||
elif self.initial:
|
||||
@@ -408,7 +457,7 @@ 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)
|
||||
@@ -420,15 +469,44 @@ 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', [])
|
||||
registered_actions = self.cleaned_data.get('registered_actions', [])
|
||||
constraints = self.cleaned_data.get('constraints')
|
||||
|
||||
# Build set of selected model keys for validation
|
||||
selected_models = {f'{ct.app_label}.{ct.model}' for ct in object_types}
|
||||
|
||||
# Build map of action_name -> set of model_keys that support it
|
||||
action_model_keys = get_action_model_map(dict(registry['model_actions']))
|
||||
|
||||
# Validate each selected action is supported by at least one selected object type
|
||||
errors = []
|
||||
final_actions = []
|
||||
for action_name in registered_actions:
|
||||
supported_models = action_model_keys.get(action_name, set())
|
||||
if not supported_models & selected_models:
|
||||
errors.append(
|
||||
_('Action "{action}" is not supported by any of the selected object types.').format(
|
||||
action=action_name
|
||||
)
|
||||
)
|
||||
elif action_name not in final_actions:
|
||||
final_actions.append(action_name)
|
||||
if errors:
|
||||
raise forms.ValidationError({'registered_actions': errors})
|
||||
|
||||
# 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)
|
||||
for action in RESERVED_ACTIONS:
|
||||
if self.cleaned_data.get(f'can_{action}') and action not in final_actions:
|
||||
final_actions.append(action)
|
||||
|
||||
# Add additional/manual actions
|
||||
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,6 +1,8 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.registry import registry
|
||||
from netbox.ui import actions, attrs, panels
|
||||
from users.constants import RESERVED_ACTIONS
|
||||
|
||||
|
||||
class TokenPanel(panels.ObjectAttributesPanel):
|
||||
@@ -45,13 +47,43 @@ 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),
|
||||
]
|
||||
|
||||
enabled_actions = set(obj.actions) - set(RESERVED_ACTIONS)
|
||||
|
||||
# Collect all registered actions from the full registry, deduplicating by name.
|
||||
seen = []
|
||||
seen_set = set()
|
||||
action_models = {}
|
||||
for model_key, model_actions in registry['model_actions'].items():
|
||||
for action in model_actions:
|
||||
if action.name not in seen_set:
|
||||
seen.append(action.name)
|
||||
seen_set.add(action.name)
|
||||
action_models.setdefault(action.name, []).append(model_key)
|
||||
|
||||
registered_display = [
|
||||
(action, action in enabled_actions, ', '.join(sorted(action_models[action])))
|
||||
for action in seen
|
||||
]
|
||||
|
||||
return {
|
||||
**super().get_context(context),
|
||||
'crud_actions': crud_actions,
|
||||
'registered_actions': registered_display,
|
||||
}
|
||||
|
||||
|
||||
class OwnerPanel(panels.ObjectAttributesPanel):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from .actions import *
|
||||
from .apiselect import *
|
||||
from .datetime import *
|
||||
from .misc import *
|
||||
|
||||
47
netbox/utilities/forms/widgets/actions.py
Normal file
47
netbox/utilities/forms/widgets/actions.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from django import forms
|
||||
|
||||
from utilities.permissions import get_action_model_map
|
||||
|
||||
__all__ = (
|
||||
'RegisteredActionsWidget',
|
||||
)
|
||||
|
||||
|
||||
class RegisteredActionsWidget(forms.CheckboxSelectMultiple):
|
||||
"""
|
||||
Widget for registered model actions. Renders each action as an individual checkbox
|
||||
row styled identically to the CRUD checkboxes, with a data-models attribute so JS
|
||||
can enable/disable based on the currently selected object types.
|
||||
"""
|
||||
template_name = 'widgets/registered_actions.html'
|
||||
|
||||
def __init__(self, *args, model_actions=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.model_actions = model_actions or {}
|
||||
self._action_model_keys = {}
|
||||
self._action_help_text = {}
|
||||
self._build_maps()
|
||||
|
||||
def _build_maps(self):
|
||||
self._action_model_keys = get_action_model_map(self.model_actions)
|
||||
self._action_help_text = {}
|
||||
for actions in self.model_actions.values():
|
||||
for action in actions:
|
||||
if action.name not in self._action_help_text:
|
||||
self._action_help_text[action.name] = action.help_text
|
||||
|
||||
def set_model_actions(self, model_actions):
|
||||
self.model_actions = model_actions
|
||||
self._build_maps()
|
||||
|
||||
def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
|
||||
"""
|
||||
Inject model_keys (comma-separated model keys that support this action) and
|
||||
help_text into the option dict. The template uses model_keys for the data-models
|
||||
attribute, which JS reads to enable/disable checkboxes based on selected object types.
|
||||
"""
|
||||
option = super().create_option(name, value, label, selected, index, subindex=subindex, attrs=attrs)
|
||||
action_name = str(value)
|
||||
option['model_keys'] = ','.join(sorted(self._action_model_keys.get(action_name, set())))
|
||||
option['help_text'] = self._action_help_text.get(action_name, '')
|
||||
return option
|
||||
@@ -9,6 +9,7 @@ __all__ = (
|
||||
'ClearableSelect',
|
||||
'ColorSelect',
|
||||
'HTMXSelect',
|
||||
'ObjectTypeSplitMultiSelectWidget',
|
||||
'SelectWithPK',
|
||||
'SplitMultiSelectWidget',
|
||||
)
|
||||
@@ -150,14 +151,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
|
||||
),
|
||||
@@ -180,3 +183,53 @@ class SplitMultiSelectWidget(forms.MultiWidget):
|
||||
def value_from_datadict(self, data, files, name):
|
||||
# Return only the choices from the SelectedOptions widget
|
||||
return super().value_from_datadict(data, files, name)[1]
|
||||
|
||||
|
||||
#
|
||||
# ObjectType-specific widgets for ObjectPermissionForm
|
||||
#
|
||||
|
||||
class ObjectTypeSelectMultiple(SelectMultipleBase):
|
||||
"""
|
||||
SelectMultiple that adds data-model-key attribute to options for JS targeting.
|
||||
"""
|
||||
pk_to_model_key = None
|
||||
|
||||
def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
|
||||
option = super().create_option(name, value, label, selected, index, subindex, attrs)
|
||||
if self.pk_to_model_key:
|
||||
model_key = self.pk_to_model_key.get(value) or self.pk_to_model_key.get(str(value))
|
||||
if model_key:
|
||||
option['attrs']['data-model-key'] = model_key
|
||||
return option
|
||||
|
||||
|
||||
class ObjectTypeAvailableOptions(ObjectTypeSelectMultiple):
|
||||
include_selected = False
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
context = super().get_context(name, value, attrs)
|
||||
context['widget']['attrs']['required'] = False
|
||||
return context
|
||||
|
||||
|
||||
class ObjectTypeSelectedOptions(ObjectTypeSelectMultiple):
|
||||
include_selected = True
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
context = super().get_context(name, value, attrs)
|
||||
context['widget']['attrs']['data-object-types-selected'] = 'true'
|
||||
return context
|
||||
|
||||
|
||||
class ObjectTypeSplitMultiSelectWidget(SplitMultiSelectWidget):
|
||||
"""
|
||||
SplitMultiSelectWidget that adds data-model-key attributes to options.
|
||||
Used by ObjectPermissionForm to enable JS show/hide of custom actions.
|
||||
"""
|
||||
available_widget_class = ObjectTypeAvailableOptions
|
||||
selected_widget_class = ObjectTypeSelectedOptions
|
||||
|
||||
def set_model_key_map(self, pk_to_model_key):
|
||||
for widget in self.widgets:
|
||||
widget.pk_to_model_key = pk_to_model_key
|
||||
|
||||
@@ -1,19 +1,84 @@
|
||||
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_action_model_map',
|
||||
'get_permission_for_model',
|
||||
'permission_is_exempt',
|
||||
'qs_filter_from_constraints',
|
||||
'register_model_actions',
|
||||
'resolve_permission',
|
||||
'resolve_permission_type',
|
||||
)
|
||||
|
||||
|
||||
@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 __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)
|
||||
if not action.name:
|
||||
raise ValueError("Action name must not be empty.")
|
||||
if action.name in RESERVED_ACTIONS:
|
||||
raise ValueError(f"'{action.name}' is a reserved action and cannot be registered.")
|
||||
if action not in registry['model_actions'][label]:
|
||||
registry['model_actions'][label].append(action)
|
||||
|
||||
|
||||
def get_action_model_map(model_actions):
|
||||
"""
|
||||
Build a mapping of action name to the set of model keys that support it.
|
||||
|
||||
Args:
|
||||
model_actions: Dict of {model_key: [ModelAction, ...]} from the registry
|
||||
|
||||
Returns:
|
||||
Dict of {action_name: {model_key, ...}}
|
||||
"""
|
||||
mapping = {}
|
||||
for model_key, actions in model_actions.items():
|
||||
for action in actions:
|
||||
mapping.setdefault(action.name, set()).add(model_key)
|
||||
return mapping
|
||||
|
||||
|
||||
def get_permission_for_model(model, action):
|
||||
"""
|
||||
Resolve the named permission for a given model (or instance) and action (e.g. view or add).
|
||||
|
||||
@@ -2,6 +2,19 @@
|
||||
{% load helpers %}
|
||||
{% load i18n %}
|
||||
|
||||
{# RegisteredActionsWidget emits its own row markup per option #}
|
||||
{% if field|widget_type == 'registeredactionswidget' %}
|
||||
{{ field }}
|
||||
{% if field.errors %}
|
||||
<div class="row mb-3">
|
||||
<div class="col offset-3">
|
||||
<div class="form-text text-danger">
|
||||
{% for error in field.errors %}{{ error }}{% if not forloop.last %}<br />{% endif %}{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="row mb-3{% if field.errors %} has-errors{% endif %}">
|
||||
|
||||
{# Render the field label (if any), except for checkboxes #}
|
||||
@@ -70,3 +83,4 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
19
netbox/utilities/templates/widgets/registered_actions.html
Normal file
19
netbox/utilities/templates/widgets/registered_actions.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{# optgroups is Django's CheckboxSelectMultiple context: list of (group_name, options, index) tuples #}
|
||||
{% for group_name, options, index in widget.optgroups %}{% for option in options %}
|
||||
<div class="row mb-3">
|
||||
<div class="col offset-3">
|
||||
<div class="form-check mb-0">
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
name="{{ option.name }}"
|
||||
value="{{ option.value }}"
|
||||
id="{{ option.attrs.id }}"
|
||||
data-models="{{ option.model_keys }}"
|
||||
{% if option.selected %}checked{% endif %}>
|
||||
<label class="form-check-label" for="{{ option.attrs.id }}">
|
||||
{{ option.label }}{% if option.help_text %} <small class="text-muted ms-1">{{ option.help_text }}</small>{% endif %}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}{% endfor %}
|
||||
185
netbox/utilities/tests/test_permissions.py
Normal file
185
netbox/utilities/tests/test_permissions.py
Normal file
@@ -0,0 +1,185 @@
|
||||
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)
|
||||
self.assertEqual(actions[0].name, 'test_action')
|
||||
self.assertEqual(actions[0].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)
|
||||
self.assertIsInstance(actions[0], ModelAction)
|
||||
self.assertEqual(actions[0].name, 'action1')
|
||||
self.assertEqual(actions[1].name, 'action2')
|
||||
|
||||
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)
|
||||
self.assertEqual(actions[0].help_text, 'Has help')
|
||||
self.assertEqual(actions[1].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)
|
||||
self.assertEqual(actions[0].name, 'first')
|
||||
self.assertEqual(actions[1].name, '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_flat_choices_no_duplicates(self):
|
||||
"""Same action name registered for multiple models should appear once in flat choices."""
|
||||
register_model_actions(Device, [ModelAction('render_config')])
|
||||
register_model_actions(VirtualMachine, [ModelAction('render_config')])
|
||||
|
||||
device_ct = ObjectType.objects.get_for_model(Device)
|
||||
form = ObjectPermissionForm(data={
|
||||
'name': 'test',
|
||||
'object_types_0': [],
|
||||
'object_types_1': [device_ct.pk],
|
||||
'can_view': True,
|
||||
})
|
||||
choices = form.fields['registered_actions'].choices
|
||||
names = [name for name, label in choices]
|
||||
self.assertEqual(names.count('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):
|
||||
"""Editing a permission with render_config should pre-select 'render_config' (plain name)."""
|
||||
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)
|
||||
|
||||
initial = form.fields['registered_actions'].initial
|
||||
self.assertIn('render_config', initial)
|
||||
# Should not use the old model-prefixed format
|
||||
self.assertNotIn('dcim.device.render_config', initial)
|
||||
self.assertNotIn('virtualization.virtualmachine.render_config', initial)
|
||||
# Should only appear once despite being registered for two models
|
||||
self.assertEqual(initial.count('render_config'), 1)
|
||||
|
||||
# Should not leak into the additional actions field
|
||||
self.assertEqual(form.initial['actions'], [])
|
||||
|
||||
permission.delete()
|
||||
|
||||
def test_clean_accepts_valid_registered_action(self):
|
||||
"""clean() should accept a plain action name when the matching object type is selected."""
|
||||
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],
|
||||
'registered_actions': ['render_config'],
|
||||
'can_view': True,
|
||||
'actions': '',
|
||||
})
|
||||
self.assertTrue(form.is_valid(), form.errors)
|
||||
self.assertIn('render_config', form.cleaned_data['actions'])
|
||||
|
||||
def test_clean_rejects_action_without_matching_object_type(self):
|
||||
"""clean() should reject a registered action when no matching object type is selected."""
|
||||
register_model_actions(Device, [ModelAction('render_config')])
|
||||
|
||||
site_ct = ObjectType.objects.get_for_model(Site)
|
||||
form = ObjectPermissionForm(data={
|
||||
'name': 'test perm',
|
||||
'object_types_0': [],
|
||||
'object_types_1': [site_ct.pk],
|
||||
'registered_actions': ['render_config'],
|
||||
'can_view': True,
|
||||
'actions': '',
|
||||
})
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('registered_actions', form.errors)
|
||||
@@ -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 VM configuration')]},
|
||||
),
|
||||
]
|
||||
@@ -276,6 +276,9 @@ class VirtualMachine(
|
||||
)
|
||||
verbose_name = _('virtual machine')
|
||||
verbose_name_plural = _('virtual machines')
|
||||
permissions = [
|
||||
('render_config', 'Render VM configuration'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
Reference in New Issue
Block a user