iT邦幫忙

2024 iThome 鐵人賽

DAY 17
0
Software Development

Django 2024: 從入門到SaaS實戰系列 第 19

Django REST framework: 權限基礎到角色存取控制

  • 分享至 

  • xImage
  •  

在不考慮資料驗證、敏感數據暴露與限制流量(這個Day20會展開)的角度,單以身份認證與權限設計的角度來說,我們現在的API有幾個問題:

  • 不需要進行登入,只要有路由就能獲取資料甚至修改資料
  • 我們在不同的序列化器中沒有定義哪些用戶是否具備對應的權限來處理資料

在Django REST framework(DRF)提供了認證(Authentication)與權限(Permission)的機制來幫助我們在開發Web API時,能夠處理上述的相關問題。今天會先初步介紹DRF中認證與權限的區別,接著深入探討我們在權限上能做哪些設計與應用,以符合更成熟的設計方式

今日程式碼:https://github.com/class83108/drf_demo/tree/drf_permission

今日重點:

  • 認證與權限的區別
  • DRF中的權限系統
    • 內建權限系統介紹
    • 進行權限配置
    • 自定義權限
  • 角色存取控制(RBAC)

認證與權限的區別

雖然我們常常會把認證與權限綁在一起說明,並且兩者的確息息相關,但是他們有不同的職責

  • 認證:負責辨認使用者的身份,主要的功能是回答“你是誰”
  • 權限:負責控制訪問的權限,主要的功能是回答“你可以做什麼”

我們的模型中,User模型就是認證的基礎,而Workspace與Document模型則是可以針對權限的設計進行展開

DRF中的權限系統

內建權限系統介紹

DRF API權限相關類別中有以下幾種

  • AllowAny:不用管客戶端是否經過認證就能使用
  • IsAuthenticated:如果是有註冊過的客戶端經過認證就能使用
  • IsAuthenticatedOrReadOnly:如果有經過認證便可請求不同方法,反之則只有只讀屬性
  • IsAdminUser:只有具備is_staff的用戶才有權限
  • DjangoModelPermissions:這個權限類別與Django模型所設定的權限有緊密的關聯
    • 首先需要該視圖具備 queryset 屬性或是 get_queryset() 才能使用
    • 即使用戶是經過認證的,也要Django Model中該用戶具備對應的操作權限才能使用
class Task(models.Model):
    ...

    class Meta:
        permissions = [
            ("change_task_status", "Can change the status of tasks"),
            ("close_task", "Can remove a task by setting its status as closed"),
        ]

進行權限配置

在視圖或是視圖集中直接設置屬性

from rest_framework.permissions import IsAuthenticated

class WorkspaceDetail(GenericAPIView):
    queryset = Workspace.objects.all()
    serializer_class = WorkspaceDetailSerializer
    permission_classes = [IsAuthenticated]
    
    ....

當我們還沒登入時
https://ithelp.ithome.com.tw/upload/images/20240929/20161866Ssfz0ZuJVH.png

而登入後就能調用API
https://ithelp.ithome.com.tw/upload/images/20240929/201618660dYJCzlpWf.png

至於登入的方式不是透過Django admin,我們可以設置DRF內置的用戶登入方式

# 根目錄urls.py

urlpatterns = [
    ...
    path("api-auth/", include("rest_framework.urls")),
    ...
]

瀏覽器輸入 http://127.0.0.1:8000/api-auth/login/ ,就能看到登入畫面了
https://ithelp.ithome.com.tw/upload/images/20240929/20161866N15EF4DBOA.png

自定義權限類別

首先自定義權限可能有分兩種:

  • 對於欄位的權限:在昨天的文章中有有範例,例如使用上下文的方式對request.user去進行身份驗證,有通過才能拿到該欄位的值
  • 對於對象本身的權限

我們可以藉由繼承permission來自定義權限,例如我們希望只有身為Workspace的owner或是member才有該workspace的權限

# views.py
from rest_framework import permissions

class IsWorkspaceOwnerOrMembers(permissions.BasePermission):
    def has_object_permission(self, request, view, obj):
        return obj.owner == request.user or request.user in obj.members.all()
        

class WorkspaceDetail(GenericAPIView):
    queryset = Workspace.objects.all()
    serializer_class = WorkspaceDetailSerializer
    permission_classes = [IsAuthenticated, IsWorkspaceOwnerOrMembers] # 進行配置

因為admin不是owner或是members所以不能訪問,改用owner後便能成功訪問
https://ithelp.ithome.com.tw/upload/images/20240929/201618660QWlqxI44B.png

https://ithelp.ithome.com.tw/upload/images/20240929/201618665ZzXvItpEN.png

根據官方文檔,想要自定義權限就需改寫BasePermission,同時可以選擇以下方法進行改寫

  • .has_permission(self, request, view)
  • .has_object_permission(self, request, view, obj)
class BasePermission(metaclass=BasePermissionMetaclass):
    """
    A base class from which all permission classes should inherit.
    """

    def has_permission(self, request, view):
        """
        Return `True` if permission is granted, `False` otherwise.
        """
        return True

    def has_object_permission(self, request, view, obj):
        """
        Return `True` if permission is granted, `False` otherwise.
        """
        return True

需要注意的是,has_object_permission是等到has_permission有通過才會執行該方法

也就是即使我們沒有在這邊納入IsAuthenticated,依然要登入才能使用

class WorkspaceDetail(GenericAPIView):
    queryset = Workspace.objects.all()
    serializer_class = WorkspaceDetailSerializer
    permission_classes = [IsWorkspaceOwnerOrMembers]

角色存取控制(RBAC)

我們先前是在視圖中進行用戶是否具備某些權限的判斷,但這樣的缺點是管理權限的邏輯分散在不同的視圖之中,我們能透過角色存取控制的方式來更好的管理權限

所謂的RBAC不是對每個使用者分發權限,而是設定好角色並且設置相對應的權線。例如公司中,身為主管的角色就可能具備修改的權限,但是基層員工就只有只讀的權限

使用RBAC時需要注意以下幾點:

  1. 定義清晰的角色與對應的權限
  2. 遵循最小權限原則,避免潛在的威脅
  3. 定期根據實情來審核或是更新角色分配

而RBAC也有以下缺點:

  1. 需要在初始時仔細規劃角色定位與權限
  2. 很容易造成過多的角色
  3. 對於特殊需求時可能要另開角色,靈活度較低

了解RBAC後,我們要怎麼將這個概念套用在DRF的權限設計上呢?

我們先訂制我們的角色:

  • 所有者(owner):有所有權限,可以添加成員並且修改他們的權限
  • 編輯者(editor):可以查看工作區,對於文檔有編輯權限
  • 讀者(reader):只有查看該工作區與底下的文檔

根據這些條件去重新定義我們的model,然後進行遷移

User = get_user_model()

class Workspace(models.Model):
    name = models.CharField(max_length=100)
    owner = models.ForeignKey(
        User, on_delete=models.CASCADE, related_name="owned_workspaces"
    )
    # members = models.ManyToManyField(User, related_name="workspaces")
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.name

class WorkspaceMember(models.Model):
    ROLE_CHOICES = (
        ("OWNER", "Owner"),
        ("EDITOR", "Editor"),
        ("READER", "Reader"),
    )

    workspace = models.ForeignKey(
        Workspace, on_delete=models.CASCADE, related_name="memberships"
    )
    user = models.ForeignKey(
        User, on_delete=models.CASCADE, related_name="workspace_memberships"
    )
    role = models.CharField(max_length=10, choices=ROLE_CHOICES, default="READER")

    def __str__(self):
        return f"{self.user.username} - {self.workspace.name} ({self.role})"

class Document(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()
    workspace = models.ForeignKey(
        Workspace, on_delete=models.CASCADE, related_name="documents"
    )
    created_by = models.ForeignKey(
        User, on_delete=models.CASCADE, related_name="created_documents"
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.title

主要的改變:

  • 移除原本workspace中的member欄位
  • 建立新的model:WorkspaceMember,指向Workspace, User並且設定相對應的角色
  • Document則沒有改變

我們需要對Workspace模型設置一些自定義的方法來檢查權限

class Workspace(models.Model):
    name = models.CharField(max_length=100)
    owner = models.ForeignKey(
        User, on_delete=models.CASCADE, related_name="owned_workspaces"
    )
    # members = models.ManyToManyField(User, related_name="workspaces")
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.name

    def get_user_role(self, user):
        """
        Return the role of the user in the workspace.
        """
        if user == self.owner:
            return "OWNER"
        membership = self.memberships.filter(user=user).first()
        return membership.role if membership else None

    def can_read(self, user):
        return self.get_user_role(user) in ["OWNER", "EDITOR", "READER"]

    def can_edit(self, user):
        return self.get_user_role(user) in ["OWNER", "EDITOR"]

    def can_delete(self, user):
        return self.get_user_role(user) == "OWNER"

    def can_manage_members(self, user):
        return self.get_user_role(user) == "OWNER"

    def add_member(self, user, role="READER"):
        """
        Add a user to the workspace with the given role.
        """
        if role not in ["OWNER", "EDITOR", "READER"]:
            raise ValueError("Invalid role")
        WorkspaceMember.objects.create(workspace=self, user=user, role=role)

    def remove_member(self, user):
        self.memberships.filter(user=user).delete()

    def change_member_role(self, user, new_role):
        if new_role not in ["EDITOR", "READER"]:
            raise ValueError("Invalid role")
        membership = self.memberships.get(user=user)
        membership.role = new_role
        membership.save()

然後重新定義序列化器


class WorkspaceMemberSerializer(serializers.ModelSerializer):
    username = serializers.CharField(source="user.username", read_only=True)

    class Meta:
        model = WorkspaceMember
        fields = ["user", "username", "role"]

class WorkspaceSerializer(serializers.ModelSerializer):
    owner = serializers.CharField(source="owner.username", read_only=True)
    members = WorkspaceMemberSerializer(source="memberships", many=True, read_only=True)
    user_role = serializers.SerializerMethodField()

    class Meta:
        model = Workspace
        fields = ["id", "name", "owner", "members", "user_role", "created_at"]

    def get_user_role(self, obj):
        user = self.context["request"].user
        return obj.get_user_role(user)

class DocumentSerializer(serializers.ModelSerializer):
    created_by = serializers.CharField(source="created_by.username", read_only=True)

    class Meta:
        model = Document
        fields = [
            "id",
            "title",
            "content",
            "workspace",
            "created_by",
            "created_at",
            "updated_at",
        ]

WorkspaceMemberSerializer就是針對WorkspaceMember模型的序列化器

DocumentSerializer則是單純對應Document模型

WorkspaceSerializer對應Workspace模型,但是我們自定義get_user_role方法,根據上下文來判斷客戶端的角色是什麼

最後我們在視圖集合中進行使用

from rest_framework import viewsets, permissions
from rest_framework.decorators import action
from rest_framework.response import Response
from django.contrib.auth.models import User
from .models import Workspace
from .serializers import WorkspaceSerializer

class WorkspaceViewSet(viewsets.ModelViewSet):
    queryset = Workspace.objects.all()
    serializer_class = WorkspaceSerializer
    permission_classes = [permissions.IsAuthenticated]

    def get_queryset(self):
        # 用戶只能看到他們是成員的工作區
        return Workspace.objects.filter(memberships__user=self.request.user)

    def perform_create(self, serializer):
        workspace = serializer.save(owner=self.request.user)
        workspace.add_member(self.request.user, role="OWNER")

    def perform_destroy(self, instance):
        if instance.can_delete(self.request.user):
            instance.delete()
        else:
            raise permissions.PermissionDenied(
                "You don't have permission to delete this workspace."
            )

    @action(detail=True, methods=["post"])
    def add_member(self, request, pk=None):
        workspace = self.get_object()
        if not workspace.can_manage_members(request.user):
            raise permissions.PermissionDenied(
                "You don't have permission to add members."
            )

        user_id = request.data.get("user_id")
        role = request.data.get("role", "READER")
        user = User.objects.get(pk=user_id)

        workspace.add_member(user, role)
        return Response({"status": "member added"})
    
    @action(detail=True, methods=["post"])
    def change_member_role(self, request, pk=None):
        workspace = self.get_object()
        if not workspace.can_manage_members(request.user):
            raise permissions.PermissionDenied(
                "You don't have permission to change member roles."
            )

        user_id = request.data.get("user_id")
        new_role = request.data.get("role")
        user = User.objects.get(pk=user_id)

        workspace.change_member_role(user, new_role)
        return Response({"status": "member role changed"})

    @action(detail=True, methods=["post"])
    def remove_member(self, request, pk=None):
        workspace = self.get_object()
        if not workspace.can_manage_members(request.user):
            raise permissions.PermissionDenied(
                "You don't have permission to remove members."
            )

        user_id = request.data.get("user_id")
        user = User.objects.get(pk=user_id)

        workspace.remove_member(user)
        return Response({"status": "member removed"})
  • permission_classes中定permissions.IsAuthenticated,確保使用API的為註冊後的用戶
  • get_querysetWorkspace.objects.filter(memberships__user=self.request.user),讓用戶只能看到他們屬於memberships的workspace
  • perform_create 時將用戶註冊成owner欄位,並且將角色:owner賦予給該用戶
  • perform_destroy只有具備owner的member可以調用方法
  • 使用action自訂相關操作

最後配置好路由

from rest_framework.routers import DefaultRouter

from .views import WorkspaceViewSet

router = DefaultRouter()

router.register(r"workspaces", WorkspaceViewSet)

urlpatterns = [    
    path("", include(router.urls)),
]

我們嘗試進行相關的操作,我們使用bob用戶建立新的workspace,然後分發角色給alice,並且將alice的角色修改成editor

  • 登入後建立workspace

https://ithelp.ithome.com.tw/upload/images/20240929/20161866AS1tbNbbdL.png

  • 輸入add_member方法中所需要的資料,新增alice用戶做為該workspace的reader角色

https://ithelp.ithome.com.tw/upload/images/20240929/20161866rXIRLDzy22.png

https://ithelp.ithome.com.tw/upload/images/20240929/20161866fjH7zzGMnn.png

  • 賦予alice用戶新的角色

https://ithelp.ithome.com.tw/upload/images/20240929/20161866Yi2fxwzfDz.png

https://ithelp.ithome.com.tw/upload/images/20240929/20161866dfJ8kObwHH.png

看出來我們新的權限設計在運行上沒有什麼太大的問題,並且透過角色的分配,對於不同用戶的權限分配有了更明確且清晰的劃分

今日總結

  • 我們介紹了DRF中的認證與權限的區別
  • 了解到DRF中內建的權限設計,以及如果在視圖中要如何使用
  • 學習建立對於對象的自定義權限
  • 導入角色存取控制的設計理念,重新規劃我們的模型、序列化器與視圖集合,並且讓整體的權限規劃更符合生產模式的設計方式

參考資料

  • RBAC:https://www.splashtop.com/tw/blog/role-based-access-control

上一篇
Django REST framework: 序列化器的高級技巧與最佳實踐
下一篇
Django REST framework: 基礎認證防線 - BasicAuthentication與 TokenAuthentication
系列文
Django 2024: 從入門到SaaS實戰31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言