-
Notifications
You must be signed in to change notification settings - Fork 5
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)