本頻道之前寫過後端存取控制套件 VaktOso,這篇則來談談前端部份的權限檢查機制。

還是快速介紹一下常見的存取控制模式 RBAC 與 ABAC。

RBAC 全稱 role-based access control,以「角色」為基礎的權限控制,最常見的例子就是 Admin,不論我的帳號為何,只要被賦予了 Admin 角色,我就能做任何事。

相對於權限霸總 Admin,另一個角色 User 就弱小得多,通常只能讀取、查詢,不能異動資料,大多新帳號建立之初被賦予的就是 User 角色。

ABAC 全稱 attribute-based access control,它是較細粒度的存取控制模式,組織 A 旗下的用戶只能看到組織 A 的資料,看不到組織 B 的資料。

之所以能作到這些 XXAC,除了靠自己寫出很巢的 if 邏輯外,最好還是利用專門的套件來幫我們制定以及執行檢查邏輯。

在之前的文章談到後端存取控制,在前端這邊,好像可以沿用但又不完全一樣,舉個例子,前端的主選單完全與後端無關,Admin 角色登入可以看到用戶管理,別的角色看不到,或者 Admin 在畫面上多了幾顆超能力按鈕,別的角色看不到,即便後端也制定了只有 Admin 能對用戶帳號增刪改,別人做就吐 403 Forbidden,但這不表示前端可以大剌剌把那些超能力選單、按鈕都放出來給大家看,而這些超能力選單、按鈕又是 UI 層的東西,與後端的權限規則不直接相關,所以無法避免的前端得有自己的一套權限制定與檢查機制,這裡我們選用 CASL。

CASL

CASL(唸作 /ˈkæsəl/)是 JavaScript 的存取控制套件,起手式當然是先安裝:

$ npm install @casl/ability

最最最簡單的用法:

// ability.ts

import { defineAbility } from '@casl/ability'

const abilityFor = (roles: string[]) => defineAbility((can) => {
    if (roles.includes('Admin')) {
        can('see menu item', 'UsersMenu')
        can('see menu item', 'SystemMenu')
        can('delete', 'SystemLog')
    }
})

export default abilityFor

這段程式的核心方法是 defineAbility()can(),前者顧名思義就是﹍define ability﹍,而那 can() 的第一個參數是「幹什麼」,第二個參數是「對什麼」,就上例而言,只有 Admin 能:

  • 見到用戶管理選單
  • 見到系統設定選單
  • 刪除系統紀錄

當然這些只是規則,真正要用得呼叫它:

// app.ts

import { abilityFor } from '@/ability'

def getUserRoles() { return ['admin'] }

const roles = getUserRoles()

const ability = abilityFor(roles)

const menusItems: MenuOptions[] = [
    { label: 'home', name: '首頁', icon: House, route: '/' },
    { label: 'transaction', name: '交易清單', icon: List, route: '/transactions', },
]
if (ability.can('see menu item', 'UsersMenu')) { // true
    menusItems.push({ label: 'user', name: '用戶管理', icon: Users, route: '/users' })
}

我們先呼叫 abilityFor(),餵它當前用戶角色,做出 ability 物件,然後呼叫 ability.can() 確認是否可行,注意這裡的 ability.can() 不同於制定規則時的 can(),這裡的 ability.can() 用於檢查行為與對象有沒有准許。

因為範例中的角色是 Admin,當然是准許啦,就這樣 Admin 登入就可以看到用戶管理選單囉,反之,如果不是 Admin,拿到的答案會是 false,選單也就不可見囉。

不可見歸不可見,如果有個反派他手動打網址進 /users 該怎麼辦?舉一反三一下,先制定規則,然後在前端路由程式那邊一樣呼叫 ability.can() 來確認就好,拿到 False 就把他擋掉。

對於現代化,具反應性、資料綁定的前端生態,例如 Vue.js,可以把那 const ability = abilityFor(roles) 放到 Pinia store 裡面,如此可以達到跨頁面使用,省去一直重複生成 ability 物件的工作,像我是弄個 authStore,所有與認證、授權有關的物件與方法都在裡面,大概長這樣:

import abilityFor from '@/ability'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'

export const useAuthStore = defineStore(
  // id
  'auth',

  // storeSetup
  () => {
    // States
    const token = ref<Token>()

    // Getters
    const payload = computed(() => { return token.value.payload })
    const ability = computed(() => {
      return abilityFor(payload.value?.roles)
    })

    // Actions
    async function signIn() { }
    async function refresh() { }
    function signOut() { }

    // return states, getters, actions
    return { token, payload, ability, signIn, refresh, signOut }
  },
)

如此一來,只要用戶登出換帳號登入,就會拿到新的 token,也就會拿到新的角色,也就會拿到只屬於自己的 ability.can() 答案了。

本文只介紹 CASL 最簡單的用法與概念,它能做的還多得多,不過簡單的版本已經能滿足一些粗粒度需求,其他以後有遇到再說吧。