Marking endpoints in a viewset that require authentication for drf-spectacular (cookiecutter-django)

253 views Asked by At

I have a relatively new, out-of-the-box Django project that I set up with cookiecutter-django. I've added a destroy method to UserViewSet, like so:

class UserViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, UpdateModelMixin, GenericViewSet):
    serializer_class = UserSerializer
    queryset = User.objects.all()
    lookup_field = "username"

    def get_serializer_class(self):
        if self.action == "create":
            return UserCreateSerializer
        return UserSerializer

    def get_permissions(self):
        if self.action == "destroy":
            return [IsSelfOrStaff()]
        return super().get_permissions()

    def get_queryset(self, *args, **kwargs):
        assert isinstance(self.request.user.id, int)
        return self.queryset.filter(id=self.request.user.id)

    def destroy(self, request, *args, **kwargs):
        username = kwargs.get("username")
        user = User.objects.filter(username=username).first()

        if user is None:
            return Response(status=status.HTTP_404_NOT_FOUND)

        if request.user.is_anonymous:
            response = Response(status=status.HTTP_401_UNAUTHORIZED)
            response["WWW-Authenticate"] = "Bearer"
            return response

        if not request.user == user and not request.user.is_staff:
            return Response(status=status.HTTP_403_FORBIDDEN)

        user.is_active = False
        user.save()
        return Response(status=status.HTTP_200_OK)

    @action(detail=False)
    def me(self, request):
        serializer = UserSerializer(request.user, context={"request": request})
        return Response(status=status.HTTP_200_OK, data=serializer.data)

My unit tests confirm that the permissions on destroy work just like I want them to:

  • Anonymous users get a 401 error
  • Authenticated users can deactivate their own accounts
  • Authenticated users get a 403 error when they try to deactivate someone else's account
  • Unless that authenticated user has staff permissions, in which case they can deactivate anyone's account

The problem is that drf-spectacular seems to have a hard time picking this up. My Swagger documentation shows the unlocked icon on all of the endpoints that UserViewSet handles.

I've found some solutions on how to make all the endpoints on the viewset require authentication, but that's not what I need. This viewset also has a POST /users/ endpoint to create a new user, and I certainly don't want that to require authentication, or there would be no way to get started.

I added the get_permissions method, because some of the examples I found suggested that drf-spectacular would pick up on this and use that to show which endpoints require authentication and which don't, but in my case, it didn't make any difference.

How do I get drf-spectacular to correctly mark the endpoints that require authentication in a viewset where only some of the methods require it?

Update #1

Thanks to @Bruno's suggestions below, I added some new permissions classes:

from rest_framework import permissions
from rest_framework.exceptions import NotAuthenticated


class IsAuthenticated(permissions.BasePermission):
    def has_permission(self, request, view):
        if not request.user or not request.user.is_authenticated:
            raise NotAuthenticated(detail="Not authenticated.")
        return super().has_permission(request, view)


class IsSelfOrStaff(permissions.BasePermission):
    def has_object_permission(self, request, view, obj):
        return request.user == obj or request.user.is_staff

Which I'm now using in UserViewSet:

class UserViewSet(CreateModelMixin, DestroyModelMixin, RetrieveModelMixin, ListModelMixin, UpdateModelMixin, GenericViewSet):
    serializer_class = UserSerializer
    queryset = User.objects.all()
    lookup_field = "username"

    def get_serializer_class(self):
        if self.action == "create":
            return UserCreateSerializer
        return UserSerializer

    def get_permissions(self):
        self_or_staff = ["update", "partial_update", "destroy", "me"]
        if self.action in self_or_staff:
            return [IsAuthenticated(), IsSelfOrStaff()]
        return super().get_permissions()

    def get_queryset(self, *args, **kwargs):
        if self.request.user.is_staff:
            return User.objects.all()
        else:
            assert isinstance(self.request.user.id, int)
            return self.queryset.filter(id=self.request.user.id)

    def destroy(self, request, *args, **kwargs):
        user = self.get_object()
        user.is_active = False
        user.save()
        return Response(status=status.HTTP_200_OK)

    @action(detail=True)
    def me(self, request):
        serializer = UserSerializer(request.user, context={"request": request})
        return Response(status=status.HTTP_200_OK, data=serializer.data)

The good news is that the unit tests are all passing; authenticated users can't delete anyone, users can delete themselves but not others, unless they're staff, in which case they can delete anyone.

The bad news is that Swagger's still leaving the front door unlocked.

Swagger screenshot

Update #2

I've had it backwards the whole time! That unlocked icon shows the endpoints that do require authentication. My problem isn't that I'm failing to mark the endpoints that need authentication, it's that I'm showing the endpoints that don't need authentication as if they do. For this, adding auth=[] to the @extend_schema decorator (or the @extend_schema_view decorator if you're getting them all at once) works perfectly. It's a little concerning that Spectacular isn't picking up those differences automatically, making me worried that there are bigger problems yet lurking beneath, but if I build out some more robust unit tests and everything works, then this could well be the answer.

2

There are 2 answers

1
Bruno A. On

When I need to customise the API docs with drf-spectacular, I found that sometimes the easiest is to decorate my method with @extend-schema. It's more verbose, but I can describe exactly what I want, including longer description, and examples for errors. Here is what it might look like in your example:

class UserViewSet(...):
    ...

    @extend_schema(
        summary="Delete a user.",
        description="Remove the user with given username. Normal users can only delete themselves, and staff can remove anyone.",
        auth=["tokenAuth"],
        responses={
            204: "",
            401: OpenApiResponse(
                response=OpenApiTypes.OBJECT,
                description="Missing authentication.",
                examples=[
                    OpenApiExample(
                        name="No auth",
                        value={"nonFieldErrors": ["Missing auth credentials"]},
                    ),
                ],
            ),
            403: OpenApiResponse(
                response=OpenApiTypes.OBJECT,
                description="Forbidden.",
                examples=[
                    OpenApiExample(
                        name="Current user cannot delete given user.",
                        value={"nonFieldErrors": ["Not allowed to delete user."]},
                    ),
                ],
            ),
        },
    )
    def destroy(self, request, *args, **kwargs):
        ...

I've never tried the auth parameter and couldn't find a lot of examples of it, so not sure of the exact syntax, but the type hint says it's a list of strings.

That being said, I have a project using drf-spectacular and the endpoints that are open to anonymous users have a closed lock icon while the ones that required auth have an open one, and I myself found that confusing.

Side notes

Things I noted in your code, which don't directly answer your question:

  • I think your update and partial_update methods need similar restrictions (these come from UpdateModelMixin.
  • You should probably replace if request.user.is_anonymous by adding IsAuthenticated at the beginning of the list of permissions classes returned by get_permissions() for the delete action.
  • You might want to do staff vs non-staff user branching in get_queryset():
    • if staff, leave it as it
    • if non-staff and action is update or destroy, filter queryset to only current user
  • To find the user, there is the shortcut get_object_or_404: user = get_object_or_404(User, username=username). I think this is what the get_object() method does in the base class, maybe call it instead?

Sorry if they are things you knew and simplified for posting on SO, but I feel like I ought to mention them, just in case you missed them... Hope that helps!

0
TomDext On

If this can help, I was struggling like you to get the Swagger auth icon on some of the end-points I am documenting with the @extend_schema decorator, and it finally worked for me with this syntax : auth=[{"tokenAuth": [], }].

So it seems the parameter auth needs a list of dict, and not a list of string as suggested by the docs. I found this here : https://drf-spectacular.readthedocs.io/en/latest/blueprints.html#djangorestframework-api-key