From 07f68ae5790784ce9dd79680ca74184acbfb7c35 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Sun, 30 Jul 2023 15:04:58 -0400 Subject: [PATCH] Closes #13038: Establish `DEFAULT_PERMISSIONS` config parameter (#13308) * Introduce the DEFAULT_PERMISSIONS config parameter * Establish default permissions for user token management --- docs/administration/permissions.md | 7 ++- docs/configuration/security.md | 32 ++++++++++ netbox/netbox/authentication.py | 13 +++- netbox/netbox/configuration_testing.py | 2 + netbox/netbox/settings.py | 7 +++ netbox/users/account_urls.py | 7 ++- netbox/users/api/views.py | 13 +--- netbox/users/tables.py | 11 +--- netbox/users/tests/test_api.py | 30 ++++++--- netbox/users/views.py | 85 ++++---------------------- 10 files changed, 99 insertions(+), 108 deletions(-) diff --git a/docs/administration/permissions.md b/docs/administration/permissions.md index 48abd5443..95f0f0c05 100644 --- a/docs/administration/permissions.md +++ b/docs/administration/permissions.md @@ -68,8 +68,13 @@ When defining a permission constraint, administrators may use the special token The `$user` token can be used only as a constraint value, or as an item within a list of values. It cannot be modified or extended to reference specific user attributes. +### Default Permissions -#### Example Constraint Definitions +!!! info "This feature was introduced in NetBox v3.6." + +While permissions are typically assigned to specific groups and/or users, it is also possible to define a set of default permissions that are applied to _all_ authenticated users. This is done using the [`DEFAULT_PERMISSIONS`](../configuration/security.md#default_permissions) configuration parameter. Note that statically configuring permissions for specific users or groups is **not** supported. + +### Example Constraint Definitions | Constraints | Description | | ----------- | ----------- | diff --git a/docs/configuration/security.md b/docs/configuration/security.md index 596de1461..a6dc4c5ce 100644 --- a/docs/configuration/security.md +++ b/docs/configuration/security.md @@ -90,6 +90,38 @@ CSRF_TRUSTED_ORIGINS = ( --- +## DEFAULT_PERMISSIONS + +!!! info "This parameter was introduced in NetBox v3.6." + +Default: + +```python +{ + 'users.view_token': ({'user': '$user'},), + 'users.add_token': ({'user': '$user'},), + 'users.change_token': ({'user': '$user'},), + 'users.delete_token': ({'user': '$user'},), +} +``` + +This parameter defines object permissions that are applied automatically to _any_ authenticated user, regardless of what permissions have been defined in the database. By default, this parameter is defined to allow all users to manage their own API tokens, however it can be overriden for any purpose. + +For example, to allow all users to create a device role beginning with the word "temp," you could configure the following: + +```python +DEFAULT_PERMISSIONS = { + 'dcim.add_devicerole': ( + {'name__startswith': 'temp'}, + ) +} +``` + +!!! warning + Setting a custom value for this parameter will overwrite the default permission mapping shown above. If you want to retain the default mapping, be sure to reproduce it in your custom configuration. + +--- + ## EXEMPT_VIEW_PERMISSIONS Default: Empty list diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index e4f9b42c2..1b0d50ca5 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -76,6 +76,18 @@ class ObjectPermissionMixin: """ Return all permissions granted to the user by an ObjectPermission. """ + # Initialize a dictionary mapping permission names to sets of constraints + perms = defaultdict(list) + + # Collect any configured default permissions + for perm_name, constraints in settings.DEFAULT_PERMISSIONS.items(): + constraints = constraints or tuple() + if type(constraints) not in (list, tuple): + raise ImproperlyConfigured( + f"Constraints for default permission {perm_name} must be defined as a list or tuple." + ) + perms[perm_name].extend(constraints) + # Retrieve all assigned and enabled ObjectPermissions object_permissions = ObjectPermission.objects.filter( self.get_permission_filter(user_obj), @@ -83,7 +95,6 @@ class ObjectPermissionMixin: ).order_by('id').distinct('id').prefetch_related('object_types') # Create a dictionary mapping permissions to their constraints - perms = defaultdict(list) for obj_perm in object_permissions: for object_type in obj_perm.object_types.all(): for action in obj_perm.actions: diff --git a/netbox/netbox/configuration_testing.py b/netbox/netbox/configuration_testing.py index 26d768004..18a3c2afa 100644 --- a/netbox/netbox/configuration_testing.py +++ b/netbox/netbox/configuration_testing.py @@ -39,6 +39,8 @@ REDIS = { SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' +DEFAULT_PERMISSIONS = {} + LOGGING = { 'version': 1, 'disable_existing_loggers': True diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 13b2bae7d..70441988e 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -99,6 +99,13 @@ DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y') DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a') DEBUG = getattr(configuration, 'DEBUG', False) DEFAULT_DASHBOARD = getattr(configuration, 'DEFAULT_DASHBOARD', None) +DEFAULT_PERMISSIONS = getattr(configuration, 'DEFAULT_PERMISSIONS', { + # Permit users to manage their own API tokens + 'users.view_token': ({'user': '$user'},), + 'users.add_token': ({'user': '$user'},), + 'users.change_token': ({'user': '$user'},), + 'users.delete_token': ({'user': '$user'},), +}) DEVELOPER = getattr(configuration, 'DEVELOPER', False) DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs')) EMAIL = getattr(configuration, 'EMAIL', {}) diff --git a/netbox/users/account_urls.py b/netbox/users/account_urls.py index bcb031003..4c65a2a42 100644 --- a/netbox/users/account_urls.py +++ b/netbox/users/account_urls.py @@ -1,6 +1,5 @@ -from django.urls import include, path +from django.urls import path -from utilities.urls import get_model_urls from . import views app_name = 'account' @@ -13,6 +12,8 @@ urlpatterns = [ path('password/', views.ChangePasswordView.as_view(), name='change_password'), path('api-tokens/', views.UserTokenListView.as_view(), name='usertoken_list'), path('api-tokens/add/', views.UserTokenEditView.as_view(), name='usertoken_add'), - path('api-tokens//', include(get_model_urls('users', 'usertoken'))), + path('api-tokens//', views.UserTokenView.as_view(), name='usertoken'), + path('api-tokens//edit/', views.UserTokenEditView.as_view(), name='usertoken_edit'), + path('api-tokens//delete/', views.UserTokenDeleteView.as_view(), name='usertoken_delete'), ] diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py index 4a8e1b154..c827089f1 100644 --- a/netbox/users/api/views.py +++ b/netbox/users/api/views.py @@ -49,21 +49,10 @@ class GroupViewSet(NetBoxModelViewSet): # class TokenViewSet(NetBoxModelViewSet): - queryset = RestrictedQuerySet(model=Token).prefetch_related('user') + queryset = Token.objects.prefetch_related('user') serializer_class = serializers.TokenSerializer filterset_class = filtersets.TokenFilterSet - def get_queryset(self): - """ - Limit the non-superusers to their own Tokens. - """ - queryset = super().get_queryset() - if not self.request.user.is_authenticated: - return queryset.none() - if self.request.user.is_superuser: - return queryset - return queryset.filter(user=self.request.user) - class TokenProvisionView(APIView): """ diff --git a/netbox/users/tables.py b/netbox/users/tables.py index 3ef885399..8069897b9 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -23,14 +23,6 @@ COPY_BUTTON = """ """ -class TokenActionsColumn(columns.ActionsColumn): - # Subclass ActionsColumn to disregard permissions for edit & delete buttons - actions = { - 'edit': columns.ActionsItem('Edit', 'pencil', None, 'warning'), - 'delete': columns.ActionsItem('Delete', 'trash-can-outline', None, 'danger'), - } - - class UserTokenTable(NetBoxTable): """ Table for users to manager their own API tokens under account views. @@ -55,7 +47,8 @@ class UserTokenTable(NetBoxTable): verbose_name=_('Allowed IPs'), template_code=ALLOWED_IPS ) - actions = TokenActionsColumn( + # TODO: Fix permissions evaluation & viewname resolution + actions = columns.ActionsColumn( actions=('edit', 'delete'), extra_buttons=COPY_BUTTON ) diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py index 2de243775..859dd0b83 100644 --- a/netbox/users/tests/test_api.py +++ b/netbox/users/tests/test_api.py @@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType from django.urls import reverse from users.models import ObjectPermission, Token -from utilities.testing import APIViewTestCases, APITestCase +from utilities.testing import APIViewTestCases, APITestCase, create_test_user from utilities.utils import deepmerge @@ -105,24 +105,35 @@ class TokenTest( def setUp(self): super().setUp() + # Apply grant_token permission to enable the creation of Tokens for other Users + self.add_permissions('users.grant_token') + + @classmethod + def setUpTestData(cls): + users = ( + create_test_user('User 1'), + create_test_user('User 2'), + create_test_user('User 3'), + ) + tokens = ( - # We already start with one Token, created by the test class - Token(user=self.user), - Token(user=self.user), + Token(user=users[0]), + Token(user=users[1]), + Token(user=users[2]), ) # Use save() instead of bulk_create() to ensure keys get automatically generated for token in tokens: token.save() - self.create_data = [ + cls.create_data = [ { - 'user': self.user.pk, + 'user': users[0].pk, }, { - 'user': self.user.pk, + 'user': users[1].pk, }, { - 'user': self.user.pk, + 'user': users[2].pk, }, ] @@ -161,6 +172,9 @@ class TokenTest( """ Test provisioning a Token for a different User with & without the grant_token permission. """ + # Clear grant_token permission assigned by setUpTestData + ObjectPermission.objects.filter(users=self.user).delete() + self.add_permissions('users.add_token') user2 = User.objects.create_user(username='testuser2') data = { diff --git a/netbox/users/views.py b/netbox/users/views.py index 3796d9af1..62ce65588 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -279,83 +279,20 @@ class UserTokenView(LoginRequiredMixin, View): }) -@register_model_view(UserToken, 'edit') -class UserTokenEditView(LoginRequiredMixin, View): +class UserTokenEditView(generic.ObjectEditView): + queryset = UserToken.objects.all() + form = forms.UserTokenForm + default_return_url = 'account:usertoken_list' - def get(self, request, pk=None): - if pk: - token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk) - else: - token = UserToken(user=request.user) - form = forms.UserTokenForm(instance=token) - - return render(request, 'generic/object_edit.html', { - 'object': token, - 'form': form, - 'return_url': reverse('account:usertoken_list'), - }) - - def post(self, request, pk=None): - if pk: - token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk) - form = forms.UserTokenForm(request.POST, instance=token) - else: - token = UserToken(user=request.user) - form = forms.UserTokenForm(request.POST) - - if form.is_valid(): - token = form.save(commit=False) - token.user = request.user - token.save() - - msg = f"Modified token {token}" if pk else f"Created token {token}" - messages.success(request, msg) - - if not pk and not settings.ALLOW_TOKEN_RETRIEVAL: - return render(request, 'users/account/token.html', { - 'object': token, - 'key': token.key, - 'return_url': reverse('users:token_list'), - }) - elif '_addanother' in request.POST: - return redirect(request.path) - else: - return redirect('account:usertoken_list') - - return render(request, 'generic/object_edit.html', { - 'object': token, - 'form': form, - 'return_url': reverse('account:usertoken_list'), - 'disable_addanother': not settings.ALLOW_TOKEN_RETRIEVAL - }) + def alter_object(self, obj, request, url_args, url_kwargs): + if not obj.pk: + obj.user = request.user + return obj -@register_model_view(UserToken, 'delete') -class UserTokenDeleteView(LoginRequiredMixin, View): - - def get(self, request, pk): - token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk) - - return render(request, 'generic/object_delete.html', { - 'object': token, - 'form': ConfirmationForm(), - 'return_url': reverse('account:usertoken_list'), - }) - - def post(self, request, pk): - token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk) - form = ConfirmationForm(request.POST) - - if form.is_valid(): - token.delete() - messages.success(request, "Token deleted") - return redirect('account:usertoken_list') - - return render(request, 'generic/object_delete.html', { - 'object': token, - 'form': form, - 'return_url': reverse('account:usertoken_list'), - }) +class UserTokenDeleteView(generic.ObjectDeleteView): + queryset = UserToken.objects.all() + default_return_url = 'account:usertoken_list' #