iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
Software Development

ERP 架構師筆記:定義驅動的框架設計系列 第 24

Day 24:權限的三軸:動作、列與欄

  • 分享至 

  • xImage
  •  

Day 24:權限的三軸:動作、列與欄

昨天結尾說今天換一條軸。保護等級管的是一次呼叫的形式,它從頭到尾沒有問過這個人可以看到哪些資料。

權限是 ERP 躲不掉的題目,而它從來不是一個問題,是三個:採購員能不能刪採購單、能刪哪幾張、以及那張單上的成本欄他看不看得到、改不改得動。三個問題的答案來源不同,執行的位置也不同。

本篇說明:

  1. X-Api-KeyAuthorization 各自回答什麼,以及為什麼它們都不是權限
  2. 動作那一軸授在哪裡,以及沒有宣告的表單為什麼整套跳過
  3. 列那一軸的範圍由誰宣告,怎麼變成一句 SQL,以及組成它為什麼一次資料庫都不用查
  4. 欄那一軸為什麼只有前端,以及它綁的不是同一個權限模型

一、先把不是權限的那一道分出去

遠端呼叫端要帶兩個標頭,而它們回答的都不是「這個人可以做什麼」:

標頭 回答什麼 答案來自哪裡
X-Api-Key 哪一個應用在呼叫 st_api_key 裡已發放的金鑰
Authorization: Bearer 是誰在呼叫 一個還沒過期的 session

金鑰本身不授予任何資料權限:它識別的是呼叫的應用程式,使用者鑑別是存取令牌的工作。Day 16 寫過有一份不必帶標頭的方法清單,實際上是兩份,一個標頭一份。

令牌那一側還有一道檢查掛在方法上。[ApiAccessControl] 帶兩個參數,昨天談的保護等級是其中一個,另一個只有 AnonymousAuthenticated 兩個值。它問的是「這支方法需不需要一個有效的令牌」,不是「這個人能做什麼」。

這幾道問完,是誰、從哪個應用來,都清楚了。接下來才是權限,而它分三個軸:

問的是 伺服端 前端
動作 能不能做這件事 權威的判定 工具列命令的狀態
能對哪幾筆做 查詢條件與寫入回查 不參與
看不看得到、改不改得動 不遮罩 隱藏、遮罩或唯讀

要看的是後兩欄。三軸不是三個平行的機制,它們執行的位置不一樣:動作兩端都做,列只在伺服端做,欄只在前端做。


二、動作:能不能做這件事

動作那一軸綁的是業務實體,不是表單。有哪些權限可以談登錄在 PermissionModels 裡,FormSchema 上則宣告一個 PermissionModelId 指過去,權限授在那個 model 上(本篇兩段片段都節選自 FormBusinessObject.Permission.cs):

var modelId = DefineAccess.GetFormSchema(ProgId).PermissionModelId;
if (string.IsNullOrEmpty(modelId)) { return; }
if (!authorization.Can(AccessToken, modelId, action))
    throw new ForbiddenException($"Permission denied: '{action}' on model '{modelId}'.");

綁業務實體換到的是一件在 ERP 裡很實際的事:一個 model 可以被好幾個 ProgId 消費。建立單、查詢單、報表都引用同一個 PurchaseOrder,授一次三個功能一起生效。反過來若綁表單,同一份採購權限得在三張表單上各授一次,而漏掉的那一張不會有任何症狀,它只是比別人鬆。

動作有六個旗標:增查改刪,加上列印與匯出兩個資料出境動作。後兩個單獨列出來是因為把一份採購單印出來或匯成試算表,資料一樣離開了系統,而「能讀」跟「能整批帶走」在稽核上不是同一件事。送簽、過帳、確認這些狀態轉換則刻意不放進這條軸,它們屬於流程權限。

這裡有一個開口:表單沒宣告 PermissionModelId 就整套跳過,動作與列都不套,為的是讓既有表單逐張導入。

前端跟著收窄,但只是跟著

動作是三軸裡唯一兩端都做的。前端那一份只是不要把使用者沒有權限的動作擺出來給人按:拿到一份這個人能做什麼的清單,據此把那些命令隱藏或不啟用。

但它擋不住任何人。伺服端那一道才是邊界,而且非它不可,因為標準畫面只是呼叫端之一:另一個前端、一支腳本、直接打 API 的整合程式,都不會經過前端那一層。前端漏了收窄,伺服端照樣擋。

一個命令上可以掛不只一個旗標,存檔就同時是新增與修改,這種命令只要其中一個拿得到就顯示,真正該不該放行由伺服端決定。前端猜寬一點沒有安全問題,猜窄了反而會讓使用者以為功能壞了。


三、列:能對哪幾筆做

列那一軸的宣告分在兩處,而且刻意不重疊。角色的授權寫的是策略,FormSchema 上標的是哪一欄:

ScopeStrategy 讀取時加上的條件
All 不限制
Own 擁有者欄 IN(使用者列識別,員工列識別)
Dept 部門欄等於所屬部門,OROwn
DeptAndSub 部門欄 IN(部門與其所有後代),OROwn

策略從不指名欄位,欄位由 FormField.ScopeRole 標成 OwnerDept。分開的好處是這套策略對每一張表單都成立,換一張表單只換標記的欄名,不必寫新策略。Own 那一格比對兩個身分,因為同一欄在不同表單上可能存登打者、也可能存被登打的員工。同一個角色可以看得到整個部門、卻只改得動自己的,因為授權是逐動作一列,範圍跟著那一列走。

主檔上同一種角色可以標在不只一欄。一張職務異動單有調出部門與調入部門,兩邊的主管都該看得到,兩欄都標 Dept,產出的條件就是兩個分支的聯集。

角色、角色授權、使用者角色綁定這三張表落在公司資料庫,不在共用的那一個,所以同一個人在兩家公司可以是完全不同的兩種角色。這是 Day 11 那條「前綴回答的是誰擁有這張表,資料庫分類回答的是它住在哪裡」的例證之一:三張表都是框架自己的前綴,位置卻在公司那一邊。

讀的時候加條件,寫的時候回頭查一次

策略與標記算出來的是一棵 FilterNode。Day 11 建立過查詢條件是一棵住在定義層的樹,從使用者填的條件到五家資料庫各自組語句中間一次轉換都沒有;權限過濾產出的是同一個型別的樹,直接接在使用者那一棵旁邊,兩棵合起來是一個 FilterGroup.All。越範圍的列在清單裡被過濾掉,單筆查詢回 null,與「查無此列」不可區分,呼叫端沒辦法拿它去探測看不見的列存不存在。

寫入端不能用同一招。送上來的 DataSet 是呼叫端給的,裡面的部門欄想填什麼就填什麼,拿 payload 裡的值去比對範圍,等於讓要被擋的人自己填答案。框架的作法是回頭查一次:

if (!repository.ExistsInScope(rowId, scopeFilter))
    throw new ForbiddenException($"Record out of scope for '{action}' on model '{schema.PermissionModelId}'.");

ExistsInScope 下的是 WHERE sys_rowid = 這一筆 AND 範圍條件,問的是資料庫裡那一列在不在範圍內,偽造的 payload 改不動它。刪除走同一條,越範圍就刪 0 列、不 cascade。新增不套範圍,一列還不存在的資料沒有既存範圍可以違反,能不能新增由動作那一軸管。

多個角色一律取聯集

一個人身上掛好幾個角色是 ERP 常態,動作與列這兩軸的規則相同,都是聯集:動作旗標 OR 起來,範圍條件也 OR 起來,任一個角色在這個動作上給了 All,整個範圍就是不限制。

反過來說,這套模型沒有拒絕授權。想要「給他採購主管的角色,但擋掉刪除」在這裡做不到,多掛一個角色只會多權限、不會少,要那個效果只能拆一個不含刪除的角色給他。換到的是查得出來:一個人刪得掉,一定是某一個角色給的。

那些條件怎麼變成一句 SQL

那棵樹翻成 SQL 只有兩條規則,而且是遞迴的:群組收成一對括號,中間用 ANDOR 串;單一條件收成一段比較,值一律換成參數。一個角色拿 DeptAndSub、主檔上標了部門欄與員工欄、使用者又在清單上填了單號開頭,組出來是這樣:

WHERE ((dept_rowid IN (@p0, @p1) OR emp_rowid IN (@p2, @p3)) AND sys_no LIKE @p4)

外層那個 AND 是權限與使用者條件的接縫,內層那個 OR 是「部門子樹或自己的」。使用者永遠只看得到自己填的那半,另外半段是伺服端接上去的,而他沒有任何辦法從回來的資料反推出那半段長什麼樣。

還有兩個細節。第一,@p0@p3 是四個部門與身分的識別碼,全部走參數,欄名則走另一條路,對照定義之後加識別符引號。Day 11 那句「參數化擋得住值,擋不住欄位名」講的就是這條分岔,權限條件也不例外。第二,範圍解析不出可用授權時,產出的不是空條件而是一個空的 IN,落到 SQL 是 1 = 0。這是刻意的,因為空條件在這條路上的意思是「不限制」,失敗的方向必須是關的。

那句 SQL 還有一件事沒說:組成它的過程一次資料庫都沒查。角色與授權、部門組織樹都是逐公司的讀穿快取,形狀同 Day 12,來源換成資料庫;三個列識別是 Day 13 那份進公司當下解析一次的 session 快照。

部門那一段省掉的不只是一次查詢。DeptAndSub 要的是「這個部門與它所有的後代」,而組織樹已經在記憶體裡,子樹就地展平成一串識別碼,落到 SQL 只是一個平的 IN。沒有這棵樹的話,每一次查詢都得在資料庫裡遞迴展開一次子樹,而遞迴查詢的寫法五家資料庫各不相同,等於在權限這條路上再長出五份方言。

判定全部走記憶體,套用才碰資料。讀取那一邊是零成本的,條件併進本來就要跑的那句 SQL;只有寫入端每一列被改動的主檔多一次 SELECT sys_rowid,純新增的存檔一次都不查。


四、欄:能看到哪些、能改哪些

採購單上有成本欄。採購員看得到,業務助理不該看到,而成本欄不會只出現在採購單上,它散在報價單、進貨單、庫存查詢上。

欄那一軸宣告在 FormField.SensitiveCategory,值是一份有限的清單:金額、成本、個人資料,加上預設的不分類。挑分類而不是讓每張表單自己編一個識別碼,是因為分類是跨表單的資料議題,不是那張表單的議題。標成成本的欄位不管出現在哪一張單上,管的都是同一份授權,「這個人能不能看成本」授一次就到處生效。

所以它綁的權限模型跟前兩軸不是同一個。動作與列走的是表單自己宣告的那個 PermissionModelId,欄走的是分類名對應的那一個。這代表「能不能改採購單」與「能不能看成本」是兩份互相獨立的授權,可以給一個人採購單的全部動作,同時不讓他看到成本。

一個欄位上的權限分成檢視與修改兩級,而且能檢視才談得上能修改。前端據此兩種處理:不允許檢視的隱藏或遮罩,不允許修改的鎖成唯讀。

最後是這一軸的邊界:欄那一軸只有前端。伺服端取資料不套欄位權限,GetListGetData 照樣把敏感欄的值回傳出去,那些不經過前端的呼叫端拿得到完整的那一列。這不是漏掉的:表單欄位常常要參與運算,而框架的運算式兩端跑的是同一份,少了其中一個欄位,那些計算在兩端都算不出來。所以框架把這一軸標成呈現層的事,而真的不能離開伺服器的資料,要放到動作或列那兩道邊界上。


小結

權限在這套框架裡是三個問題,不是一個,而這三軸的差別不只在問什麼,也在誰執行:

  • 動作:能不能做這件事。授在業務實體上而不是表單上,一個模型可以被好幾張表單共用,伺服端與前端兩端都做,而伺服端那一端才是邊界
  • 列:能對哪幾筆做。由角色給策略、FormSchema 標欄位,兩邊刻意不重疊,換一張表單只換標記的欄名;讀取端把條件併進本來就要跑的那句 SQL,寫入端不信送上來的資料,一律回頭查權威的那一份
  • 欄:能看到哪些、能改哪些。綁的是分類對應的另一個模型,所以與前兩軸互相獨立;而它只有前端

多個角色一律取聯集,只加不減,換來的是每一個權限都查得到出處。

前兩軸之所以能不碰資料庫就判完,靠的是把授權與組織樹整份放進記憶體。組織樹尤其關鍵,它把一次遞迴查詢換成了一個平的 IN,五家資料庫共用同一份寫法。

解析不出授權時產出的是恆假條件而不是空條件,越範圍的單筆查詢回的是「查無此列」而不是「無權存取」,兩者都往關的那一邊倒。唯一往開的那一邊倒的是「表單沒宣告就整套跳過」,那是為了讓既有系統逐張導入才留的門。

明天是這一章的最後一篇,談的是這些判定留下了什麼:誰、在什麼時候、對哪一筆、做了什麼、從什麼值改成什麼值。


本系列同步發表於 HackMD,完整目錄


上一篇
Day 23:API Payload 安全管線:順序、保護等級與金鑰
下一篇
Day 25:稽核軌跡與異動的前後值
系列文
ERP 架構師筆記:定義驅動的框架設計26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言