Skip to Content
核心概念欄位型別Principal 欄位

Principal 欄位:principal、user 與 social_client

適用情境

有三種欄位型別指向的是「人」而不是資料。後端把它們歸在同一個名字 PRINCIPAL_COLUMN_TYPESsrc/schemas/enums.py)之下,因為從頭到尾的契約相同:寫入是身分字串、讀取是顯示物件、只能等值比較、永遠不能排序。

型別寫入時的 cell指向
principal一個帶標籤的字串:user:<id>smc:<id>room:<id>內部使用者、社群客戶,或整個聊天室——該室的每位成員都算被指派
user一個原始 User.id本表租戶的一位內部使用者
social_client一個原始 SocialMediaClient.id一個 LINE/Messenger/Instagram/Agent 身分,其所屬聊天室屬於本表租戶

現在該選的是 principal。同一欄可以放「這筆指派實際上是什麼身分」,所以一位業務在某些列以 TS 帳號行動、在另一些列以 LINE 帳號行動時,不再需要兩個欄位再用 policy 去 or;而「這筆 deal 屬於北區業務室」也終於有了表示法,成員異動完全不必動到任何一列。

usersocial_client 沒有任何改變,也繼續支援:它們存原始 id、加工成不帶 refkind 的 dict,既有的列、篩選、授權與匯出全部逐位元不變。它們沒有被淘汰,但新的負責人欄位應該用 principal

三者都把單一字串直接存在 record.data 裡。和 link 不同,這裡沒有關聯表、也沒有搬移;基數固定為一。多負責人 cell 會是另一個獨立型別,永遠不會是這個型別上的一個開關。

user 這個字指三件不同的事。在 ACLIaC 裡,user授權對象種類(grant principal kind)——權限授予給誰。user 同時也是一種欄位型別——紀錄裡的一格。現在 user: 還是 principal 欄位裡的一種 cell 標籤。三者只是共用同一個字。

當你需要的是屬於自己 schema 的豐富人員 metadata 時,人員表加 link 仍然是正確模型。Principal 欄位處理的是身分:它對照真實目錄解析、寫入時受租戶把關,而且它才是 $me row policy 比對的對象。

principal 的 cell 文法

principal cell 是恰好一個帶標籤的字串,不是陣列:

<tag>:<id> tag ∈ { user, smc, room } id 符合 [A-Za-z0-9_-]{1,64}
標籤指向加工後的 kind
user:User.iduser
smc:SocialMediaClient.idsocial_client
room:Chatroom.idchatroom

標籤詞彙就是 IaC 身分 token 的前綴去掉 $ 記號。id 本體既不允許 $ 也不允許 :,而且整個字串是用 fullmatch 比對、不是 $ 錨定的 regex——所以結尾多一個換行也會被拒絕。因此四個 id 空間在構造上互斥,任一空間的值都不可能被誤認成另一個:

  • principal cell(user:u1)——有標籤;
  • 舊的 usersocial_client cell——裸 id,沒有標籤;
  • row policy token($me$me.department)——以 $ 開頭;
  • IaC 身分 token($user:alice)與 actor key(client:<id>)——$ 記號與 client: 這個拼法都在文法之外。社群客戶的標籤之所以是 smc: 而不是 client:,正是為了這一點。

加工後的讀取契約講的仍然是 social_clientchatroom,不是 smcroom:標籤是傳輸拼法,kind 才是詞彙。

建立 schema

以下是 columns.create 的 body。三種型別都只接受 nametyperequireddescription

{ "name": "Assignee", "type": "principal", "required": false, "description": "這筆紀錄屬於哪個使用者、客戶或聊天室" }
{ "name": "Reviewer", "type": "user", "required": false, "description": "內部審核者" }

default_value 被禁止,因為在編寫 schema 的當下無法驗證預設負責人是否屬於本租戶;max_lengthoptions 對不透明的身分沒有意義;附件與計算欄的設定欄位一概不接受。required 可以使用。

欄位在任一 principal 欄位上的結果
default_value422 principal (user/social_client/principal) columns cannot have a default_value
max_length422 principal (user/social_client/principal) columns cannot set max_length
options422 principal (user/social_client/principal) columns cannot set options
附件上限欄位422 fields [...] are only valid on attachment columns
計算欄設定422 fields [...] are not valid on principal (user/social_client/principal) columns

這些 422 來自建立表與建立欄位時執行的逐型別驗證器。欄位編輯介面(PATCH .../tables/{table_id}/columns/{column_id})沒有這個驗證器,所以同樣的錯誤在那裡會變成 400、而且措辭不同:Invalid default value: principal (user/social_client/principal) columns cannot have a default value,若是轉型則是 Default value incompatible with new type 'principal': principal (user/social_client/principal) columns cannot have a default value

寫入契約

送出身分字串——principal 要送帶標籤的那個。不是顯示名稱、不是 username,也不是讀取時拿到的那個物件。

{ "data": { "Title": "印表機離線", "Assignee": "room:c8a91f22d0", "Reviewer": "44444444-4444-4444-8444-444444444444" } }

records.create 回應 201,而且 cell 已經是加工過的顯示物件——回應的形狀和你送出的不一樣。

選填 principal 欄而言,省略 key、null"" 都是合法空值。必填欄在建立紀錄時拒絕空值,在更新時也拒絕明確送出的空值;局部更新仍可省略該 key。

每一個非空值都會跑兩道檢查。

形狀。 principal cell 必須能解析成帶標籤的 cell,否則寫入失敗並回 400 Field '<display name>' must be a principal cell (user:<id>, smc:<id> or room:<id>)usersocial_client cell 必須是非空字串:400 Field '<display name>' must be a principal id string

租戶歸屬與存活。 每種 kind 各批次查一次、沒有 N+1;principal標籤分派,舊的兩種依欄位型別分派:

Cell必須解析到拒絕訊息
user:<id>,以及 user 欄位本表有效公司底下 is_deleted == FalseUser400 Unknown or inaccessible user ids: ['<id>']
smc:<id>所屬聊天室在該公司底下且未刪除SocialMediaClient400 Unknown or inaccessible social_client ids: ['<id>']
social_client 欄位所屬聊天室在該公司底下的 SocialMediaClient(刻意檢查聊天室存活——收緊會讓既有已寫入的 cell 被拒)400 Unknown or inaccessible social_client ids: ['<id>']
room:<id>該公司底下未刪除的 Chatroom400 Unknown or inaccessible chatroom ids: ['<id>']

如果完全無法解析有效公司(表 → 部門 → 聊天室都是空),寫入 fail-closed 並回 400 Cannot resolve company for principal ownership check

寫入者可以指派自己不在其中的聊天室。 room:<rid> 的閘門只有租戶與存活兩項,刻意不要求成員身分。把一筆紀錄指派給某個聊天室,永遠不會擴大寫入者自己看得到的範圍,所以沒有什麼要防;擋下它只會讓真正要交接的人交接不出去。

這道閘門接在所有寫入路徑上:單筆新增、單筆更新、批次新增、批次更新、非同步批次新增 worker、簽核暫存套用路徑,以及版本還原路徑。它同時也是 replay 與 bulk 路徑(那些路徑會跳過 schema 驗證)必經的匯流點,所以無法解析的 principal cell 在那裡也會拋錯——400 Column '<name>' principal cell '<value>' must be user:<id>, smc:<id> or room:<id>——而不是掉進「最後那一個」歸屬查詢。沒有任何後門能把跨租戶或格式錯誤的身分夾帶進來。

歸屬檢查只看 payload 中實際出現且非空的值。局部更新從不重新驗證沒有被碰到的 principal cell;用 null"" 清除選填欄也不會查。人員離職或聊天室關閉因此不會讓歷史紀錄變成廢資料——一列上放著已被軟刪除的負責人,仍然可讀、其他欄位也仍然可編輯。軟刪除只在寫入時被拒絕,讀取不受影響。

值得寫進前端表單驗證的拒絕情境:

你送進 principal 欄位的內容結果
"Assignee": "Alice Chen"(顯示名稱)400 Field 'Assignee' must be a principal cell (user:<id>, smc:<id> or room:<id>)
"Assignee": "44444444-4444-…"(裸 id)400——同一則訊息;標籤是必要的
"Assignee": {"ref": "user:u1", "kind": "user", …}(讀取物件)400——同一則訊息;要送 ref,不是整個 dict
其他公司使用者的 "Assignee": "user:u1"400 Unknown or inaccessible user ids: ['u1']
"Assignee": "$user:alice"(IaC token)400——$ 在 cell 文法之外
"Assignee": "client:c1"(actor key)400——標籤是 smc:,永遠不是 client:

讀取契約:加工後的顯示物件

所有私有 REST 讀取都會把儲存的字串換成解析後的顯示物件,每頁每種 principal kind 最多批次查一次——一次加工同時服務帶標籤的 cell 與兩種舊型別,因為挑目錄的是標籤,不是欄位型別。

{ "data": { "Title": "印表機離線", "Assignee": { "ref": "room:c8a91f22d0", "kind": "chatroom", "id": "c8a91f22d0", "name": "北區業務", "department_id": "d1", "is_deleted": false }, "Reviewer": { "id": "44444444-4444-4444-8444-444444444444", "name": "Alice Chen", "username": "alice", "is_deleted": false } } }

principal cell 會解析成該 kind 的 dict,再加上 refkind

標籤kind加工後形狀
user:user{ref, kind, id, name, username, is_deleted}
smc:social_client{ref, kind, id, platform, name}
room:chatroom{ref, kind, id, name, department_id, is_deleted}

ref 是來回契約:原封不動把這個字串寫回去,不要從 kind 反推標籤,也不要送裸 idusername 取暱稱、沒有就退回 username;social_clientname 是各通道的顯示名稱(可能為空);chatroomname 就是聊天室名稱。

舊的 usersocial_client cell 加工結果逐位元維持原狀——{id, name, username, is_deleted}{id, platform, name}沒有 ref沒有 kind。用 cell 形狀分辨這兩種欄位型別的前端可以繼續運作。判斷「這是不是來自 principal 欄位」的可靠依據就是 ref 是否存在。

加工是 fail-closed 而且不具破壞性。被軟刪除的使用者或聊天室仍然會解析出來,並帶 is_deleted: true,讓歷史指派保持可讀——存活與否是寫入閘門的事,永遠不是讀取過濾條件。無法在本租戶解析的值——懸空、跨租戶、格式錯誤,或是在租戶尚無法解析時寫入的——會維持原本的原始字串(含標籤):既不捏造、也不丟棄。

讀取的 cell 是聯集型別:string | object。前端必須真的做型別判斷,不能假設。直接讀 cell.name 的元件會在遇到懸空 cell 時崩潰。

加工只會轉換 data 裡已經存在的 key,因此被欄位 ACL 隱藏的欄位永遠不會被解析,也不會被重新加回去。它以顯示名稱為鍵,透過 settings.column_mapping 反查,所以即使寫入時用的是內部 col_<hex> 鍵,加工後的 cell 仍會出現在顯示名稱底下——請參閱欄位身分

有三個讀取介面不會加工,而且就只有這三個:

  • 公開讀取路徑只提供原始字串。
  • 透過 lookup 帶出的 principal 欄位回傳原始字串——加工器只走訪本表自己的 principal 型別欄位,而 lookup cell 宣告的型別是 lookup。同一個人在擁有該欄位的表上顯示為姓名,透過 lookup 卻是 user:<id>(舊型別則是一串 UUID)。
  • REST records/aggregate 的分組鍵是原始字串。要畫「每位負責人的紀錄數」圖表的前端,得自己把它換成姓名。

外部 agent 通道

在 agent 工具組的外部客戶通道上,加工後的聊天室 cell 會收斂成 {ref, kind, id}:聊天室名稱與 department_id 是內部組織結構,永遠不會送到客戶端。該通道上所有 principal dict 都會拿掉 usernameis_deleted,資料 cell 也一樣。ref 在兩條通道都保留——那是呼叫端要寫回去的字串。

CSV 與 XLSX 匯出

POST .../records/export 會串流一份已渲染的檔案,principal cell 會渲染成給人看的樣子,而不是 Python dict repr:

Cell渲染結果
chatroom dict(kind == "chatroom"room:<name>
platform 的 dict(社群客戶)<platform>:<name>
使用者 dict<name>
未加工的 cell(懸空/跨租戶)原始字串,含標籤

room: 判斷跑在 platform 判斷之前,所以聊天室指派絕不會被讀成某個人的名字。名稱為空時標籤退回 id,所以一格可能是 line:<uuid>room:<uuid>。匯出與列表走同一條可見性管線——被隱藏的欄位不會變成檔案欄位,ACL 之外的列也不會進到檔案。

查詢矩陣:只能等值,永遠不能排序

Principal 身分是不透明的,因此字典序比較會給出「錯得很篤定」的答案而不是錯誤——而且 principal cell 會先依標籤排序(room:z 排在 user:a 前面),一個看起來有意義、其實沒有的順序。各處使用的運算子集合完全相同:

eqneqinis_nullis_not_null——僅此而已。

介面行為
舊版 filters map精確等值;不會走 stringtext 那條不分大小寫的部分比對分支。在 principal 上,未帶標籤的值是 400 Filter value for column '<display name>' must be a tagged principal cell (user:<id>, smc:<id> or room:<id>);在 usersocial_client 上,非字串值是 400 Filter value for column '<display name>' must be a principal id string
stored_filters / any_of上述五個運算子;其他運算子是 400 Op '<op>' is not supported on principal column '<col>' (user/social_client/principal accept: eq, in, is_not_null, is_null, neq)
運算元型別eqneq 需要非空字串,in 需要非空的非空字串陣列,否則 400 principal column '<col>' filter value must be a non-empty raw principal id string (ids are opaque; numeric operands coerce silently at compare time)。在 principal 上每一筆還必須能解析成帶標籤的 cell:400 principal column '<col>' filter value must be a tagged principal cell (user:<id>, smc:<id> or room:<id>)
in 陣列大小最多 100 筆——schema 層 422 filter op "in" value list exceeds max 100 entries,SCP 純量葉節點則是 <path>: op 'in' value list exceeds max 100
name_eq / name_containsprincipal 上被拒絕:400 Op '<op>' is not supported on principal column '<column>' — resolve the principal first and filter by eq/in on its refusersocial_client 仍然可用
sort_by、多鍵 sort一律 400 Sorting on principal (user/social_client/principal) columns is not supported。表上若至少有一個計算欄,router 前置檢查會先觸發並帶欄位名:Sorting on principal (user/social_client/principal) column '<name>' is not supported
records/aggregategroup_by 可以;countcount_distinct 可以;sumavg 被數值閘門擋下,minmax 被可排序閘門擋下
Rollup/規則/SCP filter 路徑400 filter op '<op>' cannot be applied to column '<display>' (type principal). principal id cells support only eq/neq/in/is_null/is_not_null;運算元不是帶標籤的 cell 則是 filter value '<v>' does not match column '<display>' (type principal),提示為 op '<op>' on a principal column requires a tagged principal cell (user:<id>, smc:<id> or room:<id>)

principal 欄位上,未帶標籤的運算元在所有介面都會被拒絕——REST stored query、舊版 filters map、agent 工具組的 query/aggregate/traverse 驗證器、SCP 純量葉節點、rollup 與 lookup 設定 filter,以及 row policy。這是刻意的:裸 id 或 $user: token 永遠不可能等於已儲存的帶標籤 cell,所以寬鬆的運算元規則會讓 eq 靜默比對不到任何列、讓 neq 比對到每一列——讀取時是一條死掉的 filter,編輯時就是一個權限漏洞。

永遠不要把顯示名稱當成 eqinvalue。在 principal 上會被直接拒絕;在 usersocial_client 上則會悄悄回傳零列,因為儲存的 cell 是一串原始 id。要依名稱過濾 usersocial_client,正規做法是 name_eqname_contains 運算子(伺服器端會把名稱解析成 id 再過濾);在 principal 上它們會被拒絕——請改用 custom_tables_resolve_principal,再用回傳的 ref 過濾。伺服器端對任何 principal 欄位都沒有全文姓名搜尋:agent find_recordsq 只掃 stringtext 欄位。

儲存檢視在設定當下不驗證 principal 運算子。stored_filters 對 principal 欄位使用 containsgt 的檢視會在建立時被接受,只有在套用時才失敗。

伺服器端沒有辦法依 principal 欄位排序。請改用 group_by + count,或把姓名反正規化到一個 string 欄位再排序。principal 欄位的 group_by 以整個帶標籤字串為鍵,所以 user:x 永遠不會和 smc:x 混在一起。

Principal 欄位可以用的地方

三種型別都算已儲存純量,所以日常需求都是一級支援:

介面支援情形
unique 規則成分可以——這正是「負責人去重」的情境。空的或不存在的 cell 不受限制(SQL null 語意),使用 case_insensitive 時值會先轉小寫再雜湊
Upsert 的 match_column可以
lookup 目標欄可以,但帶出的 cell 是原始字串
rollupcount_distinct 目標可以
compare 規則只有 eqneq,而且只能對照同一種 principal 型別的另一個欄位。排序運算子、跨型別(principaluserusersocial_client)與 principal 對 string 都會被拒絕:400 principal column '<display>' supports only eq/neq against another column of the same principal typeprincipal 自成一個可比較類別——帶標籤的 cell 永遠不可能等於裸 id,跟舊型別配對在構造上就是永遠為 null 的比較
check 規則只有 eqneqinnot_inmatches(正則)與排序運算子會被拒絕:400 principal column '<display>' supports only eq/neq/in/not_in checks
transition 規則可以——重新指派的狀態機(room:salesuser:lead)和 select 的狀態機一樣合法。matches 仍然被拒絕:帶標籤的身分不是正則的比對對象
規則與 trigger 的 whenis_nullis_not_nulleqneqin,且值必須是非空字串:400 when column '<name>' is a principal (user/social_client/principal) — only is_null/is_not_null/eq/neq/in are supported
Trigger action 的 source.filter同樣五個運算子:400 source.filter column '<name>' is a principal (user/social_client/principal) — only is_null/is_not_null/eq/neq/in are supported
send_channel_message 的收件人recipient.column 接受 string social_client 欄位。user 被拒絕(內部 user id 不帶任何通道身分),principal 也被拒絕——從帶標籤的 cell 做投遞刻意不在範圍內:400 send_channel_message recipient.column must name a string or social_client column of this table holding a social-media client id
invoke_command$row.<col> 輸入user 可餵給 identity:userstringsocial_client 可餵給 identity:social_media_clientstringprincipal 只能餵給 string——identity 型別的參數預期的是裸 id,每次執行都會拒絕或錯誤解析 user:u1
Formula 引用永遠不行——400 Formula cannot reference a principal column。負責人不是算術輸入
Command DSLprincipal 運算元可以和 string 運算元做身分等值比較($input.*$ctx.user_id 就是這樣進到負責人欄位的)。跨型別不相容(comparison requires compatible operand types),只要任一邊是 principal kind 就拒絕排序比較(ordered comparison requires ordered operands)。string 型別的運算式可以寫入 principal 欄位,而寫入 principal 欄位的字串常值本身必須能解析成帶標籤的 cell(Principal cell literal must be user:<id>, smc:<id> or room:<id>);principal 型別的運算式只能寫入同型別的欄位,租戶歸屬在執行時把關
IaC record 自然鍵可以——principal cell 就是一個普通純量字串,有正常的綁定表示法。(booleanjsoninterval 不能當自然鍵,principal 欄位可以)
測試資料產生器絕不捏造身分——它輸出 null。含必填 principal 欄位的表完全無法使用產生器

compare 規則比 command DSL 更嚴格:規則完全禁止 principal 對 string 的比較,而 command 運算式允許 principal 等於 string。不要把兩者寫成同一條規則。

Agent 工具組與 REST 對齊,聚合集合稍寬。principal 的寫入只收一個帶標籤的 cell(Column '<name>' must be a single principal cell — user:<id>, smc:<id> or room:<id> (resolve the id first, not a name));舊型別的寫入收一個原始 id(Column '<name>' must be a single <user|social-media client> id string — pass the id (resolve it first), not a name)。principal 的 filter 運算元若未帶標籤,拒絕訊息會明確點名 ref——… must be a tagged principal cell (user:<id>, smc:<id> or room:<id>) — resolve the principal first and pass its ref. Reads return {ref,kind,id,name,…}: send value.get('ref') back verbatim, never the bare id or the whole dict.。在舊型別欄位上,把讀取得到的加工物件塞進 filter 會被明確拒絕並提示改傳 value['id'](不會默默拆解)。聚合允許 countcount_distinctvalue_countsmodecollect,但永遠不允許 minmaxprincipal id cells have no order. Supported: count, count_distinct, value_counts, mode, collect.)與 sumavgvalue_counts 上限 50 筆。

Row policy 與 $me

Principal 欄位加上 $me token 就是最標準的「只看到自己的資料」政策:

{ "read_filter": { "and": [ { "column": "col_a1a1a1a1_a1a1_4a1a_8a1a_a1a1a1a1a1a1", "op": "eq", "value": "$me" } ] } }

$me 可用於 principalusersocial_clientstringtext 欄位;$me.department 可用於 principalstringtext。在 usersocial_client 欄位上,$me 解析成單一 id。在 principal 欄位上,它改為解析成一個帶標籤 cell 的集合——行為者自己的身分,加上他所屬的聊天室——所以 room: 指派的紀錄會送達每一位成員,而那一列從頭到尾沒有點名任何人。該解析、各通道的收斂方式與 fail-closed 上限記載於 Row policy 動態代號,本頁不重複。

在寫入 grant 時,principal 欄位條件會被收斂到同樣那五個運算子。每一則 row policy 錯誤都會冠上該葉節點的樹狀路徑:頂層葉節點是 row policy,巢狀葉節點則是 row policy <path>——例如 row policy and[0]row policy not.or[1],在 link_target 內則是 row policy target.and[0]。以下的 <label> 就代表這個前綴。

排序或 contains 運算子會被拒絕:422 <label> op '<op>' cannot be applied to column '<key>' — principal columns support only eq/neq/in/is_null/is_not_null in row policies。運算元不合法則是 <label> column '<key>' principal filter requires a non-empty string id operand<label> column '<key>' principal 'in' filter requires a non-empty list of non-empty string ids。在 principal 欄位上,非 policy token 的常值運算元還必須能解析成帶標籤的 cell:<label> column '<key>' principal filter operand must be a tagged principal cell (user:<id>, smc:<id> or room:<id>)。把 $me 用在不適用的欄位型別上是 <label> token $me may only target user/social_client/principal/string/text columns — column '<key>' is type <type>$me.department 則是 <label> token $me.department may only target string/text/principal columns — column '<key>' is type <type>。在這層收斂出現之前,負責人欄位上的排序條件會用字典序決定列的可見性——一個「已過濾」的授權卻悄悄放寬了範圍。

$me 加負責人欄位的政策會讓所有人都看不到未指派的列。空的、缺少的或 "" 的 principal cell 永遠不會命中任何值運算子,只有 is_null 選得到它。租戶通常會回報成「新工單看不到」。如果未指派的列應該保持可見,請加上明確的 is_null 分支。

$me$today$now 是 row policy 的 token:在 rollup filter、規則、SCP 純量葉節點或臨時的 REST aggregate 上,它們會被直接拒絕,訊息是 filter value '$me' on column '<display>' — identity/date tokens ($me, $today…) are only valid in ACL row policies. use a literal value here; tokens resolve only inside grant read_filter/edit_filter row policies。Trigger 的 source.filter 有自己的對應訊息:source.filter identity/date tokens ($me, $today…) are not valid (triggers evaluate with no acting user; use a literal value)

$me 當成寫入值——只限 agent 通道

儲存用的 cell 文法沒有變動:它依然既不接受 $ 也不接受第二個 :,REST 寫入仍然必須帶已解析的 id 或 ref。自 backend PR #1190 起,agent 工具組接受一個例外,而那是在驗證之前完成的一次翻譯,不是文法放寬。

custom_tables_insert_recordcustom_tables_update_record,以及 custom_tables_bulk_record_actions 的 insert 與 update 兩個分支,都允許人員 cell 寫成 $me 這個 token——去除前後空白、不分大小寫、僅此一種寫法。工具箱會在 _validate_insert_data 與 CRUD 歸屬閘門之前把它改寫成當事人,因此下游的一切(包含本頁的寫入契約)看到的都是普通的 cell,資料庫也永遠不會存進任何長得像 token 的東西。

欄位型別內部使用者通道外部 client 通道
principaluser:<id>(帶標籤的 cell)smc:<id>(帶標籤的 cell)
user裸的使用者 id不解析
social_client不解析裸的 client id

兩個舊型別各自綁定單一種身分、只存一個裸 id,所以「我」只在對應的通道上存在;無法為該欄位解析時,token 會原樣留著,接著被上面那條一般形狀檢查擋下來,而不是無聲地寫成另一個人。伺服器沒有同義詞表——「我」「me」「myself」是模型的責任,由它的協定來教——而租戶歸屬與存活檢查仍然套用在解析後的 id 上。見 agent 工具箱

IaC 的可攜身分

原始資料庫 id 會把 IaC 文件綁死在單一安裝環境。因此 principal cell 接受可攜的身分 token,在 plan 與 apply 時解析,以公司為錨點並且 fail-closed:

  • user 欄位用 $user:<username>——username 在公司內唯一,存下來是裸的 user id。
  • social_client 欄位用 $smc:<platform>:<platform_user_id>,存下來是裸的 social client id。
  • principal 欄位用 $user:$smc:,或 $room:<聊天室名稱>$room:<部門名稱>/<聊天室名稱>。三者都會存成該欄位文法要求的帶標籤 cell——user:<id>smc:<id>room:<id>——因為標籤就是 token 前綴去掉 $

$dept: 只用於 grant。部門沒有對應的帶標籤 cell 形式,所以 cell 裡的 $dept:Sales 在任何查詢之前就是逐行的 kind 錯誤:principal token '$dept:Sales' does not match the column's type: a principal column expects $user:<...> or $smc:<...> or $room:<...> or a raw id。同一張封閉對照表也會拒絕 user 欄位裡的 $smc:social_client 欄位裡的 $user:,而且不論 token 帶的名稱是否存在都會觸發。

Trigger 的 when 條件值也帶同樣的 token:usersocial_client 欄位上的 $user:$smc: 解析成裸 id,principal 欄位上的三種前綴則解析成帶標籤 cell("value": "room:<id>",運算子維持不變)。identity 型別的 invoke_command 輸入常值只接受 $user:$smc:——沒有 identity:principal 這種輸入型別,所以那裡一律解析成裸 id。Grant principal 依 principal.type 使用四種前綴:user$user:department$dept:<部門名稱>client$smc:chatroom$room:<聊天室名稱>$room:<部門名稱>/<聊天室名稱>insight_selection.chatroom 也接受 $room:。kind 對前綴的對照表是封閉的:表上沒有的 kind 會得到明確的 unknown principal kind '<kind>' for token '<tok>',而不是靜默退回 $smc:

{"kind":"column","table":"tickets","ref":"assignee","spec":{"name":"Assignee","type":"principal"}} {"kind":"column","table":"tickets","ref":"reviewer","spec":{"name":"Reviewer","type":"user"}} {"kind":"record","table":"tickets","data":{"ticket_no":"T-100","assignee":"$room:Support Desk/Tier 1","reviewer":"$user:alice"},"on_drift":"update"} {"kind":"grant","table":"tickets","principal":{"type":"user","id":"$user:alice"},"spec":{"can_read":"filtered","read_filter":{"and":[{"column":"assignee","op":"eq","value":"$me"}]},"can_insert":true,"can_edit":"none"}}

解析在 pass 0 執行,早於 record 分類,整份文件每種 kind 批次查一次,每個 token 得到的結果恰好是「一個 id」或「一則錯誤」二選一。永遠不會靜默跳過。principal 型別的 cell 裡任何以 $ 開頭的值都會被當成 token,所以格式錯誤的那一個會浮現自己的逐行錯誤,而不是以假的原始 id 流到寫入閘門。不以 $ 開頭的字串會原樣通過:usersocial_client 欄位上的舊型原始 id,以及 principal 欄位上的原始帶標籤 cell,兩者都合法,但也都只是由寫入閘門(而非解析器)把關的同環境捷徑——可攜的寫法是 token。因為分類比對的是解析後的正規值,重新匯出已套用的文件會 plan 出 0 差異。

失敗模式全都是明確的:

Token失敗訊息
$user:aliceprincipal token '$user:alice': no user with username 'alice' in this company / principal token '$user:alice': user 'alice' is deleted
$smc:line:U123principal token '$smc:line:U123': no social client with platform user id 'U123' on platform 'line' in this company / principal token '$smc:line:U123': ambiguous — 2 social clients match, in chatrooms ['<room-a>', '<room-b>']
grant 裡的 $dept:Salesno department named 'Sales' in this company / department 'Sales' is deleted / ambiguous — <n> departments share the name 'Sales' / malformed principal token '$dept:': empty department name (...)
$room:Supportno live chatroom named 'Support' in this company / ambiguous — 2 live chatrooms are named 'Support' (['<id-a>', '<id-b>']); qualify it as '$room:<department name>/Support'
$room:Support Desk/Tier 1no live chatroom named 'Tier 1' in department 'Support Desk' in this company / ambiguous — 2 live chatrooms match (['<id-a>', '<id-b>'])
cell 裡的 $dept:Salesprincipal token '$dept:Sales' does not match the column's type: a principal column expects $user:<...> or $smc:<...> or $room:<...> or a raw id——$dept: 只用於 grant
前綴與欄位或 grant 種類不符principal token '<tok>' does not match the column's type: a user column expects $user:<...> or a raw id
格式錯誤malformed principal token '$user:': empty username (expected '$user:<username>', '$smc:<platform>:<platform_user_id>', '$room:<chatroom name>' or '$room:<department name>/<chatroom name>')——grant 通道的提示還會多列出 '$dept:<department name>'
無法解析 scopecannot resolve this scope's company for principal token resolution

$room: 是唯一一個自然鍵完全沒有唯一性約束的 token——聊天室名稱是自由文字。請把限定形式 $room:<部門名稱>/<聊天室名稱> 當成預設寫法,而不是備案。名稱是以最後一個斜線切分的,所以 $room:A/B/C 代表部門 A/B、聊天室 C

$smc: token 也可能合理地無法解析:SocialMediaClient 的唯一性是以聊天室為單位,所以同一組 (platform, platform_user_id) 出現在兩個聊天室就是模稜兩可,會 fail-closed 而不是挑第一筆。

IacGrantPrincipal.id 為此放寬了限制。現在是 1–160 字元、符合 ^([a-z0-9][a-z0-9_.-]{0,35}|\$(user|dept|smc|room):[^\r\n]{1,150})$——原始 id 仍然必須不含冒號與空白,好讓推導出的 grant:<type>:<id> ref 保持合法;而 token 的內容是最多 150 字元的自由文字,因此含空白與冒號的部門與聊天室名稱都寫得出來。Token 會在推導那個 ref 之前先正規化成解析後的原始 id;無法解析的 token 會得到佔位 ref <prefix>unresolved-<8 位十六進位>,讓 plan 回報逐行錯誤而不是 500。

匯出會把原始 id 反向對應回 token,每種 kind 批次查一次並限定租戶,涵蓋 usersocial_client 的 record cell、principal cell、trigger when 值、identity command 輸入、link 自然鍵清單,以及四種 grant principal。principal cell 會先被解析,再用它自己的標籤查對應的表——user: 查使用者、smc: 查 social client、room: 查聊天室。聊天室在公司內存活房間中裸名唯一時輸出 $room:<聊天室名稱>;否則在該 (部門, 聊天室) 組合唯一、且部門名稱本身不含 / 時,輸出 $room:<部門名稱>/<聊天室名稱>。對應不出來的一律原樣輸出:principal 欄位輸出原始帶標籤 cell(user:<id>smc:<id>room:<id>),舊型別欄位與 grant 則輸出原始 id。這涵蓋兩種形式都模稜兩可的聊天室、名稱本身含 / 的聊天室、被軟刪除或懸空的聊天室、(platform, platform_user_id) 在公司內對到多筆的 smc、模稜兩可或已軟刪除的部門,以及跨租戶或 username 為空的 id。被軟刪除的使用者仍然會被 token 化,這樣重新套用時會在解析器大聲失敗,而不是把一個死掉的原始 id 帶下去。同一個未變動系統的兩次匯出是位元相同的;帶有原始帶標籤退路的匯出,在原本的 scope 重新 plan 依然是 0 差異。

匯出與公開曝光

公開讀取 token 把 principal 欄位當成整個功能的 PII 邊界,而且這道閘門是從「家族」推導出來的——所以 principal 上線的第一天就繼承了預設隱藏。除非管理員明確指名——同時出現在檢視的 columns 與/或 token 的 visible_columns,並通過兩者的交集——否則三種型別一律被強制隱藏。即使公開,公開路徑也只提供原始字串;它從不執行顯示加工器,所以匿名讀者永遠拿不到姓名。沒有任何方式可以公開一個人或一個聊天室的名稱。

鑄造公開 token 時也會拒絕引用 $me$me.departmentread_filterread_filter may not reference $me / $me.department on a public token (no principal exists); $today is allowed(422;IaC public_read line 回報的同一錯誤字尾是 …; $today and $now are allowed)。讀取時公開路徑會把 $me 解析成永不命中——在 principal 欄位上也一樣,因為兩個 principal 集合的 getter 都刻意不傳。若過濾或排序指向 token 與檢視未公開的 principal 欄位,會回傳統一的防探測 404,與未知、已撤銷或已過期的 token 完全相同。

型別轉換

把欄位轉或轉任何一種 principal 型別——包括 usersocial_clientuserprincipal(值空間互不相交)——都不會做任何轉換。每個 cell 都會重設為欄位預設值,而 principal 欄位不能宣告預設值,所以永遠是 null。沒有「加上前綴」的 userprincipal 轉換:把既有的負責人欄位轉成 principal,資料會全部消失。請改為新增一個 principal 欄位,再透過寫入路徑回填。

還有兩個相關邊界。必填欄位完全無法轉成 principal 型別:型別變更驗證器要求非空欄位必須提供 default_value,而 principal 欄位禁止 default_value,這是一個死路(Type change from '<old>' to 'principal' requires a default value for required (non-nullable) columns. Please provide a default_value.)。若該欄位被計算欄引用,轉型會是結構化的 409 {"detail": "Column is referenced by computed columns", "conflicts": [...]},而不是 400。

還原歷史版本時,principal cell 會被以字串做型別檢查,而還原路徑會對這次還原會變更的 principal cell 重跑歸屬閘門——所以簽核的時間差無法把已刪除、已關閉聊天室或跨租戶的身分洗回一列裡。還原一個負責人已離職的版本會明確失敗。

常見陷阱

  • 寫入形狀不等於讀取形狀。 寫進去是字串,讀回來是物件。把剛 GET 到的物件直接 PUT 回去會被拒絕。在 principal 欄位上要送 value.ref,在舊型別欄位上要送 value.id。這是最常見的整合錯誤。
  • 讀取的 cell 是 string | object,兩種都要處理。
  • principal cell 一定要有標籤。裸 id 不是簡寫,而是 400。
  • 有沒有 ref 就是分辨 principal cell 與舊 cell 的方法。舊 cell 從來沒有 refkind
  • principal filter 上未帶標籤的運算元在所有地方都是 400,這是刻意的:否則 eq 會靜默回零列、neq 會回每一列。
  • name_eqname_containsusersocial_client 上可用,在 principal 上被拒絕——先解析,再用 ref 過濾。
  • 任何地方都不能依 principal 欄位排序。
  • 轉成或轉離 principal 型別會清空整欄資料。
  • 負責人欄位上的 $me 政策會讓所有人都看不到未指派的列。
  • lookup 帶出的 principal 欄位,以及 REST aggregate 的分組鍵,都是原始字串——不會加工。
  • IaC 身分 token 只在 IaC 有效。在 principal 欄位上它現在會解析——$room:Support Desk/Tier 1 會存成 room:<id>——但把 $user:alice 送進 REST 紀錄 API 仍然會被拒絕。
  • 指派 room:<rid> 不需要成員身分——但該聊天室必須存活且屬於本租戶。
  • 必填 principal 欄位的表完全無法使用測試資料產生器:產生器對 principal 欄位輸出 null,不會捏造身分。≤2000 列的路徑會在 schema 驗證失敗;背景路徑則在插入任何資料前拋出 Cannot generate test data: required principal column '<internal key>' cannot be auto-populated。做展示時請把該欄位設為選填。
  • lookup 的設定錯誤訊息仍然列出加入 principal 之前的純量清單。不要把那段錯誤文字當成允許型別的參考——principal 欄位合法的 lookup 目標。

試試看

API Playgroundcolumns.create 加一個 principal 欄位,用 room:<某個存活聊天室的 id> 寫一筆紀錄,比較回應的 cell 與你送出的內容——接著在更新時把 ref 原封不動送回去。然後在該欄位上鑄造一個 "value": "$me" 的過濾式授權,再以該聊天室成員的身分讀取整張表——請見 Grant 類型與 row policy

Last updated on