Establish default permissions for user token management

This commit is contained in:
Jeremy Stretch 2023-07-30 12:33:46 -04:00
parent c734889195
commit 52241fd81f
8 changed files with 65 additions and 109 deletions

View File

@ -94,9 +94,20 @@ CSRF_TRUSTED_ORIGINS = (
!!! info "This parameter was introduced in NetBox v3.6." !!! info "This parameter was introduced in NetBox v3.6."
Default: None Default:
This parameter defines object permissions that are applied automatically to _any_ authenticated user, regardless of what permissions have been defined in the database. For example, to allow all users to create a device role beginning with the word "temp," you could configure the following: ```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 ```python
DEFAULT_PERMISSIONS = { DEFAULT_PERMISSIONS = {
@ -106,6 +117,9 @@ DEFAULT_PERMISSIONS = {
} }
``` ```
!!! 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

View File

@ -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

View File

@ -99,7 +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', {}) 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', {})

View File

@ -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'),
] ]

View File

@ -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):
""" """

View File

@ -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
) )

View File

@ -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=users[0]),
Token(user=self.user), Token(user=users[1]),
Token(user=self.user), 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 = {

View File

@ -279,83 +279,20 @@ class UserTokenView(LoginRequiredMixin, View):
}) })
@register_model_view(UserToken, 'edit') class UserTokenEditView(generic.ObjectEditView):
class UserTokenEditView(LoginRequiredMixin, View): queryset = UserToken.objects.all()
form = forms.UserTokenForm
default_return_url = 'account:usertoken_list'
def get(self, request, pk=None): def alter_object(self, obj, request, url_args, url_kwargs):
if pk: if not obj.pk:
token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk) obj.user = request.user
else: return obj
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
})
@register_model_view(UserToken, 'delete') class UserTokenDeleteView(generic.ObjectDeleteView):
class UserTokenDeleteView(LoginRequiredMixin, View): queryset = UserToken.objects.all()
default_return_url = 'account:usertoken_list'
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'),
})
# #