從「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 就不夠用了。你開始加 isManager、isFinance、isSuperAdmin,然後 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:readGuard 讀 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(那東西設定很複雜)。
