從「admin flag」到 RBAC 的演進

最初的 auth 設計通常長這樣:

// 第一版:boolean flag
interface User {
  id: string;
  isAdmin: boolean;
}
 
// route
if (!req.user.isAdmin) return res.status(403).json({ error: 'Forbidden' });

這能用,直到業務長大:

  • 「有個角色可以刪文章但不能刪使用者」
  • 「Finance 部門可以看訂單但不能改訂單」
  • 「分店店長只能管自己店的資料」

這時候 boolean flag 就不夠用了。你開始加 isManagerisFinanceisSuperAdmin,然後 route handler 裡出現:

if (!req.user.isAdmin && !req.user.isManager && req.user.shopId !== req.params.shopId) {
  return res.status(403).json({ error: 'Forbidden' });
}

這是 RBAC 設計要解的問題:讓授權邏輯可以擴充,不是讓每個 route 自己判斷。


RBAC 的三層模型

標準 RBAC 有三個實體:User → Role → Permission

User (alice)
  └─ Role (content_manager)
       ├─ Permission (posts:create)
       ├─ Permission (posts:update)
       ├─ Permission (posts:delete)
       └─ Permission (posts:read)

User (bob)
  └─ Role (viewer)
       └─ Permission (posts:read)

Permission 用 resource:action 格式——清楚、可列舉、好做 guard:

users:create   users:read    users:update   users:delete
posts:create   posts:read    posts:update   posts:delete
orders:read    orders:refund
reports:export

DB Schema 設計

-- 角色表
CREATE TABLE roles (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name VARCHAR(50) UNIQUE NOT NULL,  -- 'admin', 'content_manager', 'viewer'
  description TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW()
);
 
-- 權限表
CREATE TABLE permissions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  resource VARCHAR(50) NOT NULL,  -- 'posts', 'users', 'orders'
  action VARCHAR(50) NOT NULL,    -- 'create', 'read', 'update', 'delete'
  UNIQUE (resource, action)
);
 
-- 角色權限 mapping
CREATE TABLE role_permissions (
  role_id UUID REFERENCES roles(id) ON DELETE CASCADE,
  permission_id UUID REFERENCES permissions(id) ON DELETE CASCADE,
  PRIMARY KEY (role_id, permission_id)
);
 
-- 使用者角色 mapping(允許一個 user 有多個角色)
CREATE TABLE user_roles (
  user_id UUID REFERENCES users(id) ON DELETE CASCADE,
  role_id UUID REFERENCES roles(id) ON DELETE CASCADE,
  PRIMARY KEY (user_id, role_id)
);

這個 schema 的 production seeder(Essential tier):

// database/seeders/essential/02-roles.ts
const roles = [
  { name: 'admin', description: '系統管理員' },
  { name: 'content_manager', description: '內容管理' },
  { name: 'viewer', description: '唯讀' },
];
 
// database/seeders/essential/03-permissions.ts
const permissions = [
  { resource: 'users', action: 'create' },
  { resource: 'users', action: 'read' },
  { resource: 'users', action: 'update' },
  { resource: 'users', action: 'delete' },
  { resource: 'posts', action: 'create' },
  { resource: 'posts', action: 'read' },
  { resource: 'posts', action: 'update' },
  { resource: 'posts', action: 'delete' },
  // ...
];
 
// database/seeders/essential/04-role-permissions.ts
// admin → 全部 permission
// content_manager → posts:* only
// viewer → posts:read, users:read

Guard 讀 JWT 還是查 DB?

兩種策略,各有適用場景:

Strategy A:Permission 存在 JWT payload

// JWT payload 包含 permissions
{
  sub: "user_123",
  roles: ["content_manager"],
  permissions: ["posts:create", "posts:update", "posts:delete", "posts:read"]
}
 
// Guard:不查 DB
export const requirePermission = (perm: string) => (req, res, next) => {
  if (!req.user.permissions.includes(perm)) {
    return res.status(403).json({ error: 'Forbidden' });
  }
  next();
};
 
router.delete('/posts/:id', authenticate, requirePermission('posts:delete'), handler);

優點:guard 是純 memory 操作,零 DB 查詢。
缺點:permission 變動後需要等 access token 過期才生效(15 分鐘內的 permission 變更不即時)。

Strategy B:JWT 只存 roles,guard 查 DB

// JWT payload 只存 roles
{ sub: "user_123", roles: ["content_manager"] }
 
// Guard:查 DB 取 permissions
export const requirePermission = (perm: string) => async (req, res, next) => {
  const [resource, action] = perm.split(':');
  const hasPermission = await db.query(`
    SELECT 1 FROM user_roles ur
    JOIN role_permissions rp ON ur.role_id = rp.role_id
    JOIN permissions p ON rp.permission_id = p.id
    WHERE ur.user_id = $1 AND p.resource = $2 AND p.action = $3
  `, [req.user.sub, resource, action]);
 
  if (!hasPermission.rows.length) {
    return res.status(403).json({ error: 'Forbidden' });
  }
  next();
};

優點:permission 變動即時生效。
缺點:每個需要授權的 request 多一次 DB query(可以加 Redis cache 緩解)。

怎麼選:一般 SaaS 系統用 Strategy A 夠了(15 分鐘時差可接受);金融、合規要求即時 revoke 的場景用 Strategy B + Redis cache。


直接 Permission vs 角色 Permission

大多數系統只需要 Role → Permission 這一層。但有些場景需要給特定使用者「額外的 permission」——例如某個 viewer 臨時需要可以 export reports:

-- 直接給 user permission(不透過 role)
CREATE TABLE user_permissions (
  user_id UUID REFERENCES users(id) ON DELETE CASCADE,
  permission_id UUID REFERENCES permissions(id) ON DELETE CASCADE,
  granted_by UUID REFERENCES users(id),  -- 誰給的
  expires_at TIMESTAMPTZ,                -- 臨時授權
  PRIMARY KEY (user_id, permission_id)
);

Guard 查詢時同時考慮 role permissions + direct permissions(用 UNION)。這讓 RBAC 更靈活,但也更複雜——只有真的需要時才加這層。


ABAC:當 RBAC 不夠用

RBAC 解決「誰能做什麼」,但解不了「誰能對哪些資料做什麼」。

典型例子:分店店長只能改自己店的員工——這不是 permission 的問題,是 resource ownership 的問題。

// RBAC 解不了這個
router.put('/shops/:shopId/employees/:empId', authenticate, requirePermission('employees:update'), async (req, res) => {
  // 這裡還需要額外驗證:req.user.shopId === req.params.shopId
  if (req.user.role !== 'admin' && req.user.shopId !== req.params.shopId) {
    return res.status(403).json({ error: 'Forbidden' });
  }
  // ...
});

這種情況是 ABAC(Attribute-Based Access Control)——授權決策考慮 resource 的屬性(shopId)和 user 的屬性(shopId)。

大部分系統的實務做法:RBAC + 局部 ABAC——用 RBAC 管大方向(角色),用 ownership check 管資料邊界,不要引入完整的 ABAC framework(那東西設定很複雜)。


延伸閱讀