# 

# 04-4. 權限控制

&gt; ⏱️ **閱讀時間：** 18 分鐘
&gt; 🎯 **難度：** ⭐⭐⭐ (進階)

---

## 🤔 一句話解釋

**權限控制（Authorization）決定已認證的使用者可以存取哪些資源、執行哪些操作。**

---

## 🔐 權限模型

### 常見模型比較

```
┌─────────────────────────────────────────────────────────┐
│  1. RBAC（Role-Based Access Control）                   │
│     └─ 基於角色的存取控制                                │
│     └─ 使用者 → 角色 → 權限                             │
│     └─ 最常見，易於管理                                  │
├─────────────────────────────────────────────────────────┤
│  2. ABAC（Attribute-Based Access Control）              │
│     └─ 基於屬性的存取控制                                │
│     └─ 根據使用者屬性、資源屬性、環境條件判斷              │
│     └─ 更靈活，但較複雜                                  │
├─────────────────────────────────────────────────────────┤
│  3. ACL（Access Control List）                          │
│     └─ 存取控制列表                                      │
│     └─ 直接定義「誰」可以對「什麼」做「什麼」              │
│     └─ 細粒度控制，但難以擴展                             │
└─────────────────────────────────────────────────────────┘
```

---

## 🎭 RBAC 實作

### Model 設計

```python
from sqlalchemy import Column, Integer, String, Table, ForeignKey, Boolean
from sqlalchemy.orm import relationship, Mapped, mapped_column
from typing import List

# 使用者-角色 關聯表
user_roles = Table(
    &#34;user_roles&#34;,
    Base.metadata,
    Column(&#34;user_id&#34;, Integer, ForeignKey(&#34;users.id&#34;), primary_key=True),
    Column(&#34;role_id&#34;, Integer, ForeignKey(&#34;roles.id&#34;), primary_key=True),
)

# 角色-權限 關聯表
role_permissions = Table(
    &#34;role_permissions&#34;,
    Base.metadata,
    Column(&#34;role_id&#34;, Integer, ForeignKey(&#34;roles.id&#34;), primary_key=True),
    Column(&#34;permission_id&#34;, Integer, ForeignKey(&#34;permissions.id&#34;), primary_key=True),
)


class Permission(Base):
    &#34;&#34;&#34;權限&#34;&#34;&#34;
    __tablename__ = &#34;permissions&#34;

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(50), unique=True)  # e.g., &#34;users:read&#34;
    description: Mapped[str] = mapped_column(String(200), nullable=True)

    roles: Mapped[List[&#34;Role&#34;]] = relationship(
        &#34;Role&#34;,
        secondary=role_permissions,
        back_populates=&#34;permissions&#34;
    )


class Role(Base):
    &#34;&#34;&#34;角色&#34;&#34;&#34;
    __tablename__ = &#34;roles&#34;

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(50), unique=True)  # e.g., &#34;admin&#34;
    description: Mapped[str] = mapped_column(String(200), nullable=True)

    permissions: Mapped[List[&#34;Permission&#34;]] = relationship(
        &#34;Permission&#34;,
        secondary=role_permissions,
        back_populates=&#34;roles&#34;
    )
    users: Mapped[List[&#34;User&#34;]] = relationship(
        &#34;User&#34;,
        secondary=user_roles,
        back_populates=&#34;roles&#34;
    )


class User(Base):
    &#34;&#34;&#34;使用者&#34;&#34;&#34;
    __tablename__ = &#34;users&#34;

    id: Mapped[int] = mapped_column(primary_key=True)
    username: Mapped[str] = mapped_column(String(50), unique=True)
    email: Mapped[str] = mapped_column(String(100), unique=True)
    hashed_password: Mapped[str] = mapped_column(String(255))
    is_active: Mapped[bool] = mapped_column(default=True)
    is_superuser: Mapped[bool] = mapped_column(default=False)

    roles: Mapped[List[&#34;Role&#34;]] = relationship(
        &#34;Role&#34;,
        secondary=user_roles,
        back_populates=&#34;users&#34;
    )

    def has_permission(self, permission_name: str) -&gt; bool:
        &#34;&#34;&#34;檢查是否有特定權限&#34;&#34;&#34;
        if self.is_superuser:
            return True

        for role in self.roles:
            for permission in role.permissions:
                if permission.name == permission_name:
                    return True
        return False

    def has_role(self, role_name: str) -&gt; bool:
        &#34;&#34;&#34;檢查是否有特定角色&#34;&#34;&#34;
        if self.is_superuser:
            return True

        return any(role.name == role_name for role in self.roles)

    @property
    def permissions(self) -&gt; set[str]:
        &#34;&#34;&#34;取得所有權限&#34;&#34;&#34;
        perms = set()
        for role in self.roles:
            for permission in role.permissions:
                perms.add(permission.name)
        return perms
```

### 權限檢查依賴項

```python
# app/api/deps.py
from fastapi import Depends, HTTPException, status
from typing import Callable, List

from app.models import User
from app.api.deps import get_current_active_user


def require_permissions(*required_permissions: str) -&gt; Callable:
    &#34;&#34;&#34;要求特定權限的依賴項&#34;&#34;&#34;
    async def permission_checker(
        current_user: User = Depends(get_current_active_user)
    ) -&gt; User:
        # 超級使用者擁有所有權限
        if current_user.is_superuser:
            return current_user

        # 檢查使用者是否有所有必要權限
        user_permissions = current_user.permissions
        missing = set(required_permissions) - user_permissions

        if missing:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail=f&#34;Missing permissions: {&#39;, &#39;.join(missing)}&#34;
            )

        return current_user

    return permission_checker


def require_roles(*required_roles: str) -&gt; Callable:
    &#34;&#34;&#34;要求特定角色的依賴項&#34;&#34;&#34;
    async def role_checker(
        current_user: User = Depends(get_current_active_user)
    ) -&gt; User:
        if current_user.is_superuser:
            return current_user

        user_roles = {role.name for role in current_user.roles}
        if not user_roles.intersection(required_roles):
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail=f&#34;Requires one of these roles: {&#39;, &#39;.join(required_roles)}&#34;
            )

        return current_user

    return role_checker
```

### 使用方式

```python
# app/api/routes/users.py
from fastapi import APIRouter, Depends
from app.api.deps import require_permissions, require_roles
from app.models import User

router = APIRouter(prefix=&#34;/users&#34;, tags=[&#34;users&#34;])


@router.get(&#34;/&#34;)
async def list_users(
    current_user: User = Depends(require_permissions(&#34;users:read&#34;))
):
    &#34;&#34;&#34;列出使用者（需要 users:read 權限）&#34;&#34;&#34;
    pass


@router.post(&#34;/&#34;)
async def create_user(
    current_user: User = Depends(require_permissions(&#34;users:create&#34;))
):
    &#34;&#34;&#34;建立使用者（需要 users:create 權限）&#34;&#34;&#34;
    pass


@router.delete(&#34;/{user_id}&#34;)
async def delete_user(
    user_id: int,
    current_user: User = Depends(require_permissions(&#34;users:delete&#34;))
):
    &#34;&#34;&#34;刪除使用者（需要 users:delete 權限）&#34;&#34;&#34;
    pass


@router.get(&#34;/admin-panel&#34;)
async def admin_panel(
    current_user: User = Depends(require_roles(&#34;admin&#34;, &#34;moderator&#34;))
):
    &#34;&#34;&#34;管理面板（需要 admin 或 moderator 角色）&#34;&#34;&#34;
    pass
```

---

## 🏷️ 權限命名慣例

### 資源:操作 格式

```python
# 權限命名慣例
PERMISSIONS = {
    # 使用者管理
    &#34;users:read&#34;: &#34;查看使用者&#34;,
    &#34;users:create&#34;: &#34;建立使用者&#34;,
    &#34;users:update&#34;: &#34;更新使用者&#34;,
    &#34;users:delete&#34;: &#34;刪除使用者&#34;,

    # 文章管理
    &#34;posts:read&#34;: &#34;查看文章&#34;,
    &#34;posts:create&#34;: &#34;建立文章&#34;,
    &#34;posts:update&#34;: &#34;更新文章&#34;,
    &#34;posts:delete&#34;: &#34;刪除文章&#34;,
    &#34;posts:publish&#34;: &#34;發布文章&#34;,

    # 評論管理
    &#34;comments:read&#34;: &#34;查看評論&#34;,
    &#34;comments:create&#34;: &#34;建立評論&#34;,
    &#34;comments:delete&#34;: &#34;刪除評論&#34;,
    &#34;comments:moderate&#34;: &#34;審核評論&#34;,

    # 系統管理
    &#34;admin:access&#34;: &#34;存取管理後台&#34;,
    &#34;admin:settings&#34;: &#34;修改系統設定&#34;,
}


# 預設角色
DEFAULT_ROLES = {
    &#34;user&#34;: [&#34;posts:read&#34;, &#34;comments:read&#34;, &#34;comments:create&#34;],
    &#34;author&#34;: [&#34;posts:read&#34;, &#34;posts:create&#34;, &#34;posts:update&#34;, &#34;comments:*&#34;],
    &#34;moderator&#34;: [&#34;posts:*&#34;, &#34;comments:*&#34;, &#34;users:read&#34;],
    &#34;admin&#34;: [&#34;*&#34;],  # 所有權限
}
```

---

## 🔒 資源擁有者檢查

### 只能存取自己的資源

```python
from fastapi import HTTPException, status

async def check_resource_owner(
    resource_user_id: int,
    current_user: User
) -&gt; bool:
    &#34;&#34;&#34;檢查是否為資源擁有者&#34;&#34;&#34;
    if current_user.is_superuser:
        return True

    if resource_user_id != current_user.id:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail=&#34;You don&#39;t have permission to access this resource&#34;
        )

    return True


# 使用範例
@router.get(&#34;/posts/{post_id}&#34;)
async def get_post(
    post_id: int,
    current_user: User = Depends(get_current_active_user),
    db: AsyncSession = Depends(get_db)
):
    &#34;&#34;&#34;取得文章（自己的文章或有權限）&#34;&#34;&#34;
    post = await db.get(Post, post_id)

    if not post:
        raise HTTPException(status_code=404, detail=&#34;Post not found&#34;)

    # 公開文章任何人都可以看
    if post.is_published:
        return post

    # 未發布的文章只有擁有者或有權限的人可以看
    if post.author_id != current_user.id:
        if not current_user.has_permission(&#34;posts:read_all&#34;):
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail=&#34;You don&#39;t have permission to view this post&#34;
            )

    return post


@router.put(&#34;/posts/{post_id}&#34;)
async def update_post(
    post_id: int,
    post_data: PostUpdate,
    current_user: User = Depends(get_current_active_user),
    db: AsyncSession = Depends(get_db)
):
    &#34;&#34;&#34;更新文章（只有擁有者或管理員可以更新）&#34;&#34;&#34;
    post = await db.get(Post, post_id)

    if not post:
        raise HTTPException(status_code=404, detail=&#34;Post not found&#34;)

    # 檢查是否為擁有者或有 posts:update_all 權限
    is_owner = post.author_id == current_user.id
    can_update_all = current_user.has_permission(&#34;posts:update_all&#34;)

    if not is_owner and not can_update_all:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail=&#34;You can only update your own posts&#34;
        )

    # 更新文章
    for key, value in post_data.dict(exclude_unset=True).items():
        setattr(post, key, value)

    await db.commit()
    return post
```

---

## 🎨 裝飾器方式

### 權限裝飾器

```python
from functools import wraps
from typing import Callable, List


def permission_required(*permissions: str):
    &#34;&#34;&#34;權限檢查裝飾器&#34;&#34;&#34;
    def decorator(func: Callable):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            # 從 kwargs 取得 current_user
            current_user = kwargs.get(&#34;current_user&#34;)

            if not current_user:
                raise HTTPException(
                    status_code=status.HTTP_401_UNAUTHORIZED,
                    detail=&#34;Not authenticated&#34;
                )

            if current_user.is_superuser:
                return await func(*args, **kwargs)

            user_perms = current_user.permissions
            missing = set(permissions) - user_perms

            if missing:
                raise HTTPException(
                    status_code=status.HTTP_403_FORBIDDEN,
                    detail=f&#34;Missing permissions: {&#39;, &#39;.join(missing)}&#34;
                )

            return await func(*args, **kwargs)

        return wrapper
    return decorator


# 使用方式（不推薦，因為 FastAPI 的依賴注入更好用）
@router.get(&#34;/users&#34;)
@permission_required(&#34;users:read&#34;)
async def list_users(current_user: User = Depends(get_current_active_user)):
    pass
```

---

## 📝 完整範例：部落格權限系統

```python
# app/api/routes/blog.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select

from app.database import get_db
from app.models import User, Post
from app.api.deps import get_current_active_user, require_permissions

router = APIRouter(prefix=&#34;/blog&#34;, tags=[&#34;blog&#34;])


class PostPermissions:
    &#34;&#34;&#34;文章權限檢查&#34;&#34;&#34;

    @staticmethod
    async def can_view(post: Post, user: User | None) -&gt; bool:
        &#34;&#34;&#34;是否可以查看文章&#34;&#34;&#34;
        # 公開文章任何人都可以看
        if post.is_published:
            return True

        # 未登入不能看草稿
        if not user:
            return False

        # 擁有者可以看自己的草稿
        if post.author_id == user.id:
            return True

        # 有 posts:read_all 權限可以看所有文章
        return user.has_permission(&#34;posts:read_all&#34;)

    @staticmethod
    async def can_edit(post: Post, user: User) -&gt; bool:
        &#34;&#34;&#34;是否可以編輯文章&#34;&#34;&#34;
        # 擁有者可以編輯自己的文章
        if post.author_id == user.id:
            return True

        # 有 posts:update_all 權限可以編輯所有文章
        return user.has_permission(&#34;posts:update_all&#34;)

    @staticmethod
    async def can_delete(post: Post, user: User) -&gt; bool:
        &#34;&#34;&#34;是否可以刪除文章&#34;&#34;&#34;
        # 擁有者可以刪除自己的文章
        if post.author_id == user.id:
            return True

        # 有 posts:delete_all 權限可以刪除所有文章
        return user.has_permission(&#34;posts:delete_all&#34;)

    @staticmethod
    async def can_publish(post: Post, user: User) -&gt; bool:
        &#34;&#34;&#34;是否可以發布文章&#34;&#34;&#34;
        # 擁有者有 posts:publish 權限才能發布
        if post.author_id == user.id:
            return user.has_permission(&#34;posts:publish&#34;)

        # 有 posts:publish_all 權限可以發布所有文章
        return user.has_permission(&#34;posts:publish_all&#34;)


@router.get(&#34;/posts&#34;)
async def list_posts(
    skip: int = 0,
    limit: int = 20,
    include_drafts: bool = False,
    current_user: User | None = Depends(get_current_user_optional),
    db: AsyncSession = Depends(get_db)
):
    &#34;&#34;&#34;列出文章&#34;&#34;&#34;
    stmt = select(Post)

    if include_drafts:
        # 只有有權限的人才能看到草稿
        if not current_user:
            raise HTTPException(status_code=401, detail=&#34;Login required&#34;)

        if not current_user.has_permission(&#34;posts:read_all&#34;):
            # 只能看自己的草稿
            stmt = stmt.where(
                (Post.is_published == True) |
                (Post.author_id == current_user.id)
            )
    else:
        stmt = stmt.where(Post.is_published == True)

    stmt = stmt.offset(skip).limit(limit)
    result = await db.execute(stmt)
    return result.scalars().all()


@router.get(&#34;/posts/{post_id}&#34;)
async def get_post(
    post_id: int,
    current_user: User | None = Depends(get_current_user_optional),
    db: AsyncSession = Depends(get_db)
):
    &#34;&#34;&#34;取得單篇文章&#34;&#34;&#34;
    post = await db.get(Post, post_id)

    if not post:
        raise HTTPException(status_code=404, detail=&#34;Post not found&#34;)

    if not await PostPermissions.can_view(post, current_user):
        raise HTTPException(status_code=403, detail=&#34;Permission denied&#34;)

    return post


@router.post(&#34;/posts&#34;)
async def create_post(
    post_data: PostCreate,
    current_user: User = Depends(require_permissions(&#34;posts:create&#34;)),
    db: AsyncSession = Depends(get_db)
):
    &#34;&#34;&#34;建立文章&#34;&#34;&#34;
    post = Post(
        **post_data.dict(),
        author_id=current_user.id,
        is_published=False
    )
    db.add(post)
    await db.commit()
    await db.refresh(post)
    return post


@router.put(&#34;/posts/{post_id}&#34;)
async def update_post(
    post_id: int,
    post_data: PostUpdate,
    current_user: User = Depends(get_current_active_user),
    db: AsyncSession = Depends(get_db)
):
    &#34;&#34;&#34;更新文章&#34;&#34;&#34;
    post = await db.get(Post, post_id)

    if not post:
        raise HTTPException(status_code=404, detail=&#34;Post not found&#34;)

    if not await PostPermissions.can_edit(post, current_user):
        raise HTTPException(status_code=403, detail=&#34;Permission denied&#34;)

    for key, value in post_data.dict(exclude_unset=True).items():
        setattr(post, key, value)

    await db.commit()
    await db.refresh(post)
    return post


@router.post(&#34;/posts/{post_id}/publish&#34;)
async def publish_post(
    post_id: int,
    current_user: User = Depends(get_current_active_user),
    db: AsyncSession = Depends(get_db)
):
    &#34;&#34;&#34;發布文章&#34;&#34;&#34;
    post = await db.get(Post, post_id)

    if not post:
        raise HTTPException(status_code=404, detail=&#34;Post not found&#34;)

    if not await PostPermissions.can_publish(post, current_user):
        raise HTTPException(status_code=403, detail=&#34;Permission denied&#34;)

    post.is_published = True
    post.published_at = datetime.utcnow()
    await db.commit()

    return {&#34;message&#34;: &#34;Post published successfully&#34;}


@router.delete(&#34;/posts/{post_id}&#34;)
async def delete_post(
    post_id: int,
    current_user: User = Depends(get_current_active_user),
    db: AsyncSession = Depends(get_db)
):
    &#34;&#34;&#34;刪除文章&#34;&#34;&#34;
    post = await db.get(Post, post_id)

    if not post:
        raise HTTPException(status_code=404, detail=&#34;Post not found&#34;)

    if not await PostPermissions.can_delete(post, current_user):
        raise HTTPException(status_code=403, detail=&#34;Permission denied&#34;)

    await db.delete(post)
    await db.commit()

    return {&#34;message&#34;: &#34;Post deleted successfully&#34;}
```

---

## ✅ 重點總結

### 權限模型選擇

| 模型 | 適用場景 |
|------|----------|
| RBAC | 大多數應用（推薦）|
| ABAC | 需要複雜條件判斷 |
| ACL | 細粒度資源控制 |

### RBAC 要點

```
使用者 → 角色 → 權限

User  →  admin   →  users:*, posts:*, admin:*
      →  author  →  posts:create, posts:update
      →  user    →  posts:read, comments:create
```

### 實作建議

1. **權限命名**：使用 `資源:操作` 格式
2. **擁有者檢查**：使用者只能存取自己的資源
3. **依賴注入**：用 FastAPI Depends 實現權限檢查
4. **超級使用者**：保留一個可以繞過所有權限的角色

---

## 🎤 面試這樣答

### Q: RBAC 和 ABAC 的差別是什麼？

**答案：**

&gt; **RBAC（Role-Based）：**
&gt; - 使用者 → 角色 → 權限
&gt; - 簡單易管理
&gt; - 例如：admin 角色有所有權限
&gt;
&gt; **ABAC（Attribute-Based）：**
&gt; - 基於屬性判斷（使用者屬性、資源屬性、環境）
&gt; - 更靈活但較複雜
&gt; - 例如：部門經理只能存取自己部門的資料
&gt;
&gt; **選擇建議：**
&gt; - 大多數應用用 RBAC 就夠了
&gt; - 需要複雜條件判斷時用 ABAC

---

**上一篇：** [04-3. OAuth 2.0](./04-3)
**下一篇：** [04-5. 安全最佳實踐](./04-5)

---

最後更新：2025-12-17


---

> 作者: luk  
> URL: https://yoru-karu-blog-lalaluk-52581ac5e0cef170a3c8922c19182ecb6f7bd604.gitlab.io/posts/tutorial/fastapi/04-4/  

