Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
2cfecd7
Add ModelAction and register_model_actions() API for custom permissio…
jnovinger Feb 20, 2026
5cfdf6a
Add ObjectTypeSplitMultiSelectWidget and RegisteredActionsWidget
jnovinger Feb 20, 2026
8926445
Integrate registered actions into ObjectPermissionForm
jnovinger Feb 20, 2026
b2e0116
Add JavaScript for registered actions show/hide
jnovinger Feb 20, 2026
02b8576
Register custom actions for DataSource, Device, and VirtualMachine
jnovinger Feb 20, 2026
83888db
Add tests for ModelAction and register_model_actions
jnovinger Feb 20, 2026
7541554
Refine registered actions widget UI
jnovinger Mar 3, 2026
0f5198e
Hide custom actions field when no applicable models selected
jnovinger Mar 3, 2026
9230194
Add documentation for custom model actions
jnovinger Mar 3, 2026
637ebf6
Add RESERVED_ACTIONS constant and fix dedup in registered actions
jnovinger Mar 3, 2026
3f2734d
Fix shared action pre-selection and additional actions leakage on edit
jnovinger Mar 3, 2026
80595c0
Prevent duplicate action registration in register_model_actions()
jnovinger Mar 3, 2026
e6314e3
Remove stale comment in RegisteredActionsWidget
jnovinger Mar 3, 2026
667702e
Rebuild frontend assets after rebase onto feature
jnovinger Mar 3, 2026
de41d0d
Refactor SplitMultiSelectWidget to use class attributes for widget cl…
jnovinger Mar 3, 2026
2bd8f9d
Reject reserved action names in register_model_actions()
jnovinger Mar 3, 2026
cf6599d
Show all registered actions with enable/disable instead of show/hide
jnovinger Mar 30, 2026
6ac5afc
Validate action name is not empty and clarify RESERVED_ACTIONS origin
jnovinger Mar 30, 2026
2db5976
Adapt custom actions panel for declarative layout system
jnovinger Mar 30, 2026
2fb562f
Merge branch 'feature' into 21357-register-model-actions
jeremystretch Mar 31, 2026
002cf25
Flatten registered actions UI and declare via Meta.permissions
jnovinger Apr 1, 2026
84c2acb
Address review feedback on registered actions
jnovinger Apr 1, 2026
e9be6e4
Consolidate ObjectPermission detail view actions panel
jnovinger Apr 1, 2026
4c291f0
Address additional bot review feedback
jnovinger Apr 3, 2026
b5839d5
Update netbox/netbox/registry.py
jnovinger Apr 3, 2026
a57a538
Fix model_actions registry to use set operations
jnovinger Apr 6, 2026
181c1ab
Rename permission migrations for clarity
jnovinger Apr 6, 2026
fa50e02
Move ModelAction validation into __post_init__
jnovinger Apr 6, 2026
2e19ee6
Drop model name from permission descriptions
jnovinger Apr 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/administration/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
51 changes: 51 additions & 0 deletions docs/plugins/development/permissions.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
17 changes: 17 additions & 0 deletions netbox/core/migrations/0022_datasource_sync_permission.py
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we rename these migrations to something like foo_x_permission?

Original file line number Diff line number Diff line change
@@ -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')]},
),
]
3 changes: 3 additions & 0 deletions netbox/core/models/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ class Meta:
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}'
Expand Down
17 changes: 17 additions & 0 deletions netbox/dcim/migrations/0231_device_render_config_permission.py
Original file line number Diff line number Diff line change
@@ -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')]},
),
]
3 changes: 3 additions & 0 deletions netbox/dcim/models/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,9 @@ class Meta:
)
verbose_name = _('device')
verbose_name_plural = _('devices')
permissions = [
('render_config', 'Render configuration'),
]

def __str__(self):
if self.label and self.asset_tag:
Expand Down
10 changes: 10 additions & 0 deletions netbox/netbox/models/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
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__ = (
Expand Down Expand Up @@ -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)
1 change: 1 addition & 0 deletions netbox/netbox/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ def __delitem__(self, key):
'denormalized_fields': collections.defaultdict(list),
'event_types': dict(),
'filtersets': dict(),
'model_actions': collections.defaultdict(set),
'model_features': dict(),
'models': collections.defaultdict(set),
'plugins': dict(),
Expand Down
8 changes: 4 additions & 4 deletions netbox/project-static/dist/netbox.js

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions netbox/project-static/dist/netbox.js.map

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion netbox/project-static/src/forms/index.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 62 additions & 0 deletions netbox/project-static/src/forms/registeredActions.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 26 additions & 0 deletions netbox/templates/users/panels/actions.html
Original file line number Diff line number Diff line change
@@ -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 %}
5 changes: 5 additions & 0 deletions netbox/users/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@

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
Expand Down
Loading
Loading