Skip to content

Permissions Proof of Concept

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

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

# Each user role is associated with one of the visibility levels
VisibilityLevel: TypeAlias = Literal["ifrc", "ns", "movement", "membership", "public"]
RoleScope: TypeAlias = Literal["country", "region", "global"]

VISIBILITY_LEVELS: List[VisibilityLevel] = ["ifrc", "ns", "movement", "membership", "public"]


class VisibilityChoices(models.TextChoices):
    IFRC = "ifrc"
    NS = "ns"
    MOVEMENT = "movement"
    MEMBERSHIP = "membership"
    PUBLIC = "public"


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": "ifrc",
        "permissions": {
            "field_report": ["create", "edit", "delete", "publish"]
        },
        "role_scope": "region",
    },
    RoleChoices.EMERGENCY_ADMIN: {
        "visibility_level": "ifrc",
        "permissions": {
            "emergency": ["create", "edit"],
        },
        "role_scope": "global",
    },
    # ...
}


class UserRole(models.Model):
    user = models.ForeignKey("User")
    role = models.PositiveIntegerField(choices=RoleChoices.choices)


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 = "global") -> Type[permissions.BasePermission]:
    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", "global")]
    ...


class ReadOnlyVisibilityViewsetMixin:
    request: request.Request

    def get_visibility_queryset(self, queryset):
        choices = VisibilityChoices

        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 "ifrc" in visibility_levels:
            return queryset
        elif "movement" in visibility_levels:
            return queryset.filter(visibility__in=[choices.MOVEMENT, choices.PUBLIC, choices.MEMBERSHIP])
        elif "membership" in visibility_levels:
            return queryset.filter(visibility__in=[choices.MEMBERSHIP, choices.PUBLIC])
        elif "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