feat: add tenant groups and required tenancy

This commit is contained in:
2026-07-28 16:57:52 +02:00
parent 54f6a3f6c9
commit 35e06ee004
17 changed files with 427 additions and 75 deletions
+30 -9
View File
@@ -1,11 +1,12 @@
# NetBox Utilities # NetBox Utilities
Plugin für **NetBox 4.6.5** mit zwei Funktionen: Plugin für **NetBox 4.6.5** mit drei Funktionen:
- Jeder Benutzer kann die Menüs der linken Navigation verschieben oder ausblenden. - Jeder Benutzer kann die Menüs der linken Navigation verschieben oder ausblenden.
- Ein Dropdown in der Kopfleiste setzt einen sitzungsweiten Mandantenfilter für mandantenfähige Listen und die globale Suche. - Ein Dropdown in der Kopfleiste setzt einen sitzungsweiten Filter für einen Mandanten oder eine Mandantengruppe.
- Optional verpflichtende Mandantenzuordnung für alle mandantenfähigen Objekte.
Die Navigationseinstellungen sind benutzerbezogen. Der aktive Mandant wird in der jeweiligen Browser-Session gespeichert. Die Navigationseinstellungen sind benutzerbezogen. Die aktive Mandanten- oder Gruppenauswahl wird in der jeweiligen Browser-Session gespeichert.
## Kompatibilität ## Kompatibilität
@@ -28,7 +29,7 @@ Release-Tag oder ein bestimmter Commit verwendet werden:
```bash ```bash
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \ /opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
"git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@v0.1.0" "git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@v0.2.0"
``` ```
Alternativ kann hinter dem `@` die vollständige Commit-ID stehen. Alternativ kann hinter dem `@` die vollständige Commit-ID stehen.
@@ -61,6 +62,7 @@ PLUGINS_CONFIG = {
"netbox_utilities": { "netbox_utilities": {
"navigation_customization_enabled": True, "navigation_customization_enabled": True,
"tenant_filter_enabled": True, "tenant_filter_enabled": True,
"tenant_required": True,
}, },
} }
``` ```
@@ -129,23 +131,42 @@ Unter **Plugins > NetBox Utilities > Navigation personalisieren** sieht der Benu
Die Funktion kann installationsweit über `navigation_customization_enabled = False` in `PLUGINS_CONFIG` abgeschaltet werden. Die Funktion kann installationsweit über `navigation_customization_enabled = False` in `PLUGINS_CONFIG` abgeschaltet werden.
### Globaler Mandantenfilter ### Globaler Mandanten- und Gruppenfilter
Das Gebäude-Symbol in der Kopfleiste öffnet die Mandantenauswahl. Zur Auswahl stehen nur Mandanten, die der angemeldete Benutzer gemäß NetBox-Objektberechtigungen sehen darf. **Alle Mandanten** hebt den Filter auf. Das Gebäude-Symbol in der Kopfleiste öffnet die Auswahl. Zur Auswahl stehen
Mandanten und Mandantengruppen, die der angemeldete Benutzer gemäß
NetBox-Objektberechtigungen sehen darf. Eine Mandantengruppe umfasst auch die
Mandanten ihrer untergeordneten Gruppen. **Alle Mandanten** hebt den Filter auf.
Der Filter: Der Filter:
- erzwingt `tenant_id` in allen NetBox-Listen, deren FilterSet dieses Feld unterstützt; - erzwingt je nach Auswahl `tenant_id` oder `tenant_group_id` in unterstützten NetBox-Listen;
- beschränkt die Mandantenliste auf den gewählten Mandanten; - beschränkt die Mandantenliste auf den gewählten Mandanten beziehungsweise die gewählte Gruppe;
- beschränkt Ergebnisse der globalen NetBox-Suche; - beschränkt Ergebnisse der globalen NetBox-Suche;
- gilt auch für HTMX-Tabellenupdates und Exporte aus einer gefilterten Liste; - gilt auch für HTMX-Tabellenupdates und Exporte aus einer gefilterten Liste;
- verändert keine REST- oder GraphQL-Anfrage. - verändert keine REST- oder GraphQL-Anfrage.
Superuser können die Funktion unter **Plugins > NetBox Utilities > Einstellungen** zur Laufzeit deaktivieren. Zusätzlich kann `tenant_filter_enabled = False` in `PLUGINS_CONFIG` sie hart abschalten; diese Konfiguration hat Vorrang vor der Einstellung in der Oberfläche. Superuser können die Funktion unter **Plugins > NetBox Utilities > Einstellungen** zur Laufzeit deaktivieren. Zusätzlich kann `tenant_filter_enabled = False` in `PLUGINS_CONFIG` sie hart abschalten; diese Konfiguration hat Vorrang vor der Einstellung in der Oberfläche.
### Verpflichtende Mandantenzuordnung
Standardmäßig ist unter **Plugins > NetBox Utilities > Einstellungen** die
verpflichtende Mandantenzuordnung aktiviert. Bei allen Objekttypen, die in
NetBox ein `tenant`-Feld besitzen, wird dieses Feld als Pflichtfeld behandelt.
Die Prüfung greift in Formularen, CSV-Importen, der REST-API und bei normalen
Aufrufen von `Model.save()` aus Skripten.
Bestehende Objekte ohne Mandant bleiben nach Aktivierung zunächst bestehen.
Beim nächsten Speichern eines solchen Objekts muss ein Mandant ergänzt werden.
Globale Referenzmodelle ohne `tenant`-Feld sind nicht betroffen.
Die Einstellung kann durch einen Superuser deaktiviert werden. Mit
`tenant_required = False` in `PLUGINS_CONFIG` wird sie installationsweit fest
deaktiviert; diese Konfiguration hat Vorrang vor der Admin-Oberfläche.
## Wichtige Semantik ## Wichtige Semantik
Der Mandantenfilter ist ein **Ansichtsfilter und keine Zugriffskontrolle**. Direkte Objekt-URLs werden nicht gesperrt. NetBox-Objektberechtigungen bleiben die maßgebliche Sicherheitsgrenze. Der Mandantenfilter ist ein **Ansichtsfilter und keine Zugriffskontrolle**. Direkte Objekt-URLs werden nicht gesperrt. NetBox-Objektberechtigungen bleiben die maßgebliche Sicherheitsgrenze. Die verpflichtende Mandantenzuordnung ist dagegen eine serverseitige Datenvalidierung.
Nur Modelle mit einer Mandantenzuordnung werden eingeschränkt. Globale Referenzdaten wie Hersteller, Rollen oder Plattformen bleiben sichtbar, weil sie keinem Mandanten gehören und für die Darstellung bzw. Bearbeitung mandantengebundener Objekte benötigt werden. Nur Modelle mit einer Mandantenzuordnung werden eingeschränkt. Globale Referenzdaten wie Hersteller, Rollen oder Plattformen bleiben sichtbar, weil sie keinem Mandanten gehören und für die Darstellung bzw. Bearbeitung mandantengebundener Objekte benötigt werden.
+4 -1
View File
@@ -5,7 +5,7 @@ class NetBoxUtilitiesConfig(PluginConfig):
name = "netbox_utilities" name = "netbox_utilities"
verbose_name = "NetBox Utilities" verbose_name = "NetBox Utilities"
description = "Personal navigation and a global tenant filter" description = "Personal navigation and a global tenant filter"
version = "0.1.0" version = "0.2.0"
author = "LKE" author = "LKE"
base_url = "utilities" base_url = "utilities"
min_version = "4.6.5" min_version = "4.6.5"
@@ -13,6 +13,7 @@ class NetBoxUtilitiesConfig(PluginConfig):
default_settings = { default_settings = {
"navigation_customization_enabled": True, "navigation_customization_enabled": True,
"tenant_filter_enabled": True, "tenant_filter_enabled": True,
"tenant_required": True,
} }
middleware = [ middleware = [
"netbox_utilities.middleware.GlobalTenantFilterMiddleware", "netbox_utilities.middleware.GlobalTenantFilterMiddleware",
@@ -21,8 +22,10 @@ class NetBoxUtilitiesConfig(PluginConfig):
def ready(self): def ready(self):
super().ready() super().ready()
from .tenant_scope import install_search_filter from .tenant_scope import install_search_filter
from .tenant_validation import install_tenant_validation
install_search_filter() install_search_filter()
install_tenant_validation()
config = NetBoxUtilitiesConfig config = NetBoxUtilitiesConfig
+12 -1
View File
@@ -4,18 +4,29 @@ from .models import UtilitiesSettings
class UtilitiesSettingsForm(forms.ModelForm): class UtilitiesSettingsForm(forms.ModelForm):
def __init__(self, *args, tenant_filter_locked=False, tenant_required_locked=False, **kwargs):
super().__init__(*args, **kwargs)
self.fields["tenant_filter_enabled"].disabled = tenant_filter_locked
self.fields["tenant_required"].disabled = tenant_required_locked
class Meta: class Meta:
model = UtilitiesSettings model = UtilitiesSettings
fields = ("tenant_filter_enabled",) fields = ("tenant_filter_enabled", "tenant_required")
labels = { labels = {
"tenant_filter_enabled": "Globalen Mandantenfilter aktivieren", "tenant_filter_enabled": "Globalen Mandantenfilter aktivieren",
"tenant_required": "Mandant für mandantenfähige Objekte verpflichtend machen",
} }
help_texts = { help_texts = {
"tenant_filter_enabled": ( "tenant_filter_enabled": (
"Blendet das Mandanten-Dropdown ein und ergänzt unterstützte NetBox-Listen " "Blendet das Mandanten-Dropdown ein und ergänzt unterstützte NetBox-Listen "
"automatisch um den gewählten Mandantenfilter." "automatisch um den gewählten Mandantenfilter."
), ),
"tenant_required": (
"Verhindert das Speichern mandantenfähiger Objekte ohne Mandant. "
"Dies gilt auch für Importe, API-Aufrufe und Skripte."
),
} }
widgets = { widgets = {
"tenant_filter_enabled": forms.CheckboxInput(attrs={"class": "form-check-input"}), "tenant_filter_enabled": forms.CheckboxInput(attrs={"class": "form-check-input"}),
"tenant_required": forms.CheckboxInput(attrs={"class": "form-check-input"}),
} }
+50 -14
View File
@@ -1,10 +1,11 @@
from django.urls import Resolver404, resolve from django.urls import Resolver404, resolve
from tenancy.models import Tenant from tenancy.models import Tenant, TenantGroup
from .runtime import tenant_filter_enabled from .runtime import tenant_filter_enabled
from .tenant_scope import active_tenant_id from .tenant_scope import ActiveTenantScope, active_tenant_scope
SESSION_TENANT_KEY = "netbox_utilities.selected_tenant_id" SESSION_TENANT_KEY = "netbox_utilities.selected_tenant_id"
SESSION_TENANT_GROUP_KEY = "netbox_utilities.selected_tenant_group_id"
class GlobalTenantFilterMiddleware: class GlobalTenantFilterMiddleware:
@@ -15,35 +16,53 @@ class GlobalTenantFilterMiddleware:
def __call__(self, request): def __call__(self, request):
request.netbox_utilities_tenant = None request.netbox_utilities_tenant = None
tenant_id = self._get_selected_tenant_id(request) request.netbox_utilities_tenant_group = None
token = active_tenant_id.set(tenant_id) scope = self._get_selected_scope(request)
token = active_tenant_scope.set(scope)
try: try:
if tenant_id is not None and request.method in {"GET", "HEAD"}: if scope is not None and request.method in {"GET", "HEAD"}:
self._inject_filter_parameter(request, tenant_id) self._inject_filter_parameter(request, scope)
return self.get_response(request) return self.get_response(request)
finally: finally:
active_tenant_id.reset(token) active_tenant_scope.reset(token)
@staticmethod @staticmethod
def _get_selected_tenant_id(request): def _get_selected_scope(request):
if not tenant_filter_enabled() or not request.user.is_authenticated: if not tenant_filter_enabled() or not request.user.is_authenticated:
request.session.pop(SESSION_TENANT_KEY, None) GlobalTenantFilterMiddleware.clear_scope(request)
return None return None
tenant_group_id = request.session.get(SESSION_TENANT_GROUP_KEY)
if tenant_group_id is not None:
group = TenantGroup.objects.restrict(request.user, "view").filter(pk=tenant_group_id).first()
if group is None:
GlobalTenantFilterMiddleware.clear_scope(request)
return None
descendant_groups = group.get_descendants(include_self=True)
group_ids = frozenset(descendant_groups.values_list("pk", flat=True))
tenant_ids = Tenant.objects.filter(group_id__in=group_ids).values_list("pk", flat=True)
request.netbox_utilities_tenant_group = group
return ActiveTenantScope("group", group.pk, frozenset(tenant_ids), group_ids)
tenant_id = request.session.get(SESSION_TENANT_KEY) tenant_id = request.session.get(SESSION_TENANT_KEY)
if tenant_id is None: if tenant_id is None:
return None return None
tenant = Tenant.objects.restrict(request.user, "view").filter(pk=tenant_id).first() tenant = Tenant.objects.restrict(request.user, "view").filter(pk=tenant_id).first()
if tenant is None: if tenant is None:
request.session.pop(SESSION_TENANT_KEY, None) GlobalTenantFilterMiddleware.clear_scope(request)
return None return None
request.netbox_utilities_tenant = tenant request.netbox_utilities_tenant = tenant
return tenant.pk return ActiveTenantScope("tenant", tenant.pk, frozenset({tenant.pk}))
@staticmethod @staticmethod
def _inject_filter_parameter(request, tenant_id): def clear_scope(request):
request.session.pop(SESSION_TENANT_KEY, None)
request.session.pop(SESSION_TENANT_GROUP_KEY, None)
@staticmethod
def _inject_filter_parameter(request, scope):
try: try:
match = resolve(request.path_info) match = resolve(request.path_info)
except Resolver404: except Resolver404:
@@ -54,15 +73,32 @@ class GlobalTenantFilterMiddleware:
return return
parameter = None parameter = None
values = []
if match.view_name == "tenancy:tenant_list": if match.view_name == "tenancy:tenant_list":
if scope.kind == "tenant":
parameter = "id"
values = [scope.object_id]
else:
parameter = "group_id"
values = [scope.object_id]
elif match.view_name == "tenancy:tenantgroup_list" and scope.kind == "group":
parameter = "id" parameter = "id"
values = list(scope.group_ids) or [scope.object_id]
else: else:
view_class = getattr(match.func, "view_class", None) view_class = getattr(match.func, "view_class", None)
filterset = getattr(view_class, "filterset", None) filterset = getattr(view_class, "filterset", None)
if filterset and "tenant_id" in getattr(filterset, "base_filters", {}): base_filters = getattr(filterset, "base_filters", {}) if filterset else {}
if scope.kind == "tenant" and "tenant_id" in base_filters:
parameter = "tenant_id" parameter = "tenant_id"
values = [scope.object_id]
elif scope.kind == "group" and "tenant_group_id" in base_filters:
parameter = "tenant_group_id"
values = [scope.object_id]
elif scope.kind == "group" and "tenant_id" in base_filters:
parameter = "tenant_id"
values = list(scope.tenant_ids) or [0]
if parameter: if parameter:
query = request.GET.copy() query = request.GET.copy()
query.setlist(parameter, [str(tenant_id)]) query.setlist(parameter, [str(value) for value in values])
request.GET = query request.GET = query
@@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("netbox_utilities", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="utilitiessettings",
name="tenant_required",
field=models.BooleanField(default=True),
),
]
+1
View File
@@ -28,6 +28,7 @@ class UtilitiesSettings(models.Model):
validators=[MinValueValidator(1)], validators=[MinValueValidator(1)],
) )
tenant_filter_enabled = models.BooleanField(default=True) tenant_filter_enabled = models.BooleanField(default=True)
tenant_required = models.BooleanField(default=True)
updated = models.DateTimeField(auto_now=True) updated = models.DateTimeField(auto_now=True)
class Meta: class Meta:
+25 -9
View File
@@ -4,7 +4,7 @@ from netbox.plugins import get_plugin_config
from .models import UtilitiesSettings from .models import UtilitiesSettings
SETTINGS_CACHE_KEY = "netbox_utilities.runtime_settings.v1" SETTINGS_CACHE_KEY = "netbox_utilities.runtime_settings.v2"
def navigation_customization_enabled(): def navigation_customization_enabled():
@@ -15,18 +15,34 @@ def tenant_filter_enabled():
if not get_plugin_config("netbox_utilities", "tenant_filter_enabled"): if not get_plugin_config("netbox_utilities", "tenant_filter_enabled"):
return False return False
enabled = cache.get(SETTINGS_CACHE_KEY) return bool(_get_database_settings()["tenant_filter_enabled"])
if enabled is None:
def tenant_required():
if not get_plugin_config("netbox_utilities", "tenant_required"):
return False
return bool(_get_database_settings()["tenant_required"])
def _get_database_settings():
runtime_settings = cache.get(SETTINGS_CACHE_KEY)
if runtime_settings is None:
try: try:
enabled = ( runtime_settings = (
UtilitiesSettings.objects.filter(singleton_id=1).values_list("tenant_filter_enabled", flat=True).first() UtilitiesSettings.objects.filter(singleton_id=1)
.values("tenant_filter_enabled", "tenant_required")
.first()
) )
except (OperationalError, ProgrammingError): except (OperationalError, ProgrammingError):
# The middleware can be imported before the first plugin migration. # The middleware can be imported before the first plugin migration.
enabled = True runtime_settings = None
enabled = True if enabled is None else enabled runtime_settings = runtime_settings or {
cache.set(SETTINGS_CACHE_KEY, enabled, timeout=30) "tenant_filter_enabled": True,
return bool(enabled) "tenant_required": True,
}
cache.set(SETTINGS_CACHE_KEY, runtime_settings, timeout=30)
return runtime_settings
def clear_runtime_settings_cache(): def clear_runtime_settings_cache():
+4 -1
View File
@@ -1,5 +1,5 @@
from netbox.plugins import PluginTemplateExtension from netbox.plugins import PluginTemplateExtension
from tenancy.models import Tenant from tenancy.models import Tenant, TenantGroup
from .models import NavigationPreference from .models import NavigationPreference
from .navigation_helpers import get_visible_menus, normalize_preferences from .navigation_helpers import get_visible_menus, normalize_preferences
@@ -37,11 +37,14 @@ class UtilitiesGlobalContent(PluginTemplateExtension):
return "" return ""
tenants = Tenant.objects.restrict(request.user, "view").order_by("name") tenants = Tenant.objects.restrict(request.user, "view").order_by("name")
tenant_groups = TenantGroup.objects.restrict(request.user, "view").order_by("name")
return self.render( return self.render(
"netbox_utilities/tenant_dropdown.html", "netbox_utilities/tenant_dropdown.html",
{ {
"tenants": tenants, "tenants": tenants,
"tenant_groups": tenant_groups,
"selected_tenant": getattr(request, "netbox_utilities_tenant", None), "selected_tenant": getattr(request, "netbox_utilities_tenant", None),
"selected_tenant_group": getattr(request, "netbox_utilities_tenant_group", None),
"return_url": request.get_full_path(), "return_url": request.get_full_path(),
}, },
) )
@@ -10,13 +10,18 @@
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col col-md-9 col-xl-7"> <div class="col col-md-9 col-xl-7">
<div class="card"> <div class="card">
<div class="card-header"><h2 class="card-title">Mandantenfilter</h2></div> <div class="card-header"><h2 class="card-title">Mandanten</h2></div>
<div class="card-body"> <div class="card-body">
{% if config_forces_disabled %} {% if tenant_filter_locked %}
<div class="alert alert-warning"> <div class="alert alert-warning">
Der Mandantenfilter ist in <code>PLUGINS_CONFIG</code> fest deaktiviert und kann hier nicht aktiviert werden. Der Mandantenfilter ist in <code>PLUGINS_CONFIG</code> fest deaktiviert und kann hier nicht aktiviert werden.
</div> </div>
{% endif %} {% endif %}
{% if tenant_required_locked %}
<div class="alert alert-warning">
Die verpflichtende Mandantenzuordnung ist in <code>PLUGINS_CONFIG</code> fest deaktiviert.
</div>
{% endif %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% if form.non_field_errors %}<div class="alert alert-danger">{{ form.non_field_errors }}</div>{% endif %} {% if form.non_field_errors %}<div class="alert alert-danger">{{ form.non_field_errors }}</div>{% endif %}
@@ -27,9 +32,18 @@
</label> </label>
</div> </div>
<div class="form-text mb-3">{{ form.tenant_filter_enabled.help_text }}</div> <div class="form-text mb-3">{{ form.tenant_filter_enabled.help_text }}</div>
<button class="btn btn-primary" type="submit"{% if config_forces_disabled %} disabled{% endif %}> <div class="form-check form-switch mt-4">
Speichern {{ form.tenant_required }}
</button> <label class="form-check-label fw-bold" for="{{ form.tenant_required.id_for_label }}">
{{ form.tenant_required.label }}
</label>
</div>
<div class="form-text mb-3">{{ form.tenant_required.help_text }}</div>
<div class="alert alert-info">
Die Pflicht gilt nur für Objekttypen, die in NetBox ein Mandantenfeld besitzen.
Bestehende Objekte ohne Mandant müssen beim nächsten Speichern ergänzt werden.
</div>
<button class="btn btn-primary" type="submit">Speichern</button>
</form> </form>
</div> </div>
</div> </div>
@@ -9,7 +9,13 @@
> >
<i class="mdi mdi-domain" aria-hidden="true"></i> <i class="mdi mdi-domain" aria-hidden="true"></i>
<span class="d-none d-xl-inline ms-1 tenant-filter-label"> <span class="d-none d-xl-inline ms-1 tenant-filter-label">
{% if selected_tenant %}{{ selected_tenant }}{% else %}Alle Mandanten{% endif %} {% if selected_tenant %}
{{ selected_tenant }}
{% elif selected_tenant_group %}
{{ selected_tenant_group }}
{% else %}
Alle Mandanten
{% endif %}
</span> </span>
<i class="mdi mdi-chevron-down" aria-hidden="true"></i> <i class="mdi mdi-chevron-down" aria-hidden="true"></i>
</button> </button>
@@ -18,26 +24,39 @@
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="next" value="{{ return_url }}"> <input type="hidden" name="next" value="{{ return_url }}">
<label class="form-label fw-bold"> <label class="form-label fw-bold">
Mandant Mandant oder Mandantengruppe
</label> </label>
<select <select
class="form-select" class="form-select"
name="tenant" name="scope"
onchange="this.form.submit()" onchange="this.form.submit()"
aria-label="Mandant global auswählen" aria-label="Mandant global auswählen"
> >
<option value="">Alle Mandanten</option> <option value="">Alle Mandanten</option>
{% for tenant in tenants %} {% if tenant_groups %}
<option value="{{ tenant.pk }}"{% if selected_tenant.pk == tenant.pk %} selected{% endif %}> <optgroup label="Mandantengruppen">
{{ tenant.name }} {% for group in tenant_groups %}
</option> <option value="group:{{ group.pk }}"{% if selected_tenant_group.pk == group.pk %} selected{% endif %}>
{% endfor %} {{ group.name }}
</option>
{% endfor %}
</optgroup>
{% endif %}
{% if tenants %}
<optgroup label="Mandanten">
{% for tenant in tenants %}
<option value="tenant:{{ tenant.pk }}"{% if selected_tenant.pk == tenant.pk %} selected{% endif %}>
{{ tenant.name }}
</option>
{% endfor %}
</optgroup>
{% endif %}
</select> </select>
<noscript> <noscript>
<button class="btn btn-primary w-100 mt-2" type="submit">Anwenden</button> <button class="btn btn-primary w-100 mt-2" type="submit">Anwenden</button>
</noscript> </noscript>
<p class="text-secondary small mb-0 mt-2"> <p class="text-secondary small mb-0 mt-2">
Wirkt auf mandantenfähige Listen und die globale Suche. Gruppen schließen alle direkt und untergeordnet zugeordneten Mandanten ein.
</p> </p>
</form> </form>
</div> </div>
+21 -7
View File
@@ -1,9 +1,19 @@
from contextvars import ContextVar from contextvars import ContextVar
from dataclasses import dataclass
from functools import wraps from functools import wraps
from tenancy.models import Tenant from tenancy.models import Tenant
active_tenant_id = ContextVar("netbox_utilities_active_tenant_id", default=None)
@dataclass(frozen=True)
class ActiveTenantScope:
kind: str
object_id: int
tenant_ids: frozenset[int]
group_ids: frozenset[int] = frozenset()
active_tenant_scope = ContextVar("netbox_utilities_active_tenant_scope", default=None)
_PARENT_RELATIONS = ( _PARENT_RELATIONS = (
"device", "device",
@@ -22,6 +32,10 @@ _PARENT_RELATIONS = (
def object_matches_tenant(obj, tenant_id, depth=0): def object_matches_tenant(obj, tenant_id, depth=0):
return object_matches_tenant_ids(obj, frozenset({tenant_id}), depth=depth)
def object_matches_tenant_ids(obj, tenant_ids, depth=0):
""" """
Return whether a search result belongs to the selected tenant. Return whether a search result belongs to the selected tenant.
@@ -31,11 +45,11 @@ def object_matches_tenant(obj, tenant_id, depth=0):
if obj is None: if obj is None:
return False return False
if isinstance(obj, Tenant): if isinstance(obj, Tenant):
return obj.pk == tenant_id return obj.pk in tenant_ids
field_names = {field.name for field in obj._meta.get_fields()} field_names = {field.name for field in obj._meta.get_fields()}
if "tenant" in field_names: if "tenant" in field_names:
return getattr(obj, "tenant_id", None) == tenant_id return getattr(obj, "tenant_id", None) in tenant_ids
if depth < 2: if depth < 2:
for relation in _PARENT_RELATIONS: for relation in _PARENT_RELATIONS:
@@ -44,7 +58,7 @@ def object_matches_tenant(obj, tenant_id, depth=0):
parent = getattr(obj, relation, None) parent = getattr(obj, relation, None)
if parent is None: if parent is None:
return False return False
return object_matches_tenant(parent, tenant_id, depth + 1) return object_matches_tenant_ids(parent, tenant_ids, depth + 1)
# Global reference objects (roles, manufacturers, statuses, etc.) are not # Global reference objects (roles, manufacturers, statuses, etc.) are not
# tenant-owned and must remain usable while a filter is active. # tenant-owned and must remain usable while a filter is active.
@@ -63,10 +77,10 @@ def install_search_filter():
@wraps(original_search) @wraps(original_search)
def tenant_aware_search(*args, **kwargs): def tenant_aware_search(*args, **kwargs):
results = original_search(*args, **kwargs) results = original_search(*args, **kwargs)
tenant_id = active_tenant_id.get() scope = active_tenant_scope.get()
if tenant_id is None: if scope is None:
return results return results
return [result for result in results if object_matches_tenant(result.object, tenant_id)] return [result for result in results if object_matches_tenant_ids(result.object, scope.tenant_ids)]
search_backend.search = tenant_aware_search search_backend.search = tenant_aware_search
search_backend._netbox_utilities_wrapped = True search_backend._netbox_utilities_wrapped = True
+76
View File
@@ -0,0 +1,76 @@
from functools import wraps
from django.apps import apps
from django.core.exceptions import FieldDoesNotExist, ValidationError
from django.db.models.signals import pre_save
from django.forms.models import BaseModelForm
from tenancy.models import Tenant
from .runtime import tenant_required
VALIDATION_MESSAGE = "Für dieses Objekt muss ein Mandant angegeben werden."
def model_supports_tenant(model):
try:
field = model._meta.get_field("tenant")
except FieldDoesNotExist:
return False
return field.concrete and field.is_relation and field.related_model is Tenant
def validate_tenant_assignment(instance):
if tenant_required() and model_supports_tenant(type(instance)) and getattr(instance, "tenant_id", None) is None:
# A non-field error is intentional: a small number of specialized
# ModelForms omit the tenant field and Django cannot attach errors to
# fields which are absent from a form.
raise ValidationError(VALIDATION_MESSAGE)
def _install_model_clean_validation(model):
if not model_supports_tenant(model) or getattr(model, "_netbox_utilities_tenant_validation", False):
return
original_clean = model.clean
@wraps(original_clean)
def tenant_aware_clean(instance, *args, **kwargs):
result = original_clean(instance, *args, **kwargs)
validate_tenant_assignment(instance)
return result
model.clean = tenant_aware_clean
model._netbox_utilities_tenant_validation = True
def _install_form_validation():
if getattr(BaseModelForm, "_netbox_utilities_tenant_validation", False):
return
original_init = BaseModelForm.__init__
@wraps(original_init)
def tenant_aware_init(form, *args, **kwargs):
original_init(form, *args, **kwargs)
model = getattr(form._meta, "model", None)
if tenant_required() and model and model_supports_tenant(model) and "tenant" in form.fields:
form.fields["tenant"].required = True
BaseModelForm.__init__ = tenant_aware_init
BaseModelForm._netbox_utilities_tenant_validation = True
def _enforce_before_save(sender, instance, raw=False, **kwargs):
if not raw:
validate_tenant_assignment(instance)
def install_tenant_validation():
for model in apps.get_models():
_install_model_clean_validation(model)
_install_form_validation()
pre_save.connect(
_enforce_before_save,
dispatch_uid="netbox_utilities.enforce_tenant_before_save",
weak=False,
)
+40 -3
View File
@@ -4,6 +4,7 @@ from unittest.mock import patch
from django.test import RequestFactory, SimpleTestCase from django.test import RequestFactory, SimpleTestCase
from netbox_utilities.middleware import GlobalTenantFilterMiddleware from netbox_utilities.middleware import GlobalTenantFilterMiddleware
from netbox_utilities.tenant_scope import ActiveTenantScope
class TenantFilterSet: class TenantFilterSet:
@@ -32,7 +33,8 @@ class GlobalTenantFilterMiddlewareTest(SimpleTestCase):
mocked_resolve.return_value = resolver_match(TenantFilterSet) mocked_resolve.return_value = resolver_match(TenantFilterSet)
request = self.factory.get("/dcim/devices/?tenant_id=7&status=active") request = self.factory.get("/dcim/devices/?tenant_id=7&status=active")
GlobalTenantFilterMiddleware._inject_filter_parameter(request, 42) scope = ActiveTenantScope("tenant", 42, frozenset({42}))
GlobalTenantFilterMiddleware._inject_filter_parameter(request, scope)
self.assertEqual(request.GET.getlist("tenant_id"), ["42"]) self.assertEqual(request.GET.getlist("tenant_id"), ["42"])
self.assertEqual(request.GET["status"], "active") self.assertEqual(request.GET["status"], "active")
@@ -42,7 +44,8 @@ class GlobalTenantFilterMiddlewareTest(SimpleTestCase):
mocked_resolve.return_value = resolver_match(SharedFilterSet) mocked_resolve.return_value = resolver_match(SharedFilterSet)
request = self.factory.get("/dcim/manufacturers/?status=active") request = self.factory.get("/dcim/manufacturers/?status=active")
GlobalTenantFilterMiddleware._inject_filter_parameter(request, 42) scope = ActiveTenantScope("tenant", 42, frozenset({42}))
GlobalTenantFilterMiddleware._inject_filter_parameter(request, scope)
self.assertNotIn("tenant_id", request.GET) self.assertNotIn("tenant_id", request.GET)
self.assertEqual(request.GET["status"], "active") self.assertEqual(request.GET["status"], "active")
@@ -52,6 +55,40 @@ class GlobalTenantFilterMiddlewareTest(SimpleTestCase):
mocked_resolve.return_value = resolver_match(None, "tenancy:tenant_list") mocked_resolve.return_value = resolver_match(None, "tenancy:tenant_list")
request = self.factory.get("/tenancy/tenants/") request = self.factory.get("/tenancy/tenants/")
GlobalTenantFilterMiddleware._inject_filter_parameter(request, 42) scope = ActiveTenantScope("tenant", 42, frozenset({42}))
GlobalTenantFilterMiddleware._inject_filter_parameter(request, scope)
self.assertEqual(request.GET.getlist("id"), ["42"]) self.assertEqual(request.GET.getlist("id"), ["42"])
@patch("netbox_utilities.middleware.resolve")
def test_injects_tenant_group(self, mocked_resolve):
class TenantGroupFilterSet:
base_filters = {"tenant_id": object(), "tenant_group_id": object()}
mocked_resolve.return_value = resolver_match(TenantGroupFilterSet)
request = self.factory.get("/dcim/devices/")
scope = ActiveTenantScope("group", 8, frozenset({42, 43}))
GlobalTenantFilterMiddleware._inject_filter_parameter(request, scope)
self.assertEqual(request.GET.getlist("tenant_group_id"), ["8"])
@patch("netbox_utilities.middleware.resolve")
def test_group_falls_back_to_tenant_ids(self, mocked_resolve):
mocked_resolve.return_value = resolver_match(TenantFilterSet)
request = self.factory.get("/dcim/virtual-chassis/")
scope = ActiveTenantScope("group", 8, frozenset({42, 43}))
GlobalTenantFilterMiddleware._inject_filter_parameter(request, scope)
self.assertCountEqual(request.GET.getlist("tenant_id"), ["42", "43"])
@patch("netbox_utilities.middleware.resolve")
def test_tenant_group_list_includes_descendant_groups(self, mocked_resolve):
mocked_resolve.return_value = resolver_match(None, "tenancy:tenantgroup_list")
request = self.factory.get("/tenancy/tenant-groups/")
scope = ActiveTenantScope("group", 8, frozenset({42}), frozenset({8, 9}))
GlobalTenantFilterMiddleware._inject_filter_parameter(request, scope)
self.assertCountEqual(request.GET.getlist("id"), ["8", "9"])
+6 -1
View File
@@ -2,7 +2,7 @@ from types import SimpleNamespace
from django.test import SimpleTestCase from django.test import SimpleTestCase
from netbox_utilities.tenant_scope import object_matches_tenant from netbox_utilities.tenant_scope import object_matches_tenant, object_matches_tenant_ids
class FakeMeta: class FakeMeta:
@@ -32,3 +32,8 @@ class TenantScopeTest(SimpleTestCase):
def test_unassigned_tenant_object_is_hidden(self): def test_unassigned_tenant_object_is_hidden(self):
obj = SimpleNamespace(_meta=FakeMeta("tenant"), tenant_id=None) obj = SimpleNamespace(_meta=FakeMeta("tenant"), tenant_id=None)
self.assertFalse(object_matches_tenant(obj, 42)) self.assertFalse(object_matches_tenant(obj, 42))
def test_object_matches_any_tenant_in_group_scope(self):
obj = SimpleNamespace(_meta=FakeMeta("tenant"), tenant_id=43)
self.assertTrue(object_matches_tenant_ids(obj, frozenset({42, 43})))
self.assertFalse(object_matches_tenant_ids(obj, frozenset({7, 8})))
@@ -0,0 +1,50 @@
from types import SimpleNamespace
from unittest.mock import patch
from django.core.exceptions import FieldDoesNotExist, ValidationError
from django.test import SimpleTestCase
from tenancy.models import Tenant
from netbox_utilities.tenant_validation import model_supports_tenant, validate_tenant_assignment
class FakeMeta:
def __init__(self, supports_tenant=True):
self.supports_tenant = supports_tenant
def get_field(self, name):
if name != "tenant" or not self.supports_tenant:
raise FieldDoesNotExist(name)
return SimpleNamespace(concrete=True, is_relation=True, related_model=Tenant)
class TenantModel:
_meta = FakeMeta()
def __init__(self, tenant_id=None):
self.tenant_id = tenant_id
class SharedModel:
_meta = FakeMeta(supports_tenant=False)
class TenantValidationTest(SimpleTestCase):
def test_detects_tenant_capable_model(self):
self.assertTrue(model_supports_tenant(TenantModel))
self.assertFalse(model_supports_tenant(SharedModel))
@patch("netbox_utilities.tenant_validation.tenant_required", return_value=True)
def test_rejects_missing_tenant_when_required(self, _tenant_required):
with self.assertRaises(ValidationError) as context:
validate_tenant_assignment(TenantModel())
self.assertEqual(context.exception.messages, ["Für dieses Objekt muss ein Mandant angegeben werden."])
@patch("netbox_utilities.tenant_validation.tenant_required", return_value=True)
def test_accepts_assigned_tenant(self, _tenant_required):
validate_tenant_assignment(TenantModel(tenant_id=42))
@patch("netbox_utilities.tenant_validation.tenant_required", return_value=False)
def test_allows_missing_tenant_when_disabled(self, _tenant_required):
validate_tenant_assignment(TenantModel())
+45 -14
View File
@@ -6,10 +6,14 @@ from django.urls import reverse
from django.utils.http import url_has_allowed_host_and_scheme from django.utils.http import url_has_allowed_host_and_scheme
from django.views import View from django.views import View
from netbox.plugins import get_plugin_config from netbox.plugins import get_plugin_config
from tenancy.models import Tenant from tenancy.models import Tenant, TenantGroup
from .forms import UtilitiesSettingsForm from .forms import UtilitiesSettingsForm
from .middleware import SESSION_TENANT_KEY from .middleware import (
SESSION_TENANT_GROUP_KEY,
SESSION_TENANT_KEY,
GlobalTenantFilterMiddleware,
)
from .models import NavigationPreference, UtilitiesSettings from .models import NavigationPreference, UtilitiesSettings
from .navigation_helpers import get_visible_menus, normalize_preferences from .navigation_helpers import get_visible_menus, normalize_preferences
from .runtime import ( from .runtime import (
@@ -87,25 +91,35 @@ class UtilitiesSettingsView(LoginRequiredMixin, UserPassesTestMixin, View):
def get(self, request): def get(self, request):
instance, _ = UtilitiesSettings.objects.get_or_create(singleton_id=1) instance, _ = UtilitiesSettings.objects.get_or_create(singleton_id=1)
tenant_filter_locked = not get_plugin_config("netbox_utilities", "tenant_filter_enabled")
tenant_required_locked = not get_plugin_config("netbox_utilities", "tenant_required")
return render( return render(
request, request,
self.template_name, self.template_name,
{ {
"form": UtilitiesSettingsForm(instance=instance), "form": UtilitiesSettingsForm(
"config_forces_disabled": not get_plugin_config("netbox_utilities", "tenant_filter_enabled"), instance=instance,
tenant_filter_locked=tenant_filter_locked,
tenant_required_locked=tenant_required_locked,
),
"tenant_filter_locked": tenant_filter_locked,
"tenant_required_locked": tenant_required_locked,
}, },
) )
def post(self, request): def post(self, request):
if not get_plugin_config("netbox_utilities", "tenant_filter_enabled"):
return HttpResponseBadRequest("Der Mandantenfilter ist in PLUGINS_CONFIG deaktiviert.")
instance, _ = UtilitiesSettings.objects.get_or_create(singleton_id=1) instance, _ = UtilitiesSettings.objects.get_or_create(singleton_id=1)
form = UtilitiesSettingsForm(request.POST, instance=instance) form = UtilitiesSettingsForm(
request.POST,
instance=instance,
tenant_filter_locked=not get_plugin_config("netbox_utilities", "tenant_filter_enabled"),
tenant_required_locked=not get_plugin_config("netbox_utilities", "tenant_required"),
)
if form.is_valid(): if form.is_valid():
form.save() form.save()
clear_runtime_settings_cache() clear_runtime_settings_cache()
if not form.cleaned_data["tenant_filter_enabled"]: if not tenant_filter_enabled():
request.session.pop(SESSION_TENANT_KEY, None) GlobalTenantFilterMiddleware.clear_scope(request)
messages.success(request, "Die Plugin-Einstellungen wurden gespeichert.") messages.success(request, "Die Plugin-Einstellungen wurden gespeichert.")
return redirect("plugins:netbox_utilities:settings") return redirect("plugins:netbox_utilities:settings")
return render(request, self.template_name, {"form": form}) return render(request, self.template_name, {"form": form})
@@ -118,15 +132,32 @@ class SelectTenantView(LoginRequiredMixin, View):
if not tenant_filter_enabled(): if not tenant_filter_enabled():
return HttpResponseBadRequest("Der globale Mandantenfilter ist deaktiviert.") return HttpResponseBadRequest("Der globale Mandantenfilter ist deaktiviert.")
tenant_id = request.POST.get("tenant") scope_value = request.POST.get("scope") or request.POST.get("tenant", "")
if tenant_id: if not scope_value:
tenant = Tenant.objects.restrict(request.user, "view").filter(pk=tenant_id).first() GlobalTenantFilterMiddleware.clear_scope(request)
messages.success(request, "Der globale Mandantenfilter wurde aufgehoben.")
return redirect(_safe_return_url(request))
if ":" in scope_value:
scope_kind, object_id = scope_value.split(":", 1)
else:
scope_kind, object_id = "tenant", scope_value
if scope_kind == "tenant":
tenant = Tenant.objects.restrict(request.user, "view").filter(pk=object_id).first()
if tenant is None: if tenant is None:
return HttpResponseBadRequest("Unbekannter oder nicht erlaubter Mandant.") return HttpResponseBadRequest("Unbekannter oder nicht erlaubter Mandant.")
request.session.pop(SESSION_TENANT_GROUP_KEY, None)
request.session[SESSION_TENANT_KEY] = tenant.pk request.session[SESSION_TENANT_KEY] = tenant.pk
messages.success(request, f"Globaler Mandantenfilter: {tenant}") messages.success(request, f"Globaler Mandantenfilter: {tenant}")
else: elif scope_kind == "group":
group = TenantGroup.objects.restrict(request.user, "view").filter(pk=object_id).first()
if group is None:
return HttpResponseBadRequest("Unbekannte oder nicht erlaubte Mandantengruppe.")
request.session.pop(SESSION_TENANT_KEY, None) request.session.pop(SESSION_TENANT_KEY, None)
messages.success(request, "Der globale Mandantenfilter wurde aufgehoben.") request.session[SESSION_TENANT_GROUP_KEY] = group.pk
messages.success(request, f"Globale Mandantengruppe: {group}")
else:
return HttpResponseBadRequest("Ungültiger Mandantenfilter.")
return redirect(_safe_return_url(request)) return redirect(_safe_return_url(request))
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "netbox-utilities" name = "netbox-utilities"
version = "0.1.0" version = "0.2.0"
description = "Personal navigation and a global tenant filter for NetBox 4.6" description = "Personal navigation and a global tenant filter for NetBox 4.6"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"