為什麼 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、內容平台)

延伸閱讀