Forráskód Böngészése

Closes #13038: Establish `DEFAULT_PERMISSIONS` config parameter (#13308)

* Introduce the DEFAULT_PERMISSIONS config parameter

* Establish default permissions for user token management
Jeremy Stretch 2 éve
szülő
commit
07f68ae579

+ 6 - 1
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.
 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 |
 | Constraints | Description |
 | ----------- | ----------- |
 | ----------- | ----------- |

+ 32 - 0
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
 ## EXEMPT_VIEW_PERMISSIONS
 
 
 Default: Empty list
 Default: Empty list

+ 12 - 1
netbox/netbox/authentication.py

@@ -76,6 +76,18 @@ class ObjectPermissionMixin:
         """
         """
         Return all permissions granted to the user by an ObjectPermission.
         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
         # Retrieve all assigned and enabled ObjectPermissions
         object_permissions = ObjectPermission.objects.filter(
         object_permissions = ObjectPermission.objects.filter(
             self.get_permission_filter(user_obj),
             self.get_permission_filter(user_obj),
@@ -83,7 +95,6 @@ class ObjectPermissionMixin:
         ).order_by('id').distinct('id').prefetch_related('object_types')
         ).order_by('id').distinct('id').prefetch_related('object_types')
 
 
         # Create a dictionary mapping permissions to their constraints
         # Create a dictionary mapping permissions to their constraints
-        perms = defaultdict(list)
         for obj_perm in object_permissions:
         for obj_perm in object_permissions:
             for object_type in obj_perm.object_types.all():
             for object_type in obj_perm.object_types.all():
                 for action in obj_perm.actions:
                 for action in obj_perm.actions:

+ 2 - 0
netbox/netbox/configuration_testing.py

@@ -39,6 +39,8 @@ REDIS = {
 
 
 SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
 SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
 
 
+DEFAULT_PERMISSIONS = {}
+
 LOGGING = {
 LOGGING = {
     'version': 1,
     'version': 1,
     'disable_existing_loggers': True
     'disable_existing_loggers': True

+ 7 - 0
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')
 DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
 DEBUG = getattr(configuration, 'DEBUG', False)
 DEBUG = getattr(configuration, 'DEBUG', False)
 DEFAULT_DASHBOARD = getattr(configuration, 'DEFAULT_DASHBOARD', None)
 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)
 DEVELOPER = getattr(configuration, 'DEVELOPER', False)
 DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs'))
 DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs'))
 EMAIL = getattr(configuration, 'EMAIL', {})
 EMAIL = getattr(configuration, 'EMAIL', {})

+ 4 - 3
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
 from . import views
 
 
 app_name = 'account'
 app_name = 'account'
@@ -13,6 +12,8 @@ urlpatterns = [
     path('password/', views.ChangePasswordView.as_view(), name='change_password'),
     path('password/', views.ChangePasswordView.as_view(), name='change_password'),
     path('api-tokens/', views.UserTokenListView.as_view(), name='usertoken_list'),
     path('api-tokens/', views.UserTokenListView.as_view(), name='usertoken_list'),
     path('api-tokens/add/', views.UserTokenEditView.as_view(), name='usertoken_add'),
     path('api-tokens/add/', views.UserTokenEditView.as_view(), name='usertoken_add'),
-    path('api-tokens/<int:pk>/', include(get_model_urls('users', 'usertoken'))),
+    path('api-tokens/<int:pk>/', views.UserTokenView.as_view(), name='usertoken'),
+    path('api-tokens/<int:pk>/edit/', views.UserTokenEditView.as_view(), name='usertoken_edit'),
+    path('api-tokens/<int:pk>/delete/', views.UserTokenDeleteView.as_view(), name='usertoken_delete'),
 
 
 ]
 ]

+ 1 - 12
netbox/users/api/views.py

@@ -49,21 +49,10 @@ class GroupViewSet(NetBoxModelViewSet):
 #
 #
 
 
 class TokenViewSet(NetBoxModelViewSet):
 class TokenViewSet(NetBoxModelViewSet):
-    queryset = RestrictedQuerySet(model=Token).prefetch_related('user')
+    queryset = Token.objects.prefetch_related('user')
     serializer_class = serializers.TokenSerializer
     serializer_class = serializers.TokenSerializer
     filterset_class = filtersets.TokenFilterSet
     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):
 class TokenProvisionView(APIView):
     """
     """

+ 2 - 9
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):
 class UserTokenTable(NetBoxTable):
     """
     """
     Table for users to manager their own API tokens under account views.
     Table for users to manager their own API tokens under account views.
@@ -55,7 +47,8 @@ class UserTokenTable(NetBoxTable):
         verbose_name=_('Allowed IPs'),
         verbose_name=_('Allowed IPs'),
         template_code=ALLOWED_IPS
         template_code=ALLOWED_IPS
     )
     )
-    actions = TokenActionsColumn(
+    # TODO: Fix permissions evaluation & viewname resolution
+    actions = columns.ActionsColumn(
         actions=('edit', 'delete'),
         actions=('edit', 'delete'),
         extra_buttons=COPY_BUTTON
         extra_buttons=COPY_BUTTON
     )
     )

+ 22 - 8
netbox/users/tests/test_api.py

@@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
 from django.urls import reverse
 
 
 from users.models import ObjectPermission, Token
 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
 from utilities.utils import deepmerge
 
 
 
 
@@ -105,24 +105,35 @@ class TokenTest(
     def setUp(self):
     def setUp(self):
         super().setUp()
         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 = (
         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
         # Use save() instead of bulk_create() to ensure keys get automatically generated
         for token in tokens:
         for token in tokens:
             token.save()
             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.
         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')
         self.add_permissions('users.add_token')
         user2 = User.objects.create_user(username='testuser2')
         user2 = User.objects.create_user(username='testuser2')
         data = {
         data = {

+ 11 - 74
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'
 
 
 
 
 #
 #