DRF는 어떻게 여러 Permission을 적용할 수 있도록 구현했는가?

antoliny0919
2024710
DRF Permission과 관련된 소스코드를 보던중 한 가지 든 의문점이 있었다.
만약 어떠한 로직이 존재할때 클라이언트로부터 해당 로직에 대해 GET요청이 왔을때는 'x'권한을 적용하고 POST요청이 왔을때는 'y'권한을 적용하고 싶었다.
즉 간단하게 말해서 클래스형태의 View에 메서드마다 권한을 다르게 적용하고 싶었다.
위 의문에 대한 해결점을 파악하기 전에 먼저 DRF에서 Permission이 어떻게 진행되는지 간단하게 알아보겠다.
 
DRF에서 Permission과정
 

권한 검증

 
결론부터 말하자면 DRF에서 클래스형태의 View에 권한을 적용하고 싶으면 permission_classes값으로 권한 클래스들을 배열에 담아 가지면 된다.
(배열이 굳이 아니더라도 Iterable한 값을 가져야한다.)
python
COPY

class ExampleView(APIView):
    authentication_classes = [TokenAuthentication]
    permission_classes = [AllowAny, IsAuthenticated]

    def get(self, request, format=None):
        content = {
            'status': 'request was permitted'
        }
        return Response(content)

    def post(self, request):
        ...
    
    def delete(self, request, id):
        ...
한 가지 예시로 위와같이 ExampleView를 작성했다고 가정해보자.
위 ExampleView는 permission_classes값으로 AllowAny, IsAuthenticated를 가지고 있는데 클라이언트가 get, post, delete 어떠한 HTTP 메서드로 요청을 보내든 상관없이 AllowAny, IsAuthenticated 두 권한 클래스의 권한 검사 메서드를 모두 다 통과해야 로직이 호출된다.
왜냐하면 DRF에서 클라이언트로 요청이 왔을때 조금 축약해서 APIView의 dispatch메서드가 동작하게 되고 dispatch메서드는 필자가 작성한 ExampleView의 get, post, delete메서드가 호출되기 전에 클라이언트가 해당 메서드를 호출할 수 있는 권한이 존재하는지 검사하는 과정을 거친다.
해당 과정은 아래 check_permissions메서드를 호출함으로써 시작된다.
python
COPY


def get_permissions(self):
    """
    Instantiates and returns the list of permissions that this view requires.
    """
    return [permission() for permission in self.permission_classes]

def check_permissions(self, request):
    """
    Check if the request should be permitted.
    Raises an appropriate exception if the request is not permitted.
    """
    for permission in self.get_permissions():
        if not permission.has_permission(request, self):
            self.permission_denied(
                request,
                message=getattr(permission, 'message', None),
                code=getattr(permission, 'code', None)
             )
ExampleView의 경우에는 check_permissions가 호출되었을때 내부에서 get_permissions이 호출되어 AllowAny, IsAuthenticated 권한 클래스의 객체가 생성된 뒤 해당 객체들을 반복하여 각각 get_permissions메서드를 호출하게 된다.
get_permissions메서드는 권한 클래스의 권한 검증 메서드로 위와 같은 경우에는 만약 인증되지 않은 유저가 요청을 보냈을때 AllowAny의 get_permissions는 통과하겠지만 IsAuthenticated의 get_permissions는 통과하지 못하여 결국 401 상태코드를 반환받게 된다.
사실상 ExampleView는 permission_classes로 DRF에서 제공하는 AllowAny, IsAuthenticated를 사용했지만 AllowAny는 무용지물이 된다.
왜냐하면 어떠한 유저든 AllowAny는 통과할 수 있지만 IsAuthenticated는 인증된 유저만 통과할 수 있기 때문이다.
이렇게 DRF에서 권한 검증은 permission_classes의 값인 권한 클래스들의 객체를 생성한뒤 반복하여 각 권한 클래스의 get_permissions메서드를 호출함으로써 요청을 보낸 클라이언트의 권한이 유효한지 검사한다.
 
클래스형태 View에 메서드마다 다른 권한 적용하기
 

Super권한 클래스

 
만약 ExampleView에 GET요청에는 어떠한 유저든 허용하는 ReadOnly권한을 적용하고 POST, DELETE요청에는 인증된 유저만 허락되도록 IsAuthenticated권한을 적용하고 싶은 상황이라고 가정해보자
일단 위 ExampleView는 필자가 설명했듯이 어떠한 메서드로 클라이언트가 요청을 보내든 AllowAny, IsAuthenticated 권한 클래스 둘 다 통과해야만 한다.
python
COPY

class ExampleView(APIView):
    authentication_classes = [TokenAuthentication]
    permission_classes = [AllowAny, IsAuthenticated]

    def get(self, request, format=None):
        content = {
            'status': 'request was permitted'
        }
        return Response(content)

    def post(self, request):
        ...
    
    def delete(self, request, id):
        ...
 가장 쉽고 간단하게 해결할 수 있는 해결방법을 생각해보면 커스텀 권한 클래스를 생성하여 if문으로 메서드마다 다른 권한 검증을 수행하게 하는 Super한 권한 클래스를 만들면 된다.
python
COPY

class ExampleViewPermission(BasePermission):
  
  def has_permission(self, request, view):
    
    if request.method == "GET":
      return True
    
    elif request.method in ["POST", "DELETE"] :
      return bool(request.user and request.user.is_authenticated)
위 ExampleViewPermission클래스는 request.method의 값마다 다른 권한검증을 수행하게 된다.
GET요청이 오면 True를 반환하여 허용해주고 POST나 DELETE요청이 오면 요청을 보낸 유저가 인증된 유저인지 확인한다.
위와 같은 권한 클래스만으로도 필자가 가정한 상황은 쉽게 해결할 수 있다.
심지어 DRF에서 제공하는 IsAuthenticatedOrReadOnly권한 클래스를 사용하면 더 쉽게 문제를 해결할 수 있다.
python
COPY

class IsAuthenticatedOrReadOnly(BasePermission):
    """
    The request is authenticated as a user, or is a read-only request.
    """

    def has_permission(self, request, view):
        return bool(
            request.method in SAFE_METHODS or
            request.user and
            request.user.is_authenticated
        )
하지만 만약 더 복잡한 상황이 존재한다고 가정했을때 위 방법은 그리 좋지 않다.
잦은 조건분기로 재사용성이 떨어지고 가독성도 좋지 않아진다.
그러면 어떻게 해결해야할까?
이러한 문제점을 초보자인 필자의 시선에 DRF가 굉장히 재미있고 현명하게 해결한것으로 보였다.
어쩌면 이번 포스트의 작성 동기가 된 가장 큰 의미를 전달해준 부분이다.
 

비트 연산적용

 
아마 DRF의 공식문서 Permission부분을 봤다면 이미 답을 알고계신분이 많을거라고 생각한다.
DRF는 각 권한 클래스마다 BasePermission이라는 권한 클래스를 상속받는데 BasePermission권한 클래스는 커스텀한 클래스를 생성하는 메타클래스 BasePermissionMetaclass를 상속받은 상태이다.
그리고 BasePermissionMetaclass는 OperationHolderMixin클래스를 상속받는데 사실 큰 핵심은 OperationHolderMixin클래스에 담겨있다.
no-img
먼저 OperationHolderMixin클래스 코드를 봐보면 아래와같이 and, or, rand, ror, invert 매직메서드가 정의되어있다.
python
COPY

class OperationHolderMixin:
    def __and__(self, other):
        return OperandHolder(AND, self, other)

    def __or__(self, other):
        return OperandHolder(OR, self, other)

    def __rand__(self, other):
        return OperandHolder(AND, other, self)

    def __ror__(self, other):
        return OperandHolder(OR, other, self)

    def __invert__(self):
        return SingleOperandHolder(NOT, self)
and, or, rand, ror, invert매직메서드는 OperationHolderMixin클래스객체가 and연산, or연산, not연산 대상이 될때 해당 매직메서드가 호출된다.
한 가지 매직메서드에 관한 간단한 예시를 들자면
python
COPY

class User():
  
  def __init__(self, name, age):
    self.name = name
    self.age = age
    
  def __str__(self):
    return self.name
    
  def __and__(self, other):
    return 'trigger and'
  
  def __rand__(self, other):
    return 'trigger rand'    
  
x = User('xxx', 21)
print(x & 1)
print(1 & x)
위와 같이 User클래스를 만들었고 필자는 User객체를 x변수에 할당했다.
그리고 print(x & 1), print(1 & x)과 같이 __and__, __rand__가 구현된 객체에 &비트 연산을 적용시 첫 번째 print문에서는 'trigger and'가 출력되고 두 번째 print문에서는 __rand__의 반환값인 'trigger rand'가 호출된다.
정수형에도 __and__, __rand__가 구현되어 있다면?
비트연산 대상에 __and__, __rand__가 각각 구현되어있다면 각 객체의 and연산만 호출하게 된다.
 

Metaclass

 
위와 같은 동작들을 봤을때 OperationHolderMixin을 상속받은 클래스 객체들또한 비트연산과 마주했을때 OperationHolderMixin에서 구현한 비트연산과 관련된 매직메서드들이 호출될 것이라는걸 예상할 수 있다.
하지만 여기서 BasePermission은 OperationHolderMixin을 메타클래스로 상속한 BasePermissionMeta를 상속받은 형태라는걸 짚고가야한다.
그렇기 때문에 만약 DRF에서 제공하는 BasePermission을 상속한 AllowAny, IsAuthenticated권한 클래스가 있을때
AllowAny클래스 객체끼리의 연산이아닌 AllowAny클래스 자체에 비트연산을 적용할때 OperationHolderMixin의 비트연산 매직메서드가 호출된다.
이와같이 동작하는 이유는 일반적인 상속이 아닌 메타클래스로 상속했다는점을 상기해야한다.
python
COPY

class ExampleView(APIView):
    authentication_classes = [TokenAuthentication]
    permission_classes = [AllowAny|IsAuthenticated]

    def get(self, request, format=None):
        content = {
            'status': 'request was permitted'
        }
        return Response(content)

    def post(self, request):
        ...
    
    def delete(self, request, id):
        ...
위와 같은 예시에서는 permission_classes값으로 AllowAny와 IsAuthenticated의 or비트연산이 작동하여
OperationHolderMixin클래스의 __or__매직메서드 반환값이 permission_classes변수에 담기게된다.
실제로 위 ExampleView를 호출했을때 permission_classes값을 응답으로 반환해보면
python
COPY

class ExampleView(APIView):
  authentication_classes = [TokenAuthentication]
  permission_classes = [AllowAny|IsAuthenticated]
  
  def get(self, request, format=None):
    content = {
        'permission_classes': str(self.__class__.permission_classes)
    }
    return Response(content)
no-img
OperandHolder객체가 반환되는걸 확인할 수 있다.
지금까지 비트 연산을 구현한 OperationHolderMixin을 메타클래스를 상속한 클래스끼리 비트연산시 OperationHolderMixin의 비트 연산이 잘 동작한다는걸 확인했다.
이제 OperationHolderMixin클래스의 비트연산 반환값인 OperandHolder클래스를 확인해보자
python
COPY

class OperandHolder(OperationHolderMixin):
    def __init__(self, operator_class, op1_class, op2_class):
        self.operator_class = operator_class
        self.op1_class = op1_class
        self.op2_class = op2_class

    def __call__(self, *args, **kwargs):
        op1 = self.op1_class(*args, **kwargs)
        op2 = self.op2_class(*args, **kwargs)
        return self.operator_class(op1, op2)

    def __eq__(self, other):
        return (
            isinstance(other, OperandHolder) and
            self.operator_class == other.operator_class and
            self.op1_class == other.op1_class and
            self.op2_class == other.op2_class
        )

    def __hash__(self):
        return hash((self.operator_class, self.op1_class, self.op2_class))
OperandHolder의 초기화 매직메서드인 __init__매직메서드는 첫번째 매개변수로 operator_class를 받는데 해당 값이
비트 연산에 알맞는 논리적으로 적용되어야할 로직이 구현된 클래스를 가진다.
그렇기 때문에 OperationHolderMixin클래스의 비트연산 반환값으로 OperandHolder클래스 객체를 생성할때 전달한 매개변수를 보면 굉장히 직관적인 값이 전달되는걸 알 수 있다.
no-img
위와 같이 비트연산의 실질적인 동작을 담은 클래스 AND, OR이 OperandHolder의 operator_class필드값으로 담기게된다.
 

Operator Class

 
AND, OR클래스가 권한에서 작동하려면 어떤 동작을 할것으로 예상할 수 있을까?
권한 검증은 각 권한 클래스의 has_permission메서드를 호출함으로써 수행하게 된다.
그렇기 때문에 AND, OR과 같은 비트연산을 권한 검증에 적용하려면 각 권한 클래스들의 has_permission반환값에 AND, OR연산을 추가하면 되지 않을까?
필자의 ExampleView로 따지면 AllowAny, IsAuthenticated에 OR연산을 적용했으니 둘 중 하나라도 has_permission이 통과되면 권한 검증을 통과하는 식으로 말이다.
python
COPY

class AND:
    def __init__(self, op1, op2):
        self.op1 = op1
        self.op2 = op2

    def has_permission(self, request, view):
        return (
            self.op1.has_permission(request, view) and
            self.op2.has_permission(request, view)
        )

    def has_object_permission(self, request, view, obj):
        return (
            self.op1.has_object_permission(request, view, obj) and
            self.op2.has_object_permission(request, view, obj)
        )


class OR:
    def __init__(self, op1, op2):
        self.op1 = op1
        self.op2 = op2

    def has_permission(self, request, view):
        return (
            self.op1.has_permission(request, view) or
            self.op2.has_permission(request, view)
        )

    def has_object_permission(self, request, view, obj):
        return (
            self.op1.has_permission(request, view)
            and self.op1.has_object_permission(request, view, obj)
        ) or (
            self.op2.has_permission(request, view)
            and self.op2.has_object_permission(request, view, obj)
        )
실제로 AND, OR 클래스는 두 개의 권한 클래스를 가지며 has_permission메서드 호출시 속성으로 가진 각 권한 클래스들의 has_permission메서드를 호출하고 반환값에 비트연산을 적용한다.
즉 지금까지의 과정을 총정리해보면
먼저 필자의 ExampleView는 BasePermission클래스를 상속받은 AllowAny권한 클래스와 IsAuthenticated권한 클래스에 OR연산을 적용했기 때문에 OperationHolderMixin의 __or__매직메서드의 반환값인 OperandHolder(OR, self, other)클래스 객체가 담기게 된다.
그리고 해당 값은 클라이언트의 HTTP 요청에 의해 권한을 검사하는 로직인 check_permissions가 호출되고
python
COPY

def get_permissions(self):
    """
    Instantiates and returns the list of permissions that this view requires.
    """
    return [permission() for permission in self.permission_classes]

def check_permissions(self, request):
    """
    Check if the request should be permitted.
    Raises an appropriate exception if the request is not permitted.
    """
    for permission in self.get_permissions():
        if not permission.has_permission(request, self):
            self.permission_denied(
                request,
                message=getattr(permission, 'message', None),
                code=getattr(permission, 'code', None)
             )
check_permissions메서드에서 get_permissions메서드를 호출하면서 permission_classes값인 OperandHolder클래스 객체에 permission() --> __call__ 매직메서드를 호출하게 된다.
python
COPY

class OperandHolder(OperationHolderMixin):
    ...
    def __call__(self, *args, **kwargs):
        op1 = self.op1_class(*args, **kwargs)
        op2 = self.op2_class(*args, **kwargs)
        return self.operator_class(op1, op2)
    ...
__call__매직메서드를 통해 operator_class값인 OR클래스 객체가 생성되고 OR클래스가 생성될때 초기화 매개변수로 op1, op2 AllowAny클래스 객체, IsAuthenticated클래스 객체가 전달된다.
그렇게 생성된 OR클래스는 check_permissions메서드에서 has_permission메서드를 호출하게 되고
OR메서드의 has_permissions는 이전에도 설명했듯이 각 권한 클래스 has_permission결과값에 비트연산을 적용한 값을 반환하게 된다.
no-img
 이렇게 DRF는 여러 권한 클래스들에게 비트연산을 적용할 수 있도록 하여 클래스 형태의 View에도 HTTP 메서드마다 다양한 권한 클래스를 사용할 수 있도록 하면서 재사용성까지 챙길 수 있는 코드를 만들었다.
이제 DRF의 Permission에 어떠한 특징이 있는지 알았으니 필자가 해결하고 싶었던 문제인 ExampleView에 GET요청은 누구든지 허용하지만 POST, DELETE는 인증된 유저만 허락되게 하는 권한을 만들어보겠다.
객체 수준 권한검증
원래는 보통 POST, DELETE는 단일 객체를 대상으로 하며 본인 소유의 것만 가능하게 하는 객체 수준 검증과정도 필요하다.(has_object_permission)하지만 필자의 ExampleView는 테스트 목적의 로직이기 때문에 객체 수준 권한 검증은 구현하지 않았다.
python
COPY

class ReadOnly(BasePermission):
  def has_permission(self, request, view):
    return request.method in SAFE_METHODS

class ExampleView(APIView):
  authentication_classes = [TokenAuthentication]
  permission_classes = [ReadOnly|IsAuthenticated]
  
  def get(self, request, format=None):
     ...
  
  def post(self, request, id):
     ...
 
  def delete(self, request, id):
     ...
필자는 ReadOnly라는 커스텀 권한 클래스를 만들었다.
ReadOnly권한 클래스는 클라이언트의 요청이 SAFE_METHODS --> ['GET', 'HEAD', 'OPTION']중에 하나라면 True를 반환하여 권한 검증을 통과한다.
위와 같이 ExampleView의 permission_classes는 결론적으로 ReadOnly의 (get_permission OR IsAuthenticated의 get_permission)이 된다.
즉 권한 둘 중 하나만 통과되면 클라이언트가 요청한 로직이 허용된다.
만약 GET요청을 보냈다면 ReadOnly권한에서 허용되고
POST, DELETE요청을 보냈다면 ReadOnly에선 실패하겠지만 인증된 유저라면 IsAuthenticated에서 허용되어 요청한 로직인 ExampleView의 post, delete메서드가 동작하고 인증되지 않은 유저이면 IsAuthenticated는 실패하여 요청은 401상태코드인 응답을 반환받게 된다.
지금까지 DRF에서 클래스형태의 View에 여러 권한을 적용할 수 있는지에 대해 알아봤다.
사실 이번 포스트의 핵심은 DRF에서 권한을 어떻게 적용하는건가? 라기보단 OperationHolderMixin, Operator Class와 메타클래스를 통해 클래스에 비트연산을 적용해서 권한 적용을 효율적으로 만든 DRF의 소스코드부분이다.
DRF Permission에 관하여 더 자세한 내용은 DRF Permission APIGuide 공식문서DRF Permission 소스코드를 참고하기 바란다.