mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-22 20:12:00 -06:00
Merge feature
This commit is contained in:
commit
330c498fe4
@ -102,6 +102,14 @@ PyYAML
|
||||
# https://github.com/andymccurdy/redis-py
|
||||
redis
|
||||
|
||||
# Social authentication framework
|
||||
# https://github.com/python-social-auth/social-core
|
||||
social-auth-core[all]
|
||||
|
||||
# Django app for social-auth-core
|
||||
# https://github.com/python-social-auth/social-app-django
|
||||
social-auth-app-django
|
||||
|
||||
# SVG image rendering (used for rack elevations)
|
||||
# https://github.com/mozman/svgwrite
|
||||
svgwrite
|
||||
|
37
docs/administration/authentication.md
Normal file
37
docs/administration/authentication.md
Normal file
@ -0,0 +1,37 @@
|
||||
# Authentication
|
||||
|
||||
## Local Authentication
|
||||
|
||||
Local user accounts and groups can be created in NetBox under the "Authentication and Authorization" section of the administrative user interface. This interface is available only to users with the "staff" permission enabled.
|
||||
|
||||
At a minimum, each user account must have a username and password set. User accounts may also denote a first name, last name, and email address. [Permissions](./permissions.md) may also be assigned to users and/or groups within the admin UI.
|
||||
|
||||
## Remote Authentication
|
||||
|
||||
NetBox may be configured to provide user authenticate via a remote backend in addition to local authentication. This is done by setting the `REMOTE_AUTH_BACKEND` configuration parameter to a suitable backend class. NetBox provides several options for remote authentication.
|
||||
|
||||
### LDAP Authentication
|
||||
|
||||
```python
|
||||
REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend'
|
||||
```
|
||||
|
||||
NetBox includes an authentication backend which supports LDAP. See the [LDAP installation docs](../installation/6-ldap.md) for more detail about this backend.
|
||||
|
||||
### HTTP Header Authentication
|
||||
|
||||
```python
|
||||
REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend'
|
||||
```
|
||||
|
||||
Another option for remote authentication in NetBox is to enable HTTP header-based user assignment. The front end HTTP server (e.g. nginx or Apache) performs client authentication as a process external to NetBox, and passes information about the authenticated user via HTTP headers. By default, the user is assigned via the `REMOTE_USER` header, but this can be customized via the `REMOTE_AUTH_HEADER` configuration parameter.
|
||||
|
||||
### Single Sign-On (SSO)
|
||||
|
||||
```python
|
||||
REMOTE_AUTH_BACKEND = 'social_core.backends.google.GoogleOAuth2'
|
||||
```
|
||||
|
||||
NetBox supports single sign-on authentication via the [python-social-auth](https://github.com/python-social-auth) library. To enable SSO, specify the path to the desired authentication backend within the `social_core` Python package. Please see the complete list of [supported authentication backends](https://github.com/python-social-auth/social-core/tree/master/social_core/backends) for the available options.
|
||||
|
||||
Most remote authentication backends require some additional configuration through settings prefixed with `SOCIAL_AUTH_`. These will be automatically imported from NetBox's `configuration.py` file. Additionally, the [authentication pipeline](https://python-social-auth.readthedocs.io/en/latest/pipeline.html) can be customized via the `SOCIAL_AUTH_PIPELINE` parameter.
|
@ -1,6 +1,6 @@
|
||||
# Permissions
|
||||
|
||||
NetBox v2.9 introduced a new object-based permissions framework, which replace's Django's built-in permissions model. Object-based permissions enable an administrator to grant users or groups the ability to perform an action on arbitrary subsets of objects in NetBox, rather than all objects of a certain type. For example, it is possible to grant a user permission to view only sites within a particular region, or to modify only VLANs with a numeric ID within a certain range.
|
||||
NetBox v2.9 introduced a new object-based permissions framework, which replaces Django's built-in permissions model. Object-based permissions enable an administrator to grant users or groups the ability to perform an action on arbitrary subsets of objects in NetBox, rather than all objects of a certain type. For example, it is possible to grant a user permission to view only sites within a particular region, or to modify only VLANs with a numeric ID within a certain range.
|
||||
|
||||
{!models/users/objectpermission.md!}
|
||||
|
||||
|
@ -57,12 +57,17 @@ A `bridge` field has been added to the interface model for devices and virtual m
|
||||
|
||||
Multiple interfaces can be bridged to a single virtual interface to effect a bridge group. Alternatively, two physical interfaces can be bridged to one another, to effect an internal cross-connect.
|
||||
|
||||
#### Single Sign-On (SSO) Authentication ([#7649](https://github.com/netbox-community/netbox/issues/7649))
|
||||
|
||||
Support for single sign-on (SSO) authentication has been added via the [python-social-auth](https://github.com/python-social-auth) library. NetBox administrators can configure one of the [supported authentication backends](https://python-social-auth.readthedocs.io/en/latest/intro.html#auth-providers) to enable SSO authentication for users.
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#1337](https://github.com/netbox-community/netbox/issues/1337) - Add WWN field to interfaces
|
||||
* [#1943](https://github.com/netbox-community/netbox/issues/1943) - Relax uniqueness constraint on cluster names
|
||||
* [#3839](https://github.com/netbox-community/netbox/issues/3839) - Add `airflow` field for devices types and devices
|
||||
* [#6497](https://github.com/netbox-community/netbox/issues/6497) - Extend tag support to organizational models
|
||||
* [#6615](https://github.com/netbox-community/netbox/issues/6615) - Add filter lookups for custom fields
|
||||
* [#6711](https://github.com/netbox-community/netbox/issues/6711) - Add `longtext` custom field type with Markdown support
|
||||
* [#6715](https://github.com/netbox-community/netbox/issues/6715) - Add tenant assignment for cables
|
||||
* [#6874](https://github.com/netbox-community/netbox/issues/6874) - Add tenant assignment for locations
|
||||
|
@ -84,6 +84,7 @@ nav:
|
||||
- Using Plugins: 'plugins/index.md'
|
||||
- Developing Plugins: 'plugins/development.md'
|
||||
- Administration:
|
||||
- Authentication: 'administration/authentication.md'
|
||||
- Permissions: 'administration/permissions.md'
|
||||
- Housekeeping: 'administration/housekeeping.md'
|
||||
- Replicating NetBox: 'administration/replicating-netbox.md'
|
||||
|
@ -1,4 +1,3 @@
|
||||
from .fields import *
|
||||
from .models import *
|
||||
from .filtersets import *
|
||||
from .object_create import *
|
||||
|
@ -1,25 +0,0 @@
|
||||
from django import forms
|
||||
from netaddr import EUI
|
||||
from netaddr.core import AddrFormatError
|
||||
|
||||
__all__ = (
|
||||
'MACAddressField',
|
||||
)
|
||||
|
||||
|
||||
class MACAddressField(forms.Field):
|
||||
widget = forms.CharField
|
||||
default_error_messages = {
|
||||
'invalid': 'MAC address must be in EUI-48 format',
|
||||
}
|
||||
|
||||
def to_python(self, value):
|
||||
value = super().to_python(value)
|
||||
|
||||
# Validate MAC address format
|
||||
try:
|
||||
value = EUI(value.strip())
|
||||
except AddrFormatError:
|
||||
raise forms.ValidationError(self.error_messages['invalid'], code='invalid')
|
||||
|
||||
return value
|
@ -1,47 +1,11 @@
|
||||
import django_filters
|
||||
from django.forms import DateField, IntegerField, NullBooleanField
|
||||
|
||||
from .models import Tag
|
||||
from .choices import *
|
||||
|
||||
__all__ = (
|
||||
'CustomFieldFilter',
|
||||
'TagFilter',
|
||||
)
|
||||
|
||||
EXACT_FILTER_TYPES = (
|
||||
CustomFieldTypeChoices.TYPE_BOOLEAN,
|
||||
CustomFieldTypeChoices.TYPE_DATE,
|
||||
CustomFieldTypeChoices.TYPE_INTEGER,
|
||||
CustomFieldTypeChoices.TYPE_SELECT,
|
||||
CustomFieldTypeChoices.TYPE_MULTISELECT,
|
||||
)
|
||||
|
||||
|
||||
class CustomFieldFilter(django_filters.Filter):
|
||||
"""
|
||||
Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name.
|
||||
"""
|
||||
def __init__(self, custom_field, *args, **kwargs):
|
||||
self.custom_field = custom_field
|
||||
|
||||
if custom_field.type == CustomFieldTypeChoices.TYPE_INTEGER:
|
||||
self.field_class = IntegerField
|
||||
elif custom_field.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
|
||||
self.field_class = NullBooleanField
|
||||
elif custom_field.type == CustomFieldTypeChoices.TYPE_DATE:
|
||||
self.field_class = DateField
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.field_name = f'custom_field_data__{self.field_name}'
|
||||
|
||||
if custom_field.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
|
||||
self.lookup_expr = 'has_key'
|
||||
elif custom_field.type not in EXACT_FILTER_TYPES:
|
||||
if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE:
|
||||
self.lookup_expr = 'icontains'
|
||||
|
||||
|
||||
class TagFilter(django_filters.ModelMultipleChoiceFilter):
|
||||
"""
|
||||
|
@ -26,13 +26,6 @@ __all__ = (
|
||||
'WebhookFilterSet',
|
||||
)
|
||||
|
||||
EXACT_FILTER_TYPES = (
|
||||
CustomFieldTypeChoices.TYPE_BOOLEAN,
|
||||
CustomFieldTypeChoices.TYPE_DATE,
|
||||
CustomFieldTypeChoices.TYPE_INTEGER,
|
||||
CustomFieldTypeChoices.TYPE_SELECT,
|
||||
)
|
||||
|
||||
|
||||
class WebhookFilterSet(BaseFilterSet):
|
||||
content_types = ContentTypeFilter()
|
||||
|
@ -1,6 +1,7 @@
|
||||
import re
|
||||
from datetime import datetime, date
|
||||
|
||||
import django_filters
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
@ -12,6 +13,7 @@ from django.utils.safestring import mark_safe
|
||||
from extras.choices import *
|
||||
from extras.utils import FeatureQuery, extras_features
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from utilities import filters
|
||||
from utilities.forms import (
|
||||
CSVChoiceField, DatePicker, LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice,
|
||||
)
|
||||
@ -308,6 +310,58 @@ class CustomField(ChangeLoggedModel):
|
||||
|
||||
return field
|
||||
|
||||
def to_filter(self, lookup_expr=None):
|
||||
"""
|
||||
Return a django_filters Filter instance suitable for this field type.
|
||||
|
||||
:param lookup_expr: Custom lookup expression (optional)
|
||||
"""
|
||||
kwargs = {
|
||||
'field_name': f'custom_field_data__{self.name}'
|
||||
}
|
||||
if lookup_expr is not None:
|
||||
kwargs['lookup_expr'] = lookup_expr
|
||||
|
||||
# Text/URL
|
||||
if self.type in (
|
||||
CustomFieldTypeChoices.TYPE_TEXT,
|
||||
CustomFieldTypeChoices.TYPE_LONGTEXT,
|
||||
CustomFieldTypeChoices.TYPE_URL,
|
||||
):
|
||||
filter_class = filters.MultiValueCharFilter
|
||||
if self.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE:
|
||||
kwargs['lookup_expr'] = 'icontains'
|
||||
|
||||
# Integer
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
|
||||
filter_class = filters.MultiValueNumberFilter
|
||||
|
||||
# Boolean
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
|
||||
filter_class = django_filters.BooleanFilter
|
||||
|
||||
# Date
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_DATE:
|
||||
filter_class = filters.MultiValueDateFilter
|
||||
|
||||
# Select
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||
filter_class = filters.MultiValueCharFilter
|
||||
|
||||
# Multiselect
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
|
||||
filter_class = filters.MultiValueCharFilter
|
||||
kwargs['lookup_expr'] = 'has_key'
|
||||
|
||||
# Unsupported custom field type
|
||||
else:
|
||||
return None
|
||||
|
||||
filter_instance = filter_class(**kwargs)
|
||||
filter_instance.custom_field = self
|
||||
|
||||
return filter_instance
|
||||
|
||||
def validate(self, value):
|
||||
"""
|
||||
Validate a value according to the field's type validation rules.
|
||||
|
@ -719,7 +719,7 @@ class CustomFieldModelTest(TestCase):
|
||||
site.clean()
|
||||
|
||||
|
||||
class CustomFieldFilterTest(TestCase):
|
||||
class CustomFieldModelFilterTest(TestCase):
|
||||
queryset = Site.objects.all()
|
||||
filterset = SiteFilterSet
|
||||
|
||||
@ -772,7 +772,7 @@ class CustomFieldFilterTest(TestCase):
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
# Multiselect filtering
|
||||
cf = CustomField(name='cf9', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=['A', 'AA', 'B', 'C'])
|
||||
cf = CustomField(name='cf9', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=['A', 'B', 'C', 'X'])
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
@ -783,49 +783,88 @@ class CustomFieldFilterTest(TestCase):
|
||||
'cf3': 'foo',
|
||||
'cf4': 'foo',
|
||||
'cf5': '2016-06-26',
|
||||
'cf6': 'http://foo.example.com/',
|
||||
'cf7': 'http://foo.example.com/',
|
||||
'cf6': 'http://a.example.com',
|
||||
'cf7': 'http://a.example.com',
|
||||
'cf8': 'Foo',
|
||||
'cf9': ['A', 'B'],
|
||||
'cf9': ['A', 'X'],
|
||||
}),
|
||||
Site(name='Site 2', slug='site-2', custom_field_data={
|
||||
'cf1': 200,
|
||||
'cf2': False,
|
||||
'cf2': True,
|
||||
'cf3': 'foobar',
|
||||
'cf4': 'foobar',
|
||||
'cf5': '2016-06-27',
|
||||
'cf6': 'http://bar.example.com/',
|
||||
'cf7': 'http://bar.example.com/',
|
||||
'cf6': 'http://b.example.com',
|
||||
'cf7': 'http://b.example.com',
|
||||
'cf8': 'Bar',
|
||||
'cf9': ['AA', 'B'],
|
||||
'cf9': ['B', 'X'],
|
||||
}),
|
||||
Site(name='Site 3', slug='site-3', custom_field_data={
|
||||
'cf1': 300,
|
||||
'cf2': False,
|
||||
'cf3': 'bar',
|
||||
'cf4': 'bar',
|
||||
'cf5': '2016-06-28',
|
||||
'cf6': 'http://c.example.com',
|
||||
'cf7': 'http://c.example.com',
|
||||
'cf8': 'Baz',
|
||||
'cf9': ['C', 'X'],
|
||||
}),
|
||||
Site(name='Site 3', slug='site-3'),
|
||||
])
|
||||
|
||||
def test_filter_integer(self):
|
||||
self.assertEqual(self.filterset({'cf_cf1': 100}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf1': [100, 200]}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf1__n': [200]}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf1__gt': [200]}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf1__gte': [200]}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf1__lt': [200]}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf1__lte': [200]}, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_filter_boolean(self):
|
||||
self.assertEqual(self.filterset({'cf_cf2': True}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf2': True}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf2': False}, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_filter_text(self):
|
||||
self.assertEqual(self.filterset({'cf_cf3': 'foo'}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf4': 'foo'}, self.queryset).qs.count(), 2)
|
||||
def test_filter_text_strict(self):
|
||||
self.assertEqual(self.filterset({'cf_cf3': ['foo']}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf3__n': ['foo']}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf3__ic': ['foo']}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf3__nic': ['foo']}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf3__isw': ['foo']}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf3__nisw': ['foo']}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf3__iew': ['bar']}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf3__niew': ['bar']}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf3__ie': ['FOO']}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf3__nie': ['FOO']}, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_filter_text_loose(self):
|
||||
self.assertEqual(self.filterset({'cf_cf4': ['foo']}, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_filter_date(self):
|
||||
self.assertEqual(self.filterset({'cf_cf5': '2016-06-26'}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf5': ['2016-06-26', '2016-06-27']}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf5__n': ['2016-06-27']}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf5__gt': ['2016-06-27']}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf5__gte': ['2016-06-27']}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf5__lt': ['2016-06-27']}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf5__lte': ['2016-06-27']}, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_filter_url(self):
|
||||
self.assertEqual(self.filterset({'cf_cf6': 'http://foo.example.com/'}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf7': 'example.com'}, self.queryset).qs.count(), 2)
|
||||
def test_filter_url_strict(self):
|
||||
self.assertEqual(self.filterset({'cf_cf6': ['http://a.example.com', 'http://b.example.com']}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf6__n': ['http://b.example.com']}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf6__ic': ['b']}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf6__nic': ['b']}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf6__isw': ['http://']}, self.queryset).qs.count(), 3)
|
||||
self.assertEqual(self.filterset({'cf_cf6__nisw': ['http://']}, self.queryset).qs.count(), 0)
|
||||
self.assertEqual(self.filterset({'cf_cf6__iew': ['.com']}, self.queryset).qs.count(), 3)
|
||||
self.assertEqual(self.filterset({'cf_cf6__niew': ['.com']}, self.queryset).qs.count(), 0)
|
||||
self.assertEqual(self.filterset({'cf_cf6__ie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf6__nie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_filter_url_loose(self):
|
||||
self.assertEqual(self.filterset({'cf_cf7': ['example.com']}, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_filter_select(self):
|
||||
self.assertEqual(self.filterset({'cf_cf8': 'Foo'}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf8': 'Bar'}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf8': 'Baz'}, self.queryset).qs.count(), 0)
|
||||
self.assertEqual(self.filterset({'cf_cf8': ['Foo', 'Bar']}, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_filter_multiselect(self):
|
||||
self.assertEqual(self.filterset({'cf_cf9': 'A'}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf9': 'B'}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf9': 'C'}, self.queryset).qs.count(), 0)
|
||||
self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf9': ['X']}, self.queryset).qs.count(), 3)
|
||||
|
@ -2,19 +2,19 @@ import django_filters
|
||||
from copy import deepcopy
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django_filters.exceptions import FieldLookupError
|
||||
from django_filters.utils import get_model_field, resolve_field
|
||||
|
||||
from dcim.forms import MACAddressField
|
||||
from extras.choices import CustomFieldFilterLogicChoices
|
||||
from extras.filters import CustomFieldFilter, TagFilter
|
||||
from extras.filters import TagFilter
|
||||
from extras.models import CustomField
|
||||
from utilities.constants import (
|
||||
FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP,
|
||||
FILTER_NUMERIC_BASED_LOOKUP_MAP
|
||||
)
|
||||
from utilities.forms import MACAddressField
|
||||
from utilities import filters
|
||||
|
||||
|
||||
__all__ = (
|
||||
'BaseFilterSet',
|
||||
'ChangeLoggedModelFilterSet',
|
||||
@ -84,6 +84,7 @@ class BaseFilterSet(django_filters.FilterSet):
|
||||
def _get_filter_lookup_dict(existing_filter):
|
||||
# Choose the lookup expression map based on the filter type
|
||||
if isinstance(existing_filter, (
|
||||
django_filters.NumberFilter,
|
||||
filters.MultiValueDateFilter,
|
||||
filters.MultiValueDateTimeFilter,
|
||||
filters.MultiValueNumberFilter,
|
||||
@ -115,6 +116,63 @@ class BaseFilterSet(django_filters.FilterSet):
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_additional_lookups(cls, existing_filter_name, existing_filter):
|
||||
new_filters = {}
|
||||
|
||||
# Skip nonstandard lookup expressions
|
||||
if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'in']:
|
||||
return {}
|
||||
|
||||
# Choose the lookup expression map based on the filter type
|
||||
lookup_map = cls._get_filter_lookup_dict(existing_filter)
|
||||
if lookup_map is None:
|
||||
# Do not augment this filter type with more lookup expressions
|
||||
return {}
|
||||
|
||||
# Get properties of the existing filter for later use
|
||||
field_name = existing_filter.field_name
|
||||
field = get_model_field(cls._meta.model, field_name)
|
||||
|
||||
# Create new filters for each lookup expression in the map
|
||||
for lookup_name, lookup_expr in lookup_map.items():
|
||||
new_filter_name = f'{existing_filter_name}__{lookup_name}'
|
||||
|
||||
try:
|
||||
if existing_filter_name in cls.declared_filters:
|
||||
# The filter field has been explicitly defined on the filterset class so we must manually
|
||||
# create the new filter with the same type because there is no guarantee the defined type
|
||||
# is the same as the default type for the field
|
||||
resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid
|
||||
new_filter = type(existing_filter)(
|
||||
field_name=field_name,
|
||||
lookup_expr=lookup_expr,
|
||||
label=existing_filter.label,
|
||||
exclude=existing_filter.exclude,
|
||||
distinct=existing_filter.distinct,
|
||||
**existing_filter.extra
|
||||
)
|
||||
elif hasattr(existing_filter, 'custom_field'):
|
||||
# Filter is for a custom field
|
||||
custom_field = existing_filter.custom_field
|
||||
new_filter = custom_field.to_filter(lookup_expr=lookup_expr)
|
||||
else:
|
||||
# The filter field is listed in Meta.fields so we can safely rely on default behaviour
|
||||
# Will raise FieldLookupError if the lookup is invalid
|
||||
new_filter = cls.filter_for_field(field, field_name, lookup_expr)
|
||||
except FieldLookupError:
|
||||
# The filter could not be created because the lookup expression is not supported on the field
|
||||
continue
|
||||
|
||||
if lookup_name.startswith('n'):
|
||||
# This is a negation filter which requires a queryset.exclude() clause
|
||||
# Of course setting the negation of the existing filter's exclude attribute handles both cases
|
||||
new_filter.exclude = not existing_filter.exclude
|
||||
|
||||
new_filters[new_filter_name] = new_filter
|
||||
|
||||
return new_filters
|
||||
|
||||
@classmethod
|
||||
def get_filters(cls):
|
||||
"""
|
||||
@ -125,59 +183,12 @@ class BaseFilterSet(django_filters.FilterSet):
|
||||
"""
|
||||
filters = super().get_filters()
|
||||
|
||||
new_filters = {}
|
||||
additional_filters = {}
|
||||
for existing_filter_name, existing_filter in filters.items():
|
||||
# Loop over existing filters to extract metadata by which to create new filters
|
||||
additional_filters.update(cls.get_additional_lookups(existing_filter_name, existing_filter))
|
||||
|
||||
# If the filter makes use of a custom filter method or lookup expression skip it
|
||||
# as we cannot sanely handle these cases in a generic mannor
|
||||
if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'in']:
|
||||
continue
|
||||
filters.update(additional_filters)
|
||||
|
||||
# Choose the lookup expression map based on the filter type
|
||||
lookup_map = cls._get_filter_lookup_dict(existing_filter)
|
||||
if lookup_map is None:
|
||||
# Do not augment this filter type with more lookup expressions
|
||||
continue
|
||||
|
||||
# Get properties of the existing filter for later use
|
||||
field_name = existing_filter.field_name
|
||||
field = get_model_field(cls._meta.model, field_name)
|
||||
|
||||
# Create new filters for each lookup expression in the map
|
||||
for lookup_name, lookup_expr in lookup_map.items():
|
||||
new_filter_name = '{}__{}'.format(existing_filter_name, lookup_name)
|
||||
|
||||
try:
|
||||
if existing_filter_name in cls.declared_filters:
|
||||
# The filter field has been explicity defined on the filterset class so we must manually
|
||||
# create the new filter with the same type because there is no guarantee the defined type
|
||||
# is the same as the default type for the field
|
||||
resolve_field(field, lookup_expr)
|
||||
new_filter = type(existing_filter)(
|
||||
field_name=field_name,
|
||||
lookup_expr=lookup_expr,
|
||||
label=existing_filter.label,
|
||||
exclude=existing_filter.exclude,
|
||||
distinct=existing_filter.distinct,
|
||||
**existing_filter.extra
|
||||
)
|
||||
else:
|
||||
# The filter field is listed in Meta.fields so we can safely rely on default behaviour
|
||||
# Will raise FieldLookupError if the lookup is invalid
|
||||
new_filter = cls.filter_for_field(field, field_name, lookup_expr)
|
||||
except django_filters.exceptions.FieldLookupError:
|
||||
# The filter could not be created because the lookup expression is not supported on the field
|
||||
continue
|
||||
|
||||
if lookup_name.startswith('n'):
|
||||
# This is a negation filter which requires a queryset.exclude() clause
|
||||
# Of course setting the negation of the existing filter's exclude attribute handles both cases
|
||||
new_filter.exclude = not existing_filter.exclude
|
||||
|
||||
new_filters[new_filter_name] = new_filter
|
||||
|
||||
filters.update(new_filters)
|
||||
return filters
|
||||
|
||||
|
||||
@ -213,8 +224,19 @@ class PrimaryModelFilterSet(ChangeLoggedModelFilterSet):
|
||||
).exclude(
|
||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
|
||||
)
|
||||
for cf in custom_fields:
|
||||
self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(field_name=cf.name, custom_field=cf)
|
||||
|
||||
custom_field_filters = {}
|
||||
for custom_field in custom_fields:
|
||||
filter_name = f'cf_{custom_field.name}'
|
||||
filter_instance = custom_field.to_filter()
|
||||
if filter_instance:
|
||||
custom_field_filters[filter_name] = filter_instance
|
||||
|
||||
# Add relevant additional lookups
|
||||
additional_lookups = self.get_additional_lookups(filter_name, filter_instance)
|
||||
custom_field_filters.update(additional_lookups)
|
||||
|
||||
self.filters.update(custom_field_filters)
|
||||
|
||||
|
||||
class OrganizationalModelFilterSet(PrimaryModelFilterSet):
|
||||
|
@ -8,7 +8,6 @@ from django.contrib import auth
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db import ProgrammingError
|
||||
from django.http import Http404, HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
|
||||
from extras.context_managers import change_logging
|
||||
from netbox.config import clear_config
|
||||
@ -20,23 +19,15 @@ class LoginRequiredMiddleware:
|
||||
"""
|
||||
If LOGIN_REQUIRED is True, redirect all non-authenticated users to the login page.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
# Redirect unauthenticated requests (except those exempted) to the login page if LOGIN_REQUIRED is true
|
||||
if settings.LOGIN_REQUIRED and not request.user.is_authenticated:
|
||||
# Determine exempt paths
|
||||
exempt_paths = [
|
||||
reverse('api-root'),
|
||||
reverse('graphql'),
|
||||
]
|
||||
if settings.METRICS_ENABLED:
|
||||
exempt_paths.append(reverse('prometheus-django-metrics'))
|
||||
|
||||
# Redirect unauthenticated requests
|
||||
if not request.path_info.startswith(tuple(exempt_paths)) and request.path_info != settings.LOGIN_URL:
|
||||
if not request.path_info.startswith(settings.EXEMPT_PATHS):
|
||||
login_url = f'{settings.LOGIN_URL}?next={parse.quote(request.get_full_path_info())}'
|
||||
return HttpResponseRedirect(login_url)
|
||||
|
||||
|
@ -305,6 +305,7 @@ INSTALLED_APPS = [
|
||||
'graphene_django',
|
||||
'mptt',
|
||||
'rest_framework',
|
||||
'social_django',
|
||||
'taggit',
|
||||
'timezone_field',
|
||||
'circuits',
|
||||
@ -400,7 +401,8 @@ MESSAGE_TAGS = {
|
||||
}
|
||||
|
||||
# Authentication URLs
|
||||
LOGIN_URL = '/{}login/'.format(BASE_PATH)
|
||||
LOGIN_URL = f'/{BASE_PATH}login/'
|
||||
LOGIN_REDIRECT_URL = f'/{BASE_PATH}'
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
|
||||
|
||||
@ -414,6 +416,27 @@ EXEMPT_EXCLUDE_MODELS = (
|
||||
('users', 'objectpermission'),
|
||||
)
|
||||
|
||||
# All URLs starting with a string listed here are exempt from login enforcement
|
||||
EXEMPT_PATHS = (
|
||||
f'/{BASE_PATH}api/',
|
||||
f'/{BASE_PATH}graphql/',
|
||||
f'/{BASE_PATH}login/',
|
||||
f'/{BASE_PATH}oauth/',
|
||||
f'/{BASE_PATH}metrics/',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Django social auth
|
||||
#
|
||||
|
||||
# Load all SOCIAL_AUTH_* settings from the user configuration
|
||||
for param in dir(configuration):
|
||||
if param.startswith('SOCIAL_AUTH_'):
|
||||
globals()[param] = getattr(configuration, param)
|
||||
|
||||
SOCIAL_AUTH_JSONFIELD_ENABLED = True
|
||||
|
||||
|
||||
#
|
||||
# Django Prometheus
|
||||
|
@ -39,6 +39,7 @@ _patterns = [
|
||||
# Login/logout
|
||||
path('login/', LoginView.as_view(), name='login'),
|
||||
path('logout/', LogoutView.as_view(), name='logout'),
|
||||
path('oauth/', include('social_django.urls', namespace='social')),
|
||||
|
||||
# Apps
|
||||
path('circuits/', include('circuits.urls')),
|
||||
|
@ -39,6 +39,14 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# TODO: Improve the design & layout #}
|
||||
{% if auth_backends %}
|
||||
<h6 class="mt-4">Or use an SSO provider:</h6>
|
||||
{% for name, backend in auth_backends.items %}
|
||||
<h4><a href="{% url 'social:begin' backend=name %}" class="my-2">{{ name }}</a></h4>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{# Login form errors #}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
|
@ -61,7 +61,6 @@ urlpatterns = [
|
||||
path('contacts/edit/', views.ContactBulkEditView.as_view(), name='contact_bulk_edit'),
|
||||
path('contacts/delete/', views.ContactBulkDeleteView.as_view(), name='contact_bulk_delete'),
|
||||
path('contacts/<int:pk>/', views.ContactView.as_view(), name='contact'),
|
||||
path('contacts/<slug:slug>/', SlugRedirectView.as_view(), kwargs={'model': Contact}),
|
||||
path('contacts/<int:pk>/edit/', views.ContactEditView.as_view(), name='contact_edit'),
|
||||
path('contacts/<int:pk>/delete/', views.ContactDeleteView.as_view(), name='contact_delete'),
|
||||
path('contacts/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='contact_changelog', kwargs={'model': Contact}),
|
||||
|
@ -1,5 +1,6 @@
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
@ -12,6 +13,7 @@ from django.utils.decorators import method_decorator
|
||||
from django.utils.http import is_safe_url
|
||||
from django.views.decorators.debug import sensitive_post_parameters
|
||||
from django.views.generic import View
|
||||
from social_core.backends.utils import load_backends
|
||||
|
||||
from netbox.config import get_config
|
||||
from utilities.forms import ConfirmationForm
|
||||
@ -42,6 +44,7 @@ class LoginView(View):
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'form': form,
|
||||
'auth_backends': load_backends(settings.AUTHENTICATION_BACKENDS),
|
||||
})
|
||||
|
||||
def post(self, request):
|
||||
@ -69,13 +72,14 @@ class LoginView(View):
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'form': form,
|
||||
'auth_backends': load_backends(settings.AUTHENTICATION_BACKENDS),
|
||||
})
|
||||
|
||||
def redirect_to_next(self, request, logger):
|
||||
if request.method == "POST":
|
||||
redirect_to = request.POST.get('next', reverse('home'))
|
||||
redirect_to = request.POST.get('next', settings.LOGIN_REDIRECT_URL)
|
||||
else:
|
||||
redirect_to = request.GET.get('next', reverse('home'))
|
||||
redirect_to = request.GET.get('next', settings.LOGIN_REDIRECT_URL)
|
||||
|
||||
if redirect_to and not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()):
|
||||
logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_to}")
|
||||
|
@ -3,7 +3,7 @@ from django import forms
|
||||
from django.conf import settings
|
||||
from django_filters.constants import EMPTY_VALUES
|
||||
|
||||
from dcim.forms import MACAddressField
|
||||
from utilities.forms import MACAddressField
|
||||
|
||||
|
||||
def multivalue_field_factory(field_class):
|
||||
|
@ -2,6 +2,7 @@ import csv
|
||||
import json
|
||||
import re
|
||||
from io import StringIO
|
||||
from netaddr import AddrFormatError, EUI
|
||||
|
||||
import django_filters
|
||||
from django import forms
|
||||
@ -38,6 +39,7 @@ __all__ = (
|
||||
'ExpandableNameField',
|
||||
'JSONField',
|
||||
'LaxURLField',
|
||||
'MACAddressField',
|
||||
'SlugField',
|
||||
'TagFilterField',
|
||||
)
|
||||
@ -129,6 +131,28 @@ class JSONField(_JSONField):
|
||||
return json.dumps(value, sort_keys=True, indent=4)
|
||||
|
||||
|
||||
class MACAddressField(forms.Field):
|
||||
widget = forms.CharField
|
||||
default_error_messages = {
|
||||
'invalid': 'MAC address must be in EUI-48 format',
|
||||
}
|
||||
|
||||
def to_python(self, value):
|
||||
value = super().to_python(value)
|
||||
|
||||
# Validate MAC address format
|
||||
try:
|
||||
value = EUI(value.strip())
|
||||
except AddrFormatError:
|
||||
raise forms.ValidationError(self.error_messages['invalid'], code='invalid')
|
||||
|
||||
return value
|
||||
|
||||
|
||||
#
|
||||
# Content type fields
|
||||
#
|
||||
|
||||
class ContentTypeChoiceMixin:
|
||||
|
||||
def __init__(self, queryset, *args, **kwargs):
|
||||
|
@ -48,6 +48,9 @@ class Migration(migrations.Migration):
|
||||
('ssid', models.CharField(max_length=32)),
|
||||
('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='wireless_lans', to='wireless.wirelesslangroup')),
|
||||
('description', models.CharField(blank=True, max_length=200)),
|
||||
('auth_cipher', models.CharField(blank=True, max_length=50)),
|
||||
('auth_psk', models.CharField(blank=True, max_length=64)),
|
||||
('auth_type', models.CharField(blank=True, max_length=50)),
|
||||
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
|
||||
('vlan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='ipam.vlan')),
|
||||
],
|
||||
@ -66,6 +69,9 @@ class Migration(migrations.Migration):
|
||||
('ssid', models.CharField(blank=True, max_length=32)),
|
||||
('status', models.CharField(default='connected', max_length=50)),
|
||||
('description', models.CharField(blank=True, max_length=200)),
|
||||
('auth_cipher', models.CharField(blank=True, max_length=50)),
|
||||
('auth_psk', models.CharField(blank=True, max_length=64)),
|
||||
('auth_type', models.CharField(blank=True, max_length=50)),
|
||||
('_interface_a_device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='dcim.device')),
|
||||
('_interface_b_device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='dcim.device')),
|
||||
('interface_a', models.ForeignKey(limit_choices_to={'type__in': ['ieee802.11a', 'ieee802.11g', 'ieee802.11n', 'ieee802.11ac', 'ieee802.11ad', 'ieee802.11ax']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dcim.interface')),
|
||||
|
@ -1,41 +0,0 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wireless', '0001_wireless'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='wirelesslan',
|
||||
name='auth_cipher',
|
||||
field=models.CharField(blank=True, max_length=50),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='wirelesslan',
|
||||
name='auth_psk',
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='wirelesslan',
|
||||
name='auth_type',
|
||||
field=models.CharField(blank=True, max_length=50),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='wirelesslink',
|
||||
name='auth_cipher',
|
||||
field=models.CharField(blank=True, max_length=50),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='wirelesslink',
|
||||
name='auth_psk',
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='wirelesslink',
|
||||
name='auth_type',
|
||||
field=models.CharField(blank=True, max_length=50),
|
||||
),
|
||||
]
|
@ -23,6 +23,8 @@ netaddr==0.8.0
|
||||
Pillow==8.4.0
|
||||
psycopg2-binary==2.9.1
|
||||
PyYAML==6.0
|
||||
social-auth-app-django==5.0.0
|
||||
social-auth-core==4.1.0
|
||||
svgwrite==1.4.1
|
||||
tablib==3.0.0
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user