ABAC / RBAC 系統中組織階層的設計

 

比較 RBAC ABAC

  • RBAC 適合簡單、靜態、角色固定的系統。
  • ABAC 適合複雜、多租戶、大規模企業,特別是金融、雲端、政府。
  • AWS / GCP / Azure / Okta 這些雲端大廠,早就全面上 ABAC,因為它能撐住全球規模的多租戶需求。

🏢 已採用 ABAC 的大型系統 / 平台

  1. AWS (Amazon Web Services IAM Policies)

    • IAM policy 就是 ABAC:可以寫 Condition,檢查 user.tag == resource.tag 才能存取。
    • 範例:只有標籤 project=fanpokka 的使用者才能操作同標籤的 S3 bucket。
  2. Google Cloud IAM

    • 採「角色 + 條件」混合:IAM Conditions 允許設定 基於屬性的規則(時間、資源名稱、requester 屬性)。
  3. Microsoft Azure

    • Azure Role-Based Access Control 支援 條件存取 (Conditional Access),其實就是 ABAC:例如「只有從公司網路登入的使用者才能存取資源」。
  4. Okta / Auth0 (大型身份服務商)

    • 支援 基於使用者屬性 (department, group, app_metadata) 的動態授權,等於 ABAC。
  5. 美國國防部 / NIST 標準

    • ABAC 是 NIST 800-162 的正式標準,被美國政府和軍方大量採用,特別是需要 零信任架構 (Zero Trust) 的系統。
  6. 金融核心系統 (銀行/保險)

    • 很多新一代核心系統會用 ABAC,因為可以描述「交易金額 > 1,000,000 時必須是經理級才能批核」這種動態條件,RBAC 無法處理。

項目 RBAC (Role-Based Access Control) ABAC (Attribute-Based Access Control)
核心概念 角色 (Role) 決定權限 屬性 (Attribute) + 規則 (Policy) 決定權限
授權方式 使用者被指派一個或多個角色;角色內建一組權限 使用者屬性 + 資料屬性 + 環境屬性 → 經 Policy 引擎判斷
適合規模 組織結構簡單、角色固定 複雜組織、跨部門、多條件存取控制
例子 - Admin 可讀寫所有資料
- 店員只能存取自己門市
- 如果 user.dept = 資料.dept,允許讀取
- 如果 user.role=Manager 且 request.time < 晚上 10 點,允許修改
優點 - 簡單直觀
- 易於實作
- 適合小系統
- 精細化控制
- 高度彈性
- 支援動態條件(時間、地點、設備)
缺點 - 角色爆炸 (role explosion):要管理數百個角色
- 無法表達動態條件
- 複雜度高
- 需要 Policy Engine 或規則系統
- 測試/維護成本大
典型應用 ERP 權限管理、傳統後台系統 金融業、政府、雲端平台 (多租戶 SaaS)、零信任 (Zero Trust)

改成「泛化模型」:N 層樹 + ABAC 策略

1) 資料模型(N 層樹,不綁業務詞)

核心:org_units 作為可任意深度的組織樹(樹高不限、型別只是 metadata)

  • tenants:企業/甲方(最上層租戶)

  • org_units:租戶內的組織節點(樹狀,可任意深度)

    • id, tenant_id, parent_id, path, type, attrs
    • type 只作建議型別(如 brand/region/store/online/department),不參與硬性邏輯
    • pathltree(Postgres)或文字 path(1.3.5)利於「含子層」查詢
  • channels:每個 LINE OA(或其他渠道),綁到某一個 org_unit(最常綁門市/品牌)

  • 業務資料(members/events/messages/coupons)都存tenant_id, org_unit_id, channel_id

這樣:

  • 「層數」跟你無關(N 層)
  • 「名稱」只是展示,不動到資料結構
  • Channel 自然落在某個 org_unit 之下(例如門市或品牌)

表結構示意

-- 租戶
tenants(id uuid pk, name text, ...);

-- 組織樹(N 層)
org_units(
  id uuid pk,
  tenant_id uuid not null references tenants(id),
  parent_id uuid null references org_units(id),
  path ltree not null,            -- 例如 'a.b.c' 表示樹路徑
  type text null,                 -- 'brand'/'region'/'store'.. 只是 metadata
  attrs jsonb not null default '{}' -- 自定擴充欄位
);

-- 渠道(LINE OA)
channels(
  id uuid pk,
  tenant_id uuid not null,
  org_unit_id uuid not null references org_units(id),
  channel_id text not null,
  access_token_enc bytea not null,
  secret_enc bytea not null,
  hook_key text not null unique,
  is_active boolean not null default true
);

-- 會員/事件等通用外鍵(全部掛 org_unit + channel)
members(
  id uuid pk,
  tenant_id uuid not null,
  org_unit_id uuid not null references org_units(id),
  channel_id uuid not null references channels(id),
  line_user_id text not null,
  ...
);
events(... same keys ...);
messages(... same keys ...);
coupons(... same keys ...);

路徑索引:Postgres ltree + GIST/GIN,可支援 path @> 'a.b'(包含子層)查詢。


2) 權限與隔離(從「等級」改為「規則」)

strict/relaxed/none 改成 策略化(Policy-based)
ABAC(基於屬性) 或 OPA(Open Policy Agent)/自製 Policy Engine 來表達。

Membership 與 Policy

-- 使用者在租戶中的成員身分(可綁 top ou 或某個 ou 子樹)
memberships(
  user_id uuid,
  tenant_id uuid,
  org_unit_scope ltree,      -- 可是 'a.b',表示這個人管 a.b 子樹
  role text,                 -- owner/admin/marketer/store_clerk
  permissions jsonb          -- 額外權限/限制(如允許跨 OU 讀取等)
);

判斷邏輯(簡化)

  • 每個請求經過中介層,得到 tenant_id 與「可視子樹集合」。
  • 查詢自動注入:org_units.path <@ any(allowed_paths)(或 org_unit_id in (...) 經由展開)

更彈性的共享

  • 不是「同 root 就能看」,而是 Membership 可以有多個 org_unit_scope(一個行銷主管可以同時看到北區與南區)。
  • 若要跨租戶(代理商帳號),則該 user 會有多個 tenant_id 的 membership。

DB 層保險:RLS

  • members/events/... 開啟 RLS:

    • USING (tenant_id = current_setting('app.tenant_id')::uuid AND org_unit_id = ANY(current_setting('app.allowed_ou_ids')::uuid[]))
  • 每個請求前 SET app.tenant_id, SET app.allowed_ou_ids = '{...}'(由 membership 決定)。

好處

  • 不再被 rootId 綁死
  • 可以設定「只看這兩棵子樹」或「看整棵」
  • 能滿足大型連鎖、代理商、多品牌集團等任意組織

3) Webhook 映射(每個 Channel 唯一路徑)

維持之前要求:

POST /webhooks/line/{hookKey}
  • hookKey O(1) 查到:channel_id, tenant_id, org_unit_id, secret
  • 驗簽後把事件丟入 queue,事件上打:tenant_id/org_unit_id/channel_id
  • 日後報表就能依 channelorg_unit 任意聚合

為什麼 Channel 綁 org_unit?

  • 因為同一租戶下可能有「門市用一個 OA、電商用另一個 OA」;各自對應不同 org_unit 更直覺。

4) API 擴充(泛化命名,避免業務詞寫死)

把特定詞(brand/region/department)改為抽象資源:org-units

  • 查樹:GET /tenants/{tenantId}/org-units?parentId=&type=&q=...
  • 指定 Channel 掛在哪個 org-unit:POST /tenants/{tenantId}/channels { orgUnitId, ... }
  • 查會員/訊息/報表都能帶 orgUnitIdchannelId;或 includeDescendants=true

查詢例

GET /tenants/{t}/members?orgUnitId=ou_xxx&includeDescendants=true&tag=VIP
GET /tenants/{t}/analytics/messages/stats?groupBy=channel&orgUnitId=ou_store_123

5) 報表維度

tenant_id, org_unit_id, channel_id, sender 為標準維度。

  • 任何 KPI 支援 groupBy = [day | orgUnit | channel | sender | segment]
  • includeDescendants=true,後端展開 org_units.path <@ target.path 做整棵匯總
  • 可以在 DWH 建 物化視圖 加速(org_unit_path + channel_id 維度)

6) 過度專化版本的額外風險

  • 遷移地獄:一旦某客戶要第 5 層,就得改 schema/查詢/索引/權限;成本高昂。
  • 權限邏輯不夠表達力strict/relaxed 無法覆蓋「跨兩個非相鄰區域共享」。
  • 不同產業不適配:醫療、教育、政府採購、品牌代理都會有不同結構,寫死詞彙難賣。
  • rootId 大範圍掃描:開發者容易用 rootId 做懶人查詢,出現越權風險和性能問題。

7) 過渡策略(從 4 層 → 泛化)

  1. 先把現有四層映射org_units

    • brand → type='brand'
    • region → type='region'
    • channel → type='channel'
    • department → type='department'
  2. 新專案只用 org_units,不再創建新固定層表。

  3. 建置 membership.scope 改存 ltree path;API 支援 includeDescendants

  4. 用 DB migration 把舊資料加上 org_unit_id/path

  5. 報表 ETL 改用 org_unit_path 聚合。


8) 範例:RLS 與查詢(Postgres)

-- RLS policy(以 org_unit_id 列表限制)
ALTER TABLE members ENABLE ROW LEVEL SECURITY;

CREATE POLICY p_members_tenant_ou ON members
  USING (
    tenant_id = current_setting('app.tenant_id')::uuid
    AND org_unit_id = ANY (string_to_array(current_setting('app.allowed_ou_ids'), ',')::uuid[])
  );

中介層(偽碼)

middleware(req):
  user = decodeJWT(req.Authorization)
  tenant = pathParam('tenantId')
  allowed_ous = membershipService.resolveScopes(user.id, tenant)  # 展開成多個 org_unit_id
  DB.exec("SET app.tenant_id = ?", tenant)
  DB.exec("SET app.allowed_ou_ids = ?", join(allowed_ous, ','))
  next()

子樹查詢(含後代)

-- 找某 OU 與其子層所有會員
SELECT m.*
FROM members m
JOIN org_units ou ON ou.id = m.org_unit_id
WHERE ou.path <@ 'a.b'::ltree;  -- 'a.b' 為目標 OU 的路徑

9) 測試要點(把「泛化」測到底)

  • ✅ 同一租戶、不同 OU:看得到自己 OU,但看不到旁支 OU
  • ✅ membership 綁「兩棵不同子樹」:兩邊都看得到,其他都看不到
  • ✅ includeDescendants:有無包含子層結果不同
  • ✅ channel 綁在任意 OU:Webhook 事件正確落到該 OU,報表能 groupBy channel
  • ✅ RLS 生效:移除中介層 WHERE 後仍無法越權讀取

10) 總結

把四層固定結構,改成「N 層 org_units + ABAC」。
Channel 綁 org_unit;資料一律帶 tenant_id/org_unit_id/channel_id
隔離靠 membership 的「子樹 scope」+資料庫 RLS 雙保險。
這樣才泛化可賣可維護可長期演進


以下備注,詳細說明什麼是 ABAC

好 👍 我幫你把 「N 層 org_units + ABAC」 這個概念,講成比較淺顯又工程師能懂的版本。


🧩 什麼是「N 層 org_units」?

👉 簡單說:把租戶內的組織結構做成一棵樹,可以有任意層級,不要寫死只有 4 層。

  • org_units 就是「組織單位(Organization Units)」。
  • 每個 org_unit 可以是品牌、分公司、區域、門市、部門… 名字不重要。
  • 層數不限(N 層),可以是 2 層、3 層、5 層都行。

範例

康是美 (org_unit: brand)
└── 北區 (org_unit: region)
    ├── 台北店 (org_unit: store)
    │   ├── 行銷部 (org_unit: dept)
    │   └── 客服部 (org_unit: dept)
    └── 網店 (org_unit: channel)

再換一個產業 → 醫療院所

三總醫院 (org_unit: hospital)
└── 心臟內科 (org_unit: dept)
    ├── 心導管室 (org_unit: unit)
    └── 門診 (org_unit: unit)

➡️ 不同產業有不同層級,但因為是「N 層樹」,架構不用改,只要填不同的 org_unit


🛡️ 什麼是「ABAC」?

👉 Attribute-Based Access Control = 基於屬性的存取控制

傳統做法是 RBAC(Role-Based Access Control)

  • 角色 = 權限 → 例如「Admin 可以看全部」「店員只能看自己的店」
  • 缺點:一旦組織複雜,就要創造一堆角色,很快失控。

ABAC 改成「判斷屬性」:

  • 使用者有屬性(user.id, user.roles, user.org_scope)
  • 資源有屬性(data.tenant_id, data.org_unit_path, data.channel_id)
  • 請求有屬性(operation=read/write, time=2025-09-10)

➡️ 系統判斷規則:是否允許 user 對 resource 做 operation
這些規則就是「Policy」。

範例

規則:

  • user.tenant_id == data.tenant_id → 同租戶才可讀取
  • data.org_unit_path 在 user.org_scope 子樹裡 → 只能看自己分店與其子部門
  • operation == 'read' → 行銷可以看資料,但不能刪除

效果:

  • 北區經理登入 → user.org_scope = "北區" → 可以看「台北店」和「客服部」
  • 高雄經理登入 → user.org_scope = "南區" → 看不到北區的任何資料
  • 平台管理員 → user.org_scope = "*" / role=platform-admin → 可看全部

*

張貼留言 (0)
較新的 較舊

廣告1

廣告2