為什麼 RBAC 不夠
RBAC 的設計假設是:同一個 role 的人看到的世界是一樣的。shop_manager 可以 employees:update——這個 permission 不帶任何「哪些 employees」的資訊。
當多租戶、多分店、所有權這些概念出現,RBAC 就卡住了:
shop_manager A(管台北店)能不能改高雄店的員工?
純 RBAC 的答案是「有 employees:update permission 就能改」——這是錯的。
你可以把 permission 拆成 taipei_employees:update / kaohsiung_employees:update,但這樣 permission 數量會隨分店數量爆炸,而且無法動態擴充。
ABAC 的核心概念
ABAC(Attribute-Based Access Control):授權決策同時考慮:
- Subject 屬性:發起人的屬性(userId、role、shopId、department)
- Resource 屬性:被操作資源的屬性(ownerId、shopId、status、visibility)
- Environment 屬性:請求環境(time、IP、requestedAction)
授權決策 = f(subject attributes, resource attributes, environment attributes)
實務做法:RBAC + 局部 ABAC
完整的 ABAC framework(如 OPA、Casbin)設定複雜,大部分系統不需要引入。
實務上的做法:RBAC 管大方向,ownership check 管資料邊界。
// RBAC guard:有沒有這個 action 的 permission
// ABAC check:這個 resource 是不是你的
router.put('/shops/:shopId/employees/:empId',
authenticate,
requirePermission('employees:update'), // RBAC:role 有沒有這個 permission
requireShopOwnership('shopId'), // ABAC:這家店是不是你管的
async (req, res) => {
// 到這裡兩層都過了
await employeeService.update(req.params.empId, req.body);
res.json({ success: true });
}
);Ownership Check Middleware
// 通用的 ownership check:比對 resource 的某個欄位和 user 的對應屬性
const requireShopOwnership = (paramKey: string) => {
return async (req: AuthRequest, res: Response, next: NextFunction) => {
const shopId = req.params[paramKey];
// admin 可以跨店操作
if (req.user.role === 'admin') return next();
// shop_manager 只能操作自己的店
if (req.user.shopId !== shopId) {
return res.status(403).json({
error: 'Forbidden',
code: 'RESOURCE_NOT_OWNED',
});
}
next();
};
};
// 也可以做資料庫層的 ownership check(resource 本身記錄 ownerId)
const requireResourceOwnership = (
repo: { findById: (id: string) => Promise<{ ownerId: string } | null> },
paramKey: string,
) => {
return async (req: AuthRequest, res: Response, next: NextFunction) => {
const resourceId = req.params[paramKey];
const resource = await repo.findById(resourceId);
if (!resource) {
return res.status(404).json({ error: 'Not Found' });
}
if (req.user.role !== 'admin' && resource.ownerId !== req.user.id) {
return res.status(403).json({
error: 'Forbidden',
code: 'RESOURCE_NOT_OWNED',
});
}
next();
};
};Service 層的 Scoped Query
Middleware 做的是「能不能進這個 endpoint」,但資料邊界不能只靠 middleware。
更安全的做法:把 ownership 條件下推到 query 本身。
// ❌ 只靠 middleware check,但 query 沒有加限制
async function getEmployees(shopId: string) {
return Employee.findAll(); // 如果 middleware 被繞過,這裡會回傳所有員工
}
// ✅ 把 scope 注入 query,即使 middleware 出問題也不會漏資料
async function getEmployees(shopId: string, requestingUser: User) {
const where: WhereOptions = {};
if (requestingUser.role !== 'admin') {
where.shopId = requestingUser.shopId; // 只能看自己店的
}
return Employee.findAll({ where });
}這個模式叫 Scoped Query——授權條件直接編進 DB query,不依賴外部 guard 的正確性。
多租戶的 Tenant Isolation
SaaS 系統的租戶隔離是最常見的 ABAC 場景:
// 所有 Repository 方法都強制帶 tenantId
class OrderRepository {
async findById(id: string, tenantId: string): Promise<Order | null> {
return Order.findOne({
where: { id, tenantId }, // tenantId 永遠是 query condition
});
}
async findAll(filters: OrderFilters, tenantId: string): Promise<Order[]> {
return Order.findAll({
where: { ...filters, tenantId },
});
}
}
// Service 從 authenticated user 拿 tenantId,不信任 request body 的 tenantId
class OrderService {
async getOrders(filters: OrderFilters, user: AuthUser) {
return this.orderRepo.findAll(filters, user.tenantId);
}
}重要:tenantId 永遠從 JWT payload 取,不能從 request body 或 query params 取——那是可以被偽造的。
跨租戶操作(Super Admin)
平台管理員需要跨租戶操作時,不要破壞 tenant isolation 的設計:
// 在 JWT payload 加一個 bypass flag,只有 super admin 才有
interface AuthUser {
id: string;
tenantId: string;
role: string;
isSuperAdmin?: boolean; // 平台層的管理員
}
// Repository 層的 bypass 處理
async findById(id: string, tenantId: string, bypassTenantCheck = false): Promise<Order | null> {
const where: WhereOptions = { id };
if (!bypassTenantCheck) {
where.tenantId = tenantId;
}
return Order.findOne({ where });
}
// Service 層決定是否 bypass
async getOrderById(id: string, user: AuthUser) {
return this.orderRepo.findById(id, user.tenantId, user.isSuperAdmin);
}bypassTenantCheck 這個 flag 只能從 user.isSuperAdmin 來,不能讓 caller 自己傳。
欄位級別的 ABAC
有時候不是整個 resource 要限制,而是特定欄位:
// 不同角色看到的 response 欄位不同
class UserSerializer {
static toResponse(user: User, requestingUser: AuthUser) {
const base = {
id: user.id,
name: user.name,
email: user.email,
};
// 只有 admin 看得到薪水和 SSN
if (requestingUser.role === 'admin' || requestingUser.id === user.id) {
return {
...base,
salary: user.salary,
ssn: user.ssn,
createdAt: user.createdAt,
};
}
return base;
}
}這也是為什麼 API response 不應該直接 return entity——要先過 serializer 做欄位過濾。
常見設計決策
授權邏輯放哪裡?
Middleware layer → 粗粒度:這個 endpoint 能不能進(RBAC permission)
Service layer → 細粒度:這筆資料能不能操作(ABAC ownership)
Repository layer → 防禦層:query 本身帶 scope(不依賴上層的正確性)
三層可以同時存在,粒度遞增。不是選一層,是每一層做自己該做的。
什麼時候需要 ABAC?
- 多租戶 SaaS(tenant isolation)
- 組織架構型資料(分店、部門、team)
- 用戶只能管自己的資料(訂單、個人資料)
- 審批流程(只有特定狀態才能操作)
什麼時候 RBAC 就夠了?
- 功能型權限(admin 能匯出報表、viewer 只能看)
- 沒有所有權概念的系統(工具型 SaaS、內容平台)
