diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index 1cb32c1e4..64b43661b 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -59,6 +59,10 @@ class TokenPermissions(DjangoObjectPermissions): def has_permission(self, request, view): + # User must be authenticated + if not request.user.is_authenticated: + return False + # Enforce Token write ability if isinstance(request.auth, Token) and not self._verify_write_permission(request): return False diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py index 27ef3fc82..df9af0f19 100644 --- a/netbox/users/api/nested_serializers.py +++ b/netbox/users/api/nested_serializers.py @@ -3,11 +3,12 @@ from django.contrib.contenttypes.models import ContentType from rest_framework import serializers from netbox.api import ContentTypeField, WritableNestedSerializer -from users.models import ObjectPermission +from users.models import ObjectPermission, Token __all__ = [ 'NestedGroupSerializer', 'NestedObjectPermissionSerializer', + 'NestedTokenSerializer', 'NestedUserSerializer', ] @@ -28,6 +29,14 @@ class NestedUserSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'username'] +class NestedTokenSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='users-api:token-detail') + + class Meta: + model = Token + fields = ['id', 'url', 'display', 'key', 'write_enabled'] + + class NestedObjectPermissionSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='users-api:objectpermission-detail') object_types = ContentTypeField( diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 054f9ba48..ba456b038 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -3,10 +3,18 @@ from django.contrib.contenttypes.models import ContentType from rest_framework import serializers from netbox.api import ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer -from users.models import ObjectPermission +from users.models import ObjectPermission, Token from .nested_serializers import * +__all__ = ( + 'GroupSerializer', + 'ObjectPermissionSerializer', + 'TokenSerializer', + 'UserSerializer', +) + + class UserSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='users-api:user-detail') groups = SerializedPKRelatedField( @@ -47,6 +55,21 @@ class GroupSerializer(ValidatedModelSerializer): fields = ('id', 'url', 'display', 'name', 'user_count') +class TokenSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='users-api:token-detail') + key = serializers.CharField(min_length=40, max_length=40, allow_blank=True, required=False) + user = NestedUserSerializer() + + class Meta: + model = Token + fields = ('id', 'url', 'display', 'user', 'created', 'expires', 'key', 'write_enabled', 'description') + + def to_internal_value(self, data): + if 'key' not in data: + data['key'] = Token.generate_key() + return super().to_internal_value(data) + + class ObjectPermissionSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='users-api:objectpermission-detail') object_types = ContentTypeField( diff --git a/netbox/users/api/urls.py b/netbox/users/api/urls.py index df2e8c25a..43d960980 100644 --- a/netbox/users/api/urls.py +++ b/netbox/users/api/urls.py @@ -9,6 +9,9 @@ router.APIRootView = views.UsersRootView router.register('users', views.UserViewSet) router.register('groups', views.GroupViewSet) +# Tokens +router.register('tokens', views.TokenViewSet) + # Permissions router.register('permissions', views.ObjectPermissionViewSet) diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py index b0443b87e..a1a8728a3 100644 --- a/netbox/users/api/views.py +++ b/netbox/users/api/views.py @@ -7,7 +7,7 @@ from rest_framework.viewsets import ViewSet from netbox.api.views import ModelViewSet from users import filtersets -from users.models import ObjectPermission, UserConfig +from users.models import ObjectPermission, Token, UserConfig from utilities.querysets import RestrictedQuerySet from utilities.utils import deepmerge from . import serializers @@ -37,6 +37,25 @@ class GroupViewSet(ModelViewSet): filterset_class = filtersets.GroupFilterSet +# +# REST API tokens +# + +class TokenViewSet(ModelViewSet): + queryset = RestrictedQuerySet(model=Token).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 self.request.user.is_superuser: + return queryset + return queryset.filter(user=self.request.user) + + # # ObjectPermissions # diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index 6625cba36..87f7dce57 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -3,7 +3,7 @@ from django.contrib.auth.models import Group, User from django.db.models import Q from netbox.filtersets import BaseFilterSet -from users.models import ObjectPermission +from users.models import ObjectPermission, Token __all__ = ( 'GroupFilterSet', @@ -60,6 +60,17 @@ class UserFilterSet(BaseFilterSet): ) +class TokenFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + + class Meta: + model = Token + fields = ['id', 'user', 'created', 'expires', 'key', 'write_enabled'] + + class ObjectPermissionFilterSet(BaseFilterSet): user_id = django_filters.ModelMultipleChoiceFilter( field_name='users', diff --git a/netbox/users/models.py b/netbox/users/models.py index 4a8274ab4..958681ba5 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -216,7 +216,8 @@ class Token(BigIDModel): self.key = self.generate_key() return super().save(*args, **kwargs) - def generate_key(self): + @staticmethod + def generate_key(): # Generate a random 160-bit key expressed in hexadecimal. return binascii.hexlify(os.urandom(20)).decode() diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py index c2488f2f6..c63da0639 100644 --- a/netbox/users/tests/test_api.py +++ b/netbox/users/tests/test_api.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from django.urls import reverse -from users.models import ObjectPermission +from users.models import ObjectPermission, Token from utilities.testing import APIViewTestCases, APITestCase from utilities.utils import deepmerge @@ -75,6 +75,38 @@ class GroupTest(APIViewTestCases.APIViewTestCase): Group.objects.bulk_create(users) +class TokenTest(APIViewTestCases.APIViewTestCase): + model = Token + brief_fields = ['display', 'id', 'key', 'url', 'write_enabled'] + bulk_update_data = { + 'description': 'New description', + } + + def setUp(self): + super().setUp() + + tokens = ( + # We already start with one Token, created by the test class + Token(user=self.user), + Token(user=self.user), + ) + # Use save() instead of bulk_create() to ensure keys get automatically generated + for token in tokens: + token.save() + + self.create_data = [ + { + 'user': self.user.pk, + }, + { + 'user': self.user.pk, + }, + { + 'user': self.user.pk, + }, + ] + + class ObjectPermissionTest(APIViewTestCases.APIViewTestCase): model = ObjectPermission brief_fields = ['actions', 'display', 'enabled', 'groups', 'id', 'name', 'object_types', 'url', 'users']