Cholate
← 全部文章

2026-06-20 · 5 min

把 Casbin 接進 Node.js 專案

從安裝到中介層整合,用最小可行的 model 與 policy 讓 Express 專案跑起 RBAC。

為什麼#

上一篇講完 RBAC 的概念,這篇要動手把它接進實際的 Node.js 專案。我常看到的錯誤示範是:工程師把「角色判斷」寫成散落在各個路由裡的 if (user.role === 'admin'),改一次規則要搜尋整個專案,還常常漏改。這篇的目標,是用最小可行的方式,讓 Express 專案透過 Casbin 這個函式庫,把權限判斷集中成一個中介層,之後不管加幾個角色、幾條規則,都只改設定檔,不動路由程式碼。

Casbin 而不是自己手刻一套判斷邏輯,理由很單純:RBACABAC(屬性存取控制)這些模型的規則語法已經被設計、驗證過很多年,直接套用比自己重新發明一套「我覺得夠用」的判斷式可靠得多。而且 Casbin 支援多種語言的實作(Node.js、Go、Java、Python 都有對應版本),同一套 model 語法可以跨專案沿用,團隊換了主力語言,權限規則的思維方式不用重新學一次。

怎麼做#

先裝套件:

pnpm add casbin express

Casbin 的核心分成兩個檔案:model(規則的「語法」)跟 policy(規則的「內容」)。先寫 model,這份幾乎每個專案都可以照抄:

[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[role_definition]
g = _, _

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act

這份 model 講的是:每次請求會帶著「誰(sub)、要動哪個資源(obj)、要做什麼動作(act)」,Casbin 拿這三個值去比對 policy 清單,符合就放行。g 那行則是「角色繼承」的定義——讓一個使用者可以對應到一個角色

接著是 policy,把上一篇表格裡的規則翻譯成 Casbin 看得懂的格式:

p, moderator, post, delete
p, moderator, member, block
p, admin, post, delete
p, admin, member, block
p, admin, group, manage

g, alice, moderator
g, bob, admin

p 開頭的是權限規則(角色、資源、動作),g 開頭的是「使用者屬於哪個角色」。值得一提的是,g 這行其實還能表達「角色繼承」,不只是「使用者屬於角色」——你可以寫 g, admin, moderator,代表管理員自動繼承版主的所有權限,不用把版主能做的事在管理員底下重複寫一次。角色一多、階層一深的時候,這個繼承機制能省下不少重複的 policy 行數,也讓「管理員理論上什麼都能做」這件事在設定檔裡看得一目了然,不用靠工程師口頭約定。

有了這兩個檔案,接下來就是把 Casbin 接進 Express 的中介層:

import { newEnforcer } from 'casbin';
import type { Request, Response, NextFunction } from 'express';

const enforcerPromise = newEnforcer('model.conf', 'policy.csv');

export function requirePermission(obj: string, act: string) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const enforcer = await enforcerPromise;
    const sub = req.user?.id; // 假設前面已有登入中介層填好 req.user
    const allowed = await enforcer.enforce(sub, obj, act);
    if (!allowed) {
      return res.status(403).json({ error: '沒有權限執行此操作' });
    }
    next();
  };
}

路由裡只要掛上這個中介層,完全不用再寫 if (role === ...)

import express from 'express';
import { requirePermission } from './middleware/rbac';

const app = express();

app.delete('/posts/:id', requirePermission('post', 'delete'), (req, res) => {
  // 真正刪除貼文的邏輯,這裡已經確定使用者有權限
  res.sendStatus(204);
});
▸ 深入細節:policy 存在檔案裡,正式環境怎麼辦?

範例用 policy.csv 純粹是方便本地開發,實際上線後你會想把 policy 存進資料庫,這樣後台才能即時調整角色權限,不用重新部署。Casbin 對這件事的解法是「adapter」——把 newEnforcer('model.conf', 'policy.csv') 換成 newEnforcer('model.conf', new PostgresAdapter(pool))(官方與社群提供多種 adapter,涵蓋 Postgres、MongoDB、Redis 等),中介層的程式碼完全不用改,因為 enforcer.enforce() 的介面是一致的。這也是分離「規則語法」(model)與「規則內容」(policy)的好處:儲存方式換了,判斷邏輯不用跟著換。

換成資料庫 adapter 之後,通常還會搭配一個簡單的後台頁面,讓非工程師的營運人員也能自己新增角色、調整權限,不用每次都排工程師的時間去改 CSV 檔案重新部署。這也是為什麼一開始就把「規則語法」跟「規則內容」分開設計如此重要——內容誰都能改,語法只有工程師需要懂。

我自己在專案裡踩過一個坑:一開始把 enforcer 建立成每個請求重新讀一次 policy 檔案,效能很差。後來才發現 Casbin 的 enforcer 應該在應用啟動時建立一次、常駐在記憶體裡,policy 有更新時呼叫 enforcer.loadPolicy() 重新載入即可,不需要每個請求都重建。

另一個常見的地雷,是把權限判斷只做在前端。你可能會想,既然前端已經根據 user.role 決定要不要顯示「刪除」按鈕,那後端是不是可以偷懶不檢查?答案是不行——前端的判斷只是「體驗上」不讓使用者看到不該按的按鈕,實際的攔截一定要在後端,因為任何人都可以繞過畫面直接打 API。這也是為什麼前面的中介層寫在 Express 路由層,而不是寫在前端元件裡:Casbinenforcer.enforce() 必須是伺服器端最後一道防線,前端頂多是錦上添花。

把權限規則寫好之後,我會建議直接針對 policy 寫測試,而不是手動點畫面確認,畢竟角色一多,人工測試很容易漏掉某個組合:

import { newEnforcer } from 'casbin';
import { describe, it, expect, beforeAll } from 'vitest';

describe('rbac policy', () => {
  let enforcer: Awaited<ReturnType<typeof newEnforcer>>;

  beforeAll(async () => {
    enforcer = await newEnforcer('model.conf', 'policy.csv');
  });

  it('版主可以刪除貼文,一般成員不行', async () => {
    expect(await enforcer.enforce('alice', 'post', 'delete')).toBe(true);
    expect(await enforcer.enforce('carol', 'post', 'delete')).toBe(false);
  });

  it('只有管理員可以管理社團設定', async () => {
    expect(await enforcer.enforce('bob', 'group', 'manage')).toBe(true);
    expect(await enforcer.enforce('alice', 'group', 'manage')).toBe(false);
  });
});

這樣一來,之後每次調整 policy,只要重跑這份測試,就能確認「原本該擋的還是擋得住、原本該放行的沒有被誤擋」,不用每次改完都手動切換好幾個帳號重新點一遍畫面。

重點回顧#

  • model.conf 定義規則的「語法」,policy.csv(或資料庫)定義規則的「內容」,兩者分離讓儲存方式可以換,判斷邏輯不用動。
  • Express 中介層只需要呼叫一次 enforcer.enforce(sub, obj, act),路由本身完全不用出現角色判斷式。
  • enforcer 應該在應用啟動時建立一次並常駐記憶體,避免每個請求重複讀取 policy 檔案拖垮效能。
  • 正式環境把 policy 換成資料庫 adapter,中介層程式碼不用改,這正是分離 model/policy 帶來的彈性。
  • 權限檢查一定要做在後端,前端根據角色隱藏按鈕只是體驗優化,不是安全防線。
  • 針對 policy 直接寫測試,比每次改完手動切換帳號點畫面更可靠,也更快發現漏改的規則。

延伸閱讀#

  • Casbin Node.js 文件
  • 完整範例:見本篇 frontmatter 的 source 連結
  • 系列上一篇:〈RBAC 是什麼?從 FB 社團看權限模型〉

#casbin#rbac#nodejs