Skip to content

Permissions Proof of Concept

Bibek Pandey edited this page Jul 17, 2023 · 3 revisions
from functools import reduce
from operator import concat
from typing import TypeAlias, Literal, List, TypedDict, Type
from enum import Enum

from django.db import models
from rest_framework import permissions, viewsets, request


# Each user role is associated with one of the visibility levels
class VisibilityLevel(Enum):
    IFRC = "ifrc"
    NS = "ns"
    MOVEMENT = "movement"
    MEMBERSHIP = "membership"
    PUBLIC = "public"


class RoleScope(Enum):
    COUNTRY = "country"
    REGION = "region"
    GLOBAL = "global"


FieldReportPermission: TypeAlias = Literal["create", "edit", "delete", "publish"]
EmergencyPermission: TypeAlias = Literal["create", "edit", "delete"]
...  # other

PermissionModule: TypeAlias = Literal[
    "field_report",
    "emergency",
    # ...
]


# NOTE: The above PermissionModule and the following keys should be in sync.
# Unfortunately, can't get key of a TypedDict in python at the moment.
class Permissions(TypedDict, total=False):
    field_report: List[FieldReportPermission]
    emergency: List[EmergencyPermission]  # NOTE: Need to handle this differently
    ...  # other


class RoleChoices(models.IntegerChoices):
    REGIONAL_ADMIN = 1
    EMERGENCY_ADMIN = 2
    ...


class Role(TypedDict):
    visibility_level: VisibilityLevel
    permissions: Permissions
    role_scope: RoleScope


ROLES: dict[int, Role] = {
    RoleChoices.REGIONAL_ADMIN: {
        "visibility_level": VisibilityLevel.IFRC,
        "permissions": {
            "field_report": ["create", "edit", "delete", "publish"]
        },
        "role_scope": RoleScope.GLOBAL,
    },
    RoleChoices.EMERGENCY_ADMIN: {
        "visibility_level": VisibilityLevel.IFRC,
        "permissions": {
            "emergency": ["create", "edit"],
        },
        "role_scope": RoleScope.GLOBAL,
    },
    # ...
}


class UserRole(models.Model):
    user = models.ForeignKey("User")
    role = models.PositiveIntegerField(choices=RoleChoices.choices)
    # IMPORTANT and TO DISCUSS
    # NOTE: A simplification made here is that this role accounts for all three scopes.
    # So, if users have a role "Country Admin", they are admin for all the
    # countries in which they are associated to(UserCountry model).


class UserCountry(models.Model):
    user = models.ForeignKey("User")
    country = models.ForeignKey("Country")


class UserRegion(models.Model):
    user = models.ForeignKey("User")
    region = models.ForeignKey("Region")


def get_permission_class_for(module: PermissionModule, scope: RoleScope = RoleScope.GLOBAL) -> Type[permissions.BasePermission]:
    """
    Return a dynamically generated permission class for a particular module and scope
    Example: module can be, say, "field_report", scope can be "global"
    """
    class IFRCGeneralPermission(permissions.BasePermission):
        def has_permission(self, request, view):
            if request.method in permissions.SAFE_METHODS:
                return True

            user_roles = UserRole.objects.filter(user=request.user)

            role_permissions = [ROLES[userrole.role]["permissions"] for userrole in user_roles]
            module_permissions = [x.get(module, []) for x in role_permissions]
            perms = reduce(concat, module_permissions)

            if request.method == "POST" and "create" not in perms:
                # TODO: Allow create in particular region, country?
                return False

            if request.method in ["PUT", "PATCH"] and "edit" not in perms:
                return False

            if request.method == "DELETE" and "delete" not in perms:
                return False
            # TODO: for other actions like publish/unpublish, for example:
            # if request.action == "publish" and "publish" not in perms:
            #     return False
            return True

        def has_object_permission(self, request, view, obj):
            user_countries = UserCountry.objects.filter(user=request.user)
            user_regions = UserRegion.objects.filter(user=request.user)
            if scope == "country" and hasattr(obj, "country"):
                return obj.country in [x.country for x in user_countries]
            elif scope == "region" and hasattr(obj, "region"):
                return obj.region in [x.region for x in user_regions]
            return True

    return IFRCGeneralPermission


# EXAMPLE
class FieldReportViewSet(viewsets.ModelViewset):
    permission_classes = [get_permission_class_for("field_report", RoleScope.GLOBAL)]
    ...


class ReadOnlyVisibilityViewsetMixin:
    request: request.Request

    def get_visibility_queryset(self, queryset):
        choices = VisibilityLevel

        if not self.request.user.is_authenticated:
            return queryset.filter(visibility=choices.PUBLIC)

        user_roles = UserRole.objects.filter(user=self.request.user)

        visibility_levels = [ROLES[userrole.role]["visibility_level"] for userrole in user_roles]

        if VisibilityLevel.IFRC in visibility_levels:
            return queryset
        elif VisibilityLevel.MOVEMENT in visibility_levels:
            return queryset.filter(visibility__in=[choices.MOVEMENT, choices.PUBLIC, choices.MEMBERSHIP])
        elif VisibilityLevel.MEMBERSHIP in visibility_levels:
            return queryset.filter(visibility__in=[choices.MEMBERSHIP, choices.PUBLIC])
        elif VisibilityLevel.NS in visibility_levels:
            levels = [choices.MOVEMENT, choices.PUBLIC, choices.MEMBERSHIP]
            user_countries = UserCountry.objects.filter(user=self.request.user)\
                .values_list('id', flat=True)
            return queryset.filter(
                models.Q(visibility__in=levels) |
                models.Q(visibility=choices.NS, country_id__in=user_countries)  # TODO: check regions
            )
        return queryset.filter(visibility=choices.PUBLIC)

    def get_queryset(self):
        queryset = super().get_queryset()
        return self.get_visibility_queryset(queryset)
Clone this wiki locally