Проблема


Всем привет. При разработке API для очередного веб-портала я взял свой привычный стек:


  • Django
  • django-rest-framework

Но в этот раз стояла довольно непривычная задача — сделать одну User модель, которая может иметь несколько разных профилей (Исполнитель, Заказчик). И наличие каждого из профилей дает разные полномочия на работу с одними и теми же ресурсами.


Такой подход позволяет пользователям не заводить несколько учетных записей для каждой роли, что зачастую было бы невозможно, ввиду ограничений на модель: уникальный email или номер телефона.


Итак, опишем возникшие перед нами проблемы:


  1. Один пользователь – несколько профилей.
  2. Как организовать права каждого из профилей.
  3. Доступ к одним тем же ресурсам от разных профилей.

Ниже я приведу свой способ решения этой задачи, который сложился из уже наработанных привычек по организации Django-проекта, а также попыток придумать наиболее гибкое и масштабируемое решение.


Один пользователь — несколько профилей


Над этой проблемой я почти не думал и шел по опыту предыдущих проектов, да и в документации фреймворка этот способ описан, как предпочтительный (https://docs.djangoproject.com/en/3.0/topics/auth/customizing/#extending-the-existing-user-model)


# models.py
class User(AbstractUser):
    username = None
    email = models.EmailField(unique=True, verbose_name=_("email"))
    phone = models.CharField(unique=True, max_length=11, verbose_name=_("phone"))
    ...
    USERNAME_FIELD = "email"
    EMAIL_FIELD = "email"
    ...

class Employee(models.Model):
    ...
    user = models.OneToOneField(
        "user.User",
        on_delete=models.CASCADE,
        related_name="employee",
        verbose_name=_("user"),
    )

class Company(models.Model):
    ...

class Member(models.Model):
    is_owner = models.BooleanField(default=False, verbose_name=_("owner"))
    is_manager = models.BooleanField(default=False, verbose_name=_("manager"))
    is_active = models.BooleanField(default=True)
    user = models.OneToOneField(
        "user.User",
        on_delete=models.CASCADE,
        related_name="member",
        verbose_name=_("user"),
    )
    company = models.ForeignKey(
        "user.Company",
        on_delete=models.CASCADE,
        related_name="members",
        verbose_name=_("company"),
    )

Таким образом каждый пользователь может иметь от 0 до 2-х профилей (профиль Исполнителя, профиль Заказчика).


Данную организацию моделей я принял как исходную точку для разработки решения и считал, что здесь ошибок допущено не было.


Как организовать права каждого из профилей


Эту часть задачи я также решал по опыту предыдущих проектов, использовав Custom Permissions от drf (https://www.django-rest-framework.org/api-guide/permissions/#custom-permissions).


В этом решении мне нравится его простота и наглядность. Но допускаю, что данную задачу можно было решить и через джанго пермишны.


# permissions.py
from rest_framework.permissions import BasePermission

class EmployeeOnly(BasePermission):
    def has_permission(self, request, view):
        return request.user.is_authenticated and hasattr(request.user, "employee")

class CompanyManagerOnly(BasePermission):
    def has_permission(self, request, view):
        return (
            request.user.is_authenticated
            and hasattr(request.user, "member")
            and request.user.member.is_active
            and request.user.member.is_manager
        )

Для действий, которые доступны пользователям с любым из профилей, можно сделать общий permission


# permissions.py
class EmployeeAndCompanyManagerOnly(BasePermission):
    def has_permission(self, request, view):
        return request.user.is_authenticated and (
            hasattr(request.user, "employee")
            or (
                hasattr(request.user, "member")
                and request.user.member.is_active
                and request.user.member.is_manager
            )
        )

У такого подхода есть минус: если пермишнов по профилям будет больше 2-х, то множество таких сочетаний будет сильно расти. Но если вы можете с достаточной уверенностью утверждать, что пермишны не будут расширяться, то код останется довольно лаконичным.


Почему необходимость в таких составных пермишнах вообще возникает


В django-rest-framework пермишны, которые применяются к конкретной вью, указываются в атрибуте permissions_class, которые накладывается по принципу AND.


# views.py
class TaskView(APIView):
    ...
    permission_classes = (CompanyManagerOnly, EmployeeOnly)
    ...

В этом примере доступ ко вью получат только пользователи, имеющие оба профиля.


Именно в таком случае нам приходится придумывать фокусы с составными пермишнами EmployeeAndCompanyManagerOnly и использовать их в качестве одного общего пермишн класса.


# views.py
class TaskView(APIView):
    ...
    permission_classes = (EmployeeAndCompanyManagerOnly,)
    ...

С версии 3.9 в drf появилась возможность конструировать различные условия из пермишнов, используя синтаксис битовых операций


# views.py
class TaskView(APIView):
    ...
    permission_classes = ((CompanyManagerOnly|EmployeeOnly),)
    ...

Синтаксис не самый очевидный и симпатичный, но может быть удобнее, чем городить кучу составных пермишн классов.


В текущей версии проекта я использовал версию drf 3.11, поэтому решил использовать эту новую возможность. Это добавляет некоторой гибкости в масштабировании проекта, не так уж сильно усложняя чтение и понимание кода.


P.S. В дальнейшем у меня добавились еще пермишны для CompanyOwnerOnly и CompanyMemberOnly, и мне не пришлось ничего дописывать помимо этих классов, что хорошо.


Доступ к одним и тем же ресурсам от разных профилей


Мы дошли до самого интересного и неочевидного места.


Немного о том, как я организую апи


Когда-то давно, потратив несколько дней на изучение вопросов (Как же все-таки правильно писать REST API? Обязательно ли апи должно быть RESTful? Да и вообще как делать правильно? Должны же быть какие-то best practice?), я понял, что мнений много, а четких правил нет. Но все мнения сходятся в одном: не бойтесь делать то, что удобно в вашем случае.


И для себя я вывел следующие тезисы:


  • Не усложняй. В попытке удержать гордое звание RESTful, апи может стать довольно сложным, а количество производных ресурсов будет неизбежно расти. Так как действий, выраженных глаголами GET POST PUT DELETE, будет всегда не хватать. Особенно если вы делаете что-то сложнее, чем приложение AuthorBook.
  • Не бойся использовать action endpoint'ы. Добавив немного RPC в наш REST, мы поймем, что оно ничуть не потеряло в удобстве. Зато жизнь разработчика упрощается многократно. Например, это очень удобно для переключения каких-то флагов у ресурсов.
  • Не злоупотребляй action endopoint'ами. Если их стало слишком много, это значит, что скорее всего твой ресурс слишком сложный, и его надо декомпозировать на несколько производных объектов.

В своих проектах я использую viewsets от drf, которые из коробки генерят необходимые нам REST окончания для наших моделей. И в случае необходимости разбавляю его некоторыми экшнами.



Вернемся к нашей проблеме


Итак, опишем ресурс, с которым взаимодействуют различные пользователи:


# models.py
class Task(models.Model):
    ...

# views.py
class TaskViewSet(
    mixins.CreateModelMixin,
    mixins.UpdateModelMixin,
    mixins.ListModelMixin,
    mixins.RetrieveModelMixin,
    viewsets.GenericViewSet,
):
    serializer_class = TaskSerializer

    def get_permissions(self):
        if self.action in ["accept_invite", "reject_invite", "employee_me"]:
            return (EmployeeOnly(),)

        if self.action in [
            "accept_employees",
            "reject_employees",
            "create",
            "update",
            "partial_update",
        ]:
            return (CompanyManagerOnly(),)

        return ((CompanyManagerOnly | EmployeeOnly)(),) # тоже страшно от этой конструкции

    ...

Cодержание экшнов не имеет значения ("create", "update", "partial_update" — название дефолтных экшнов drf), поэтому не буду приводить их описания.


Вот как будет выглядеть апи от имени Исполнителя (для Заказчика аналогично, только со своими методами):



И все бы было хорошо, если бы не тот факт, что один и тот же пользователь, может иметь сразу оба профиля, а значит сваггер нарисует нам следующую картинку:



Тут и становится понятно, что у нас есть некоторые проблемы:


  1. Как определить, от имени какого профиля я хочу запросить список задач, если у меня их два.
  2. Имея доступ к задаче (могу получить ее по GET /tasks/), я могу делать с ней только те действия, которые определены моей ролью в этой задаче, а не наличием у меня того или иного профиля.

И решить их навскидку можно 4-мя способами:


  1. Написать разные TaskViewSet для Исполнителя и Заказчика.


    Плюсы Минусы
    Код получается прямолинейный, без лишних if Спецификация сильно растет
    - Несколько вьюх вместо одной (труднее поддерживать)
    - Employee и Сompany это все-таки не путь к ресурсу, а роль, с которой я работаю в этом ресурсе, как-то не "по-рестовски" пихать ее в url

  2. Использовать кастомный хэдер в реквесте для определения, в качестве кого пользователь хочет запросить ресурс.


    Плюсы Минусы
    Предположительная красота спецификации (ничего лишнего в урлах) В логах ничего не понятно: если что-то где-то упадет, сложно понять, от имени кого пользователь запрашивал данные
    - Автоматический генератор спецификации (drf_yasg) не поддерживает добавления кастомных хэдеров, поэтому разработка превратилась бы в ад

  3. Использовать query параметры в урле.


    Плюсы Минусы
    Вью будет одна Сразу понятно, что вью усложнится: будут постоянные if else
    Кажется, что спецификация будет симпатичная (вкусовщина) -

  4. HATEOAS (расскажу подробнее в следующем посте!)


    По этому решению я не могу описать плюсы-минусы, так как никогда не пытался внедрять в свои проекты. Читал, что это не всегда удобно и бывает не просто поддерживать.
    Но достоверность этой информации подтвердить не могу.
    Так же есть подозрение, что подход не решает проблему №1.



Я выбрал третье решение, так как в нем меньше всего минусов.


Написал специальный сериалайзер и подключаю его к спецификации тех экшнов, которые должны содержать этот query параметр.


# serializers.py
class ModeSerializer(serializers.Serializer):
    mode = serializers.ChoiceField(MODE)

# views.py
@swagger_auto_schema(query_serializer=ModeSerializer)
def list(self, request, *args, **kwargs):
    return super().list(request, *args, **kwargs)

@swagger_auto_schema(query_serializer=ModeSerializer)
def retrieve(self, request, pk, *args, **kwargs):
    return super().retrieve(request, *args, **kwargs)


Получилось довольно симпатично и последним штрихом осталось определить queryset во вью в зависимости от выбранного mode.


# views.py
def get_queryset(self):
    if getattr(self, "swagger_fake_view", False):
        return Task.objects.none()

    mode = self.request.query_params.get("mode")

    if mode == MODE.COMPANY:
        return Task.objects.filter(author=self.request.user).order_by("-created_at")
    elif mode == MODE.EMPLOYEE:
        # сложная возня с фильтрацией, но главное что мы 
        # определяем другой queryset!
        tasks = set(
            taskemployee.task.id
            for taskemployee in self.request.user.employee.taskemployee_set.exclude(
                invite_status=SUGGEST_STATUS.REJECT
            )
            if taskemployee.task.status
            in [
                TASK_STATUS.IN_WORK,
                TASK_STATUS.DONE,
            ]
        )
        return Task.objects.filter(id__in=tasks).order_by("-created_at")

Итак, мы решили проблему №1, теперь мы точно знаем, от какой роли запрашиваем задачи.


Следующим этапом надо было придумать, как ограничить действия на объекты, которые мы можем получить, например, как Исполнитель, но не можем редактировать как Заказчик (так как мы не ее автор, хоть у нас и есть профиль Заказчика).


И тут мне помогло то, как организован метод self.get_object() в drf view.


# rest_framework.generics.GenericAPIView
class GenericAPIView(views.APIView):
    ...

    def get_object(self):
        ...

        queryset = self.filter_queryset(self.get_queryset())

        ...

        obj = get_object_or_404(queryset, **filter_kwargs)

        # May raise a permission denied
        self.check_object_permissions(self.request, obj)

        return obj

Он ищет объект в queryset = self.filter_queryset(self.get_queryset()), который мы определили до этого, что в общем-то логично, так как другой связи с моделью у вью нет.


Соответственно небольшой модификацией кода мы решили и вторую проблему:


# views.py
def get_queryset(self):
    if getattr(self, "swagger_fake_view", False):
        return Task.objects.none()

    mode = self.request.query_params.get("mode")

    if mode == MODE.COMPANY or self.action in [
        "create",
        "update",
        "partial_update",
        "accept_employees",
        "reject_employees",
    ]:
        ...
    elif mode == MODE.EMPLOYEE or self.action in [
        "accept_invite",
        "reject_invite",
    ]:
        ...

Технически проблема решена: при попытке изменить задачу, автором которой я не являюсь, мне вернется ошибка 404 NotFound, т.к. queryset для данного пользователя сформированный по ветке MODE.COMPANY просто не будет содержать этот ресурс.


Дальше я попытался навести красоту: мне не нравилось то, что в if мы мешаем и mode из урла, и проверку по экшнам. А также при добавлении нового экшна надо не забыть его здесь дописать.


После небольшого рефакторинга стало лучше:


# views.py
class TaskViewSet(
    mixins.CreateModelMixin,
    mixins.UpdateModelMixin,
    mixins.ListModelMixin,
    mixins.RetrieveModelMixin,
    viewsets.GenericViewSet,
):
    ...

    action_default_mode = {
        "create": MODE.COMPANY,
        "update": MODE.COMPANY,
        "partial_update": MODE.COMPANY,
        "accept_invite": MODE.EMPLOYEE,
        "reject_invite": MODE.EMPLOYEE,
        "employee_me": MODE.EMPLOYEE,
    }

    def get_queryset(self):
        if getattr(self, "swagger_fake_view", False):
            return Task.objects.none()

        mode = self.request.query_params.get("mode") or self.action_default_mode.get(
            self.action
        )

        assert mode, (
            "You need to add required query param in your urls "
            "or set an action default mode in self.action_default_mode map"
            f" for action `{self.action}`"
        )

        if mode == MODE.COMPANY:
            ...
        elif mode == MODE.EMPLOYEE:
            ...

Но от чего хочется избавиться так это от того, что при добавлении экшна я должен скролить до словаря и вписывать туда дефолтный мод. По-питонячи было бы использовать какой-нибудь декоратор и пометить экшн сразу, но эту доработку я оставлю на потом.


Заключение


В первую очередь я набирал этот текст, чтобы попытаться логически выстроить свое решение и объяснить каждый следующую шаг.


В итоге, как мне кажется, получилось довольно лаконичное решение, которое хорошо масштабируется на произвольное количество профилей. При этом сложность кода почти не возрастает, и мы смогли удачно разобраться с пермишнами на объекты.


На хабр я решил запостить эту статью, т.к. пока решал данную задачу, долго искал что-то похожее в интернете, но нашел довольно мало материалов на тему "один пользователь — много ролей".


Хочется узнать не изобретал ли я велосипед в стеке Django, drf. А если это действительно нужный кейс, то можно даже обернуть это в небольшой плагинчик для джанго.


Всем спасибо!