Skip to content

Commit 8d703ff

Browse files
Merge pull request #6730 from MaxRink/remote_groups
Remote groups via HTTP Headers
2 parents 574b57e + d5e5cdd commit 8d703ff

File tree

5 files changed

+382
-45
lines changed

5 files changed

+382
-45
lines changed

docs/configuration/optional-settings.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,14 @@ NetBox can be configured to support remote user authentication by inferring user
490490

491491
---
492492

493+
## REMOTE_AUTH_GROUP_SYNC_ENABLED
494+
495+
Default: `False`
496+
497+
NetBox can be configured to sync remote user groups by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.) (Requires `REMOTE_AUTH_ENABLED`.)
498+
499+
---
500+
493501
## REMOTE_AUTH_HEADER
494502

495503
Default: `'HTTP_REMOTE_USER'`
@@ -498,6 +506,54 @@ When remote user authentication is in use, this is the name of the HTTP header w
498506

499507
---
500508

509+
## REMOTE_AUTH_GROUP_HEADER
510+
511+
Default: `'HTTP_REMOTE_USER_GROUP'`
512+
513+
When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. For example, to use the request header `X-Remote-User-Groups` it needs to be set to `HTTP_X_REMOTE_USER_GROUPS`. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
514+
515+
---
516+
517+
## REMOTE_AUTH_SUPERUSER_GROUPS
518+
519+
Default: `[]` (Empty list)
520+
521+
The list of groups that promote an remote User to Superuser on Login. If group isn't present on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
522+
523+
---
524+
525+
## REMOTE_AUTH_SUPERUSERS
526+
527+
Default: `[]` (Empty list)
528+
529+
The list of users that get promoted to Superuser on Login. If user isn't present in list on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
530+
531+
---
532+
533+
## REMOTE_AUTH_STAFF_GROUPS
534+
535+
Default: `[]` (Empty list)
536+
537+
The list of groups that promote an remote User to Staff on Login. If group isn't present on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
538+
539+
---
540+
541+
## REMOTE_AUTH_STAFF_USERS
542+
543+
Default: `[]` (Empty list)
544+
545+
The list of users that get promoted to Staff on Login. If user isn't present in list on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
546+
547+
---
548+
549+
## REMOTE_AUTH_GROUP_SEPARATOR
550+
551+
Default: `|` (Pipe)
552+
553+
The Seperator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
554+
555+
---
556+
501557
## RELEASE_CHECK_URL
502558

503559
Default: None (disabled)

netbox/netbox/authentication.py

Lines changed: 133 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@
22
from collections import defaultdict
33

44
from django.conf import settings
5+
from django.contrib.auth import get_user_model
56
from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _RemoteUserBackend
6-
from django.contrib.auth.models import Group
7+
from django.contrib.auth.models import Group, AnonymousUser
78
from django.core.exceptions import ImproperlyConfigured
89
from django.db.models import Q
910

1011
from users.models import ObjectPermission
1112
from utilities.permissions import permission_is_exempt, resolve_permission, resolve_permission_ct
1213

14+
UserModel = get_user_model()
15+
1316

1417
class ObjectPermissionMixin():
1518

@@ -101,38 +104,145 @@ class RemoteUserBackend(_RemoteUserBackend):
101104
def create_unknown_user(self):
102105
return settings.REMOTE_AUTH_AUTO_CREATE_USER
103106

104-
def configure_user(self, request, user):
107+
def configure_groups(self, user, remote_groups):
105108
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
106109

107110
# Assign default groups to the user
108111
group_list = []
109-
for name in settings.REMOTE_AUTH_DEFAULT_GROUPS:
112+
for name in remote_groups:
110113
try:
111114
group_list.append(Group.objects.get(name=name))
112115
except Group.DoesNotExist:
113-
logging.error(f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
116+
logging.error(
117+
f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
114118
if group_list:
115-
user.groups.add(*group_list)
116-
logger.debug(f"Assigned groups to remotely-authenticated user {user}: {group_list}")
119+
user.groups.set(group_list)
120+
logger.debug(
121+
f"Assigned groups to remotely-authenticated user {user}: {group_list}")
122+
else:
123+
user.groups.clear()
124+
logger.debug(f"Stripping user {user} from Groups")
125+
user.is_superuser = self._is_superuser(user)
126+
logger.debug(f"User {user} is Superuser: {user.is_superuser}")
127+
logger.debug(
128+
f"User {user} should be Superuser: {self._is_superuser(user)}")
129+
130+
user.is_staff = self._is_staff(user)
131+
logger.debug(f"User {user} is Staff: {user.is_staff}")
132+
logger.debug(f"User {user} should be Staff: {self._is_staff(user)}")
133+
user.save()
134+
return user
117135

118-
# Assign default object permissions to the user
119-
permissions_list = []
120-
for permission_name, constraints in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS.items():
136+
def authenticate(self, request, remote_user, remote_groups=None):
137+
"""
138+
The username passed as ``remote_user`` is considered trusted. Return
139+
the ``User`` object with the given username. Create a new ``User``
140+
object if ``create_unknown_user`` is ``True``.
141+
Return None if ``create_unknown_user`` is ``False`` and a ``User``
142+
object with the given username is not found in the database.
143+
"""
144+
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
145+
logger.debug(
146+
f"trying to authenticate {remote_user} with groups {remote_groups}")
147+
if not remote_user:
148+
return
149+
user = None
150+
username = self.clean_username(remote_user)
151+
152+
# Note that this could be accomplished in one try-except clause, but
153+
# instead we use get_or_create when creating unknown users since it has
154+
# built-in safeguards for multiple threads.
155+
if self.create_unknown_user:
156+
user, created = UserModel._default_manager.get_or_create(**{
157+
UserModel.USERNAME_FIELD: username
158+
})
159+
if created:
160+
user = self.configure_user(request, user)
161+
else:
121162
try:
122-
object_type, action = resolve_permission_ct(permission_name)
123-
# TODO: Merge multiple actions into a single ObjectPermission per content type
124-
obj_perm = ObjectPermission(actions=[action], constraints=constraints)
125-
obj_perm.save()
126-
obj_perm.users.add(user)
127-
obj_perm.object_types.add(object_type)
128-
permissions_list.append(permission_name)
129-
except ValueError:
130-
logging.error(
131-
f"Invalid permission name: '{permission_name}'. Permissions must be in the form "
132-
"<app>.<action>_<model>. (Example: dcim.add_site)"
133-
)
134-
if permissions_list:
135-
logger.debug(f"Assigned permissions to remotely-authenticated user {user}: {permissions_list}")
163+
user = UserModel._default_manager.get_by_natural_key(username)
164+
except UserModel.DoesNotExist:
165+
pass
166+
if self.user_can_authenticate(user):
167+
if settings.REMOTE_AUTH_GROUP_SYNC_ENABLED:
168+
if user is not None and not isinstance(user, AnonymousUser):
169+
return self.configure_groups(user, remote_groups)
170+
else:
171+
return user
172+
else:
173+
return None
174+
175+
def _is_superuser(self, user):
176+
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
177+
superuser_groups = settings.REMOTE_AUTH_SUPERUSER_GROUPS
178+
logger.debug(f"Superuser Groups: {superuser_groups}")
179+
superusers = settings.REMOTE_AUTH_SUPERUSERS
180+
logger.debug(f"Superuser Users: {superusers}")
181+
user_groups = set()
182+
for g in user.groups.all():
183+
user_groups.add(g.name)
184+
logger.debug(f"User {user.username} is in Groups:{user_groups}")
185+
186+
result = user.username in superusers or (
187+
set(user_groups) & set(superuser_groups))
188+
logger.debug(f"User {user.username} in Superuser Users :{result}")
189+
return bool(result)
190+
191+
def _is_staff(self, user):
192+
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
193+
staff_groups = settings.REMOTE_AUTH_STAFF_GROUPS
194+
logger.debug(f"Superuser Groups: {staff_groups}")
195+
staff_users = settings.REMOTE_AUTH_STAFF_USERS
196+
logger.debug(f"Staff Users :{staff_users}")
197+
user_groups = set()
198+
for g in user.groups.all():
199+
user_groups.add(g.name)
200+
logger.debug(f"User {user.username} is in Groups:{user_groups}")
201+
result = user.username in staff_users or (
202+
set(user_groups) & set(staff_groups))
203+
logger.debug(f"User {user.username} in Staff Users :{result}")
204+
return bool(result)
205+
206+
def configure_user(self, request, user):
207+
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
208+
if not settings.REMOTE_AUTH_GROUP_SYNC_ENABLED:
209+
# Assign default groups to the user
210+
group_list = []
211+
for name in settings.REMOTE_AUTH_DEFAULT_GROUPS:
212+
try:
213+
group_list.append(Group.objects.get(name=name))
214+
except Group.DoesNotExist:
215+
logging.error(
216+
f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
217+
if group_list:
218+
user.groups.add(*group_list)
219+
logger.debug(
220+
f"Assigned groups to remotely-authenticated user {user}: {group_list}")
221+
222+
# Assign default object permissions to the user
223+
permissions_list = []
224+
for permission_name, constraints in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS.items():
225+
try:
226+
object_type, action = resolve_permission_ct(
227+
permission_name)
228+
# TODO: Merge multiple actions into a single ObjectPermission per content type
229+
obj_perm = ObjectPermission(
230+
actions=[action], constraints=constraints)
231+
obj_perm.save()
232+
obj_perm.users.add(user)
233+
obj_perm.object_types.add(object_type)
234+
permissions_list.append(permission_name)
235+
except ValueError:
236+
logging.error(
237+
f"Invalid permission name: '{permission_name}'. Permissions must be in the form "
238+
"<app>.<action>_<model>. (Example: dcim.add_site)"
239+
)
240+
if permissions_list:
241+
logger.debug(
242+
f"Assigned permissions to remotely-authenticated user {user}: {permissions_list}")
243+
else:
244+
logger.debug(
245+
f"Skipped initial assignment of permissions and groups to remotely-authenticated user {user} as Group sync is enabled")
136246

137247
return user
138248

netbox/netbox/middleware.py

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import uuid
22
from urllib import parse
3+
import logging
34

45
from django.conf import settings
56
from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
7+
from django.contrib import auth
8+
from django.core.exceptions import ImproperlyConfigured
69
from django.db import ProgrammingError
710
from django.http import Http404, HttpResponseRedirect
811
from django.urls import reverse
@@ -16,6 +19,7 @@ class LoginRequiredMiddleware(object):
1619
"""
1720
If LOGIN_REQUIRED is True, redirect all non-authenticated users to the login page.
1821
"""
22+
1923
def __init__(self, get_response):
2024
self.get_response = get_response
2125

@@ -49,12 +53,65 @@ def header(self):
4953
return settings.REMOTE_AUTH_HEADER
5054

5155
def process_request(self, request):
52-
56+
logger = logging.getLogger(
57+
'netbox.authentication.RemoteUserMiddleware')
5358
# Bypass middleware if remote authentication is not enabled
5459
if not settings.REMOTE_AUTH_ENABLED:
5560
return
56-
57-
return super().process_request(request)
61+
# AuthenticationMiddleware is required so that request.user exists.
62+
if not hasattr(request, 'user'):
63+
raise ImproperlyConfigured(
64+
"The Django remote user auth middleware requires the"
65+
" authentication middleware to be installed. Edit your"
66+
" MIDDLEWARE setting to insert"
67+
" 'django.contrib.auth.middleware.AuthenticationMiddleware'"
68+
" before the RemoteUserMiddleware class.")
69+
try:
70+
username = request.META[self.header]
71+
except KeyError:
72+
# If specified header doesn't exist then remove any existing
73+
# authenticated remote-user, or return (leaving request.user set to
74+
# AnonymousUser by the AuthenticationMiddleware).
75+
if self.force_logout_if_no_header and request.user.is_authenticated:
76+
self._remove_invalid_user(request)
77+
return
78+
# If the user is already authenticated and that user is the user we are
79+
# getting passed in the headers, then the correct user is already
80+
# persisted in the session and we don't need to continue.
81+
if request.user.is_authenticated:
82+
if request.user.get_username() == self.clean_username(username, request):
83+
return
84+
else:
85+
# An authenticated user is associated with the request, but
86+
# it does not match the authorized user in the header.
87+
self._remove_invalid_user(request)
88+
89+
# We are seeing this user for the first time in this session, attempt
90+
# to authenticate the user.
91+
if settings.REMOTE_AUTH_GROUP_SYNC_ENABLED:
92+
logger.debug("Trying to sync Groups")
93+
user = auth.authenticate(
94+
request, remote_user=username, remote_groups=self._get_groups(request))
95+
else:
96+
user = auth.authenticate(request, remote_user=username)
97+
if user:
98+
# User is valid. Set request.user and persist user in the session
99+
# by logging the user in.
100+
request.user = user
101+
auth.login(request, user)
102+
103+
def _get_groups(self, request):
104+
logger = logging.getLogger(
105+
'netbox.authentication.RemoteUserMiddleware')
106+
107+
groups_string = request.META.get(
108+
settings.REMOTE_AUTH_GROUP_HEADER, None)
109+
if groups_string:
110+
groups = groups_string.split(settings.REMOTE_AUTH_GROUP_SEPARATOR)
111+
else:
112+
groups = []
113+
logger.debug(f"Groups are {groups}")
114+
return groups
58115

59116

60117
class ObjectChangeMiddleware(object):
@@ -71,6 +128,7 @@ class ObjectChangeMiddleware(object):
71128
have been created. Conversely, deletions are acted upon immediately, so that the serialized representation of the
72129
object is recorded before it (and any related objects) are actually deleted from the database.
73130
"""
131+
74132
def __init__(self, get_response):
75133
self.get_response = get_response
76134

@@ -90,6 +148,7 @@ class APIVersionMiddleware(object):
90148
"""
91149
If the request is for an API endpoint, include the API version as a response header.
92150
"""
151+
93152
def __init__(self, get_response):
94153
self.get_response = get_response
95154

@@ -105,6 +164,7 @@ class ExceptionHandlingMiddleware(object):
105164
Intercept certain exceptions which are likely indicative of installation issues and provide helpful instructions
106165
to the user.
107166
"""
167+
108168
def __init__(self, get_response):
109169
self.get_response = get_response
110170

netbox/netbox/settings.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,13 @@
120120
REMOTE_AUTH_DEFAULT_PERMISSIONS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_PERMISSIONS', {})
121121
REMOTE_AUTH_ENABLED = getattr(configuration, 'REMOTE_AUTH_ENABLED', False)
122122
REMOTE_AUTH_HEADER = getattr(configuration, 'REMOTE_AUTH_HEADER', 'HTTP_REMOTE_USER')
123+
REMOTE_AUTH_GROUP_HEADER = getattr(configuration, 'REMOTE_AUTH_GROUP_HEADER', 'HTTP_REMOTE_USER_GROUP')
124+
REMOTE_AUTH_GROUP_SYNC_ENABLED = getattr(configuration, 'REMOTE_AUTH_GROUP_SYNC_ENABLED', False)
125+
REMOTE_AUTH_SUPERUSER_GROUPS = getattr(configuration, 'REMOTE_AUTH_SUPERUSER_GROUPS', [])
126+
REMOTE_AUTH_SUPERUSERS = getattr(configuration, 'REMOTE_AUTH_SUPERUSERS', [])
127+
REMOTE_AUTH_STAFF_GROUPS = getattr(configuration, 'REMOTE_AUTH_STAFF_GROUPS', [])
128+
REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', [])
129+
REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|')
123130
RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
124131
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
125132
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)

0 commit comments

Comments
 (0)