角色權限系統設計

權限系統最貴的錯誤不是規則寫錯,是預設值選錯。這篇記三件事:新使用者的預設角色、 Client 與 Server 兩層檢查各自負責什麼,以及權限定義為什麼必須只有一份。

系列・Nuxt 4 全棧實戰筆記・第 5/12 篇

認證回答「你是誰」,授權回答「你能做什麼」。前者做錯會登不進去,後者做錯不會有任何症狀——直到有人做了他不該做的事。

這篇是三件在實作前就該定下來的事。

一、預設角色是拒絕

最貴的一個錯誤不在規則寫得對不對,而在沒有規則時的預設行為。

只檢查登入狀態的守衛長這樣:

if (!loggedIn.value) return navigateTo('/login')
// 通過

有 OAuth 社交登入的系統上,這等於任何有 Google 帳號的人都能進來——因為註冊不需要任何內部審核,而「已登入」是他註冊完就自動獲得的狀態。

所以使用者表要有一個明確的「已登入但還沒有權限」狀態,我們用 unauthorized,而且它是資料庫欄位的預設值,不是應用程式判斷出來的:

const publicPages = ['/login', '/register', '/forbidden']
if (publicPages.includes(to.path)) return
if (!loggedIn.value) return navigateTo('/login')
if (user.value?.role === 'unauthorized') return navigateTo('/forbidden')

同樣的原則延伸到路由:沒有明確標示需要什麼權限的路由,預設應該是需要登入,而不是預設公開。公開頁面用白名單列舉,因為公開頁面的數量是可數的,需要保護的頁面則會一直長。

二、兩層檢查的分工

Client 端與 Server 端都要檢查,但它們不是互為備援——它們負責的是不同的事:

負責失效後果
Client middleware體驗:不要讓人點進一個馬上會失敗的頁面使用者看到一個空白頁或錯誤
Server handler安全:實際擋住操作未授權的人真的做了那件事

這個分工有個直接的推論:Client 端的檢查再完整也不能取代 Server 端,因為 API 可以被直接呼叫,瀏覽器裡的任何判斷都不在你的控制範圍內。

伺服器端的檢查就寫在需要它的那一支 handler 上,讓權限需求跟著端點走:

export default defineEventHandler(async (event) => {
  const { user } = await requireUserSession(event, {
    user: { role: ['admin', 'manager'] },
  })
  // 到這裡 user 保證存在且角色符合
})

有些權限不是靠角色就能判斷的——例如「資源的擁有者或管理員可以編輯」。這種要在取得資源之後判:

const isOwner = resource?.owner_id === user.id
const isAdmin = ['admin', 'manager'].includes(user.role)

if (!isOwner && !isAdmin) {
  throw createError({ statusCode: 403, message: '您沒有權限編輯此資源' })
}

注意這裡必須先讀資源才能判斷,也就是說讀取本身要先被允許。權限模型設計時要意識到這種「為了判斷而讀」的路徑存在,否則會寫出一個自己把自己擋住的規則。

三、權限定義只能有一份

權限邏輯最容易的死法是散開:middleware 裡有一份角色比對、選單元件裡有一份、每支 API 各寫各的。它們一開始一致,然後某次需求變更只改了其中兩處。

集中成一份定義,其他地方都引用它:

// app/utils/permission.ts
export const PERMISSIONS = {
  MANAGE_USERS: ['admin', 'manager'],
  EDIT_SETTINGS: ['admin'],
  VIEW_DATA: ['admin', 'manager', 'staff'],
} as const

export function can(role: string | undefined, permission: keyof typeof PERMISSIONS) {
  if (!role) return false
  return (PERMISSIONS[permission] as readonly string[]).includes(role)
}

介面上的顯示與否也走同一份定義,這樣「看得到但點了會失敗」的狀況就不會出現:

<UButton v-if="can(user?.role, 'MANAGE_USERS')" to="/manage/users">
  使用者管理
</UButton>

這裡要提醒一件事:用權限控制 UI 顯示是體驗,不是安全。按鈕藏起來不代表對應的 API 被擋住——那件事只有伺服器端的檢查算數。兩者共用同一份定義的好處是它們不會互相矛盾,不是其中一個可以省略。

為什麼不用階層數值

常見的做法是給角色編號(admin 4、manager 3、staff 2),比大小來判斷。它在角色真的是線性的時候很好用,但多數系統的角色遲早不是線性的——出現一個「只能看報表但不能改資料」的稽核角色時,它在數值軸上放哪裡都不對。

用權限清單而不是角色階層,是為了讓那一天到來時不必重寫整個模型:新角色只是在幾個 PERMISSIONS 條目裡被列進去或不列進去。代價是定義稍微囉嗦一點,而這個代價在第一次新增非線性角色時就回本了。

判斷你現在的模型撐不撐得住,有一個很快的方法:找一個「權限比較少但多一項特權」的角色,試著把它放進你的模型。 放得進去就沒事,放不進去——而你遲早會遇到這種角色——那要改的就不只是一個設定,是每一處寫著角色比大小的程式碼。