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.
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.
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: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:
update
andpartial_update
methods need similar restrictions (these come fromUpdateModelMixin
.if request.user.is_anonymous
by addingIsAuthenticated
at the beginning of the list of permissions classes returned byget_permissions()
for thedelete
action.get_queryset()
:get_object_or_404
:user = get_object_or_404(User, username=username)
. I think this is what theget_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!