Principal 欄位:principal、user 與 social_client
適用情境
有三種欄位型別指向的是「人」而不是資料。後端把它們歸在同一個名字 PRINCIPAL_COLUMN_TYPES(src/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 屬於北區業務室」也終於有了表示法,成員異動完全不必動到任何一列。
user 與 social_client 沒有任何改變,也繼續支援:它們存原始 id、加工成不帶 ref/kind 的 dict,既有的列、篩選、授權與匯出全部逐位元不變。它們沒有被淘汰,但新的負責人欄位應該用 principal。
三者都把單一字串直接存在 record.data 裡。和 link 不同,這裡沒有關聯表、也沒有搬移;基數固定為一。多負責人 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.id | user |
smc: | SocialMediaClient.id | social_client |
room: | Chatroom.id | chatroom |
標籤詞彙就是 IaC 身分 token 的前綴去掉 $ 記號。id 本體既不允許 $ 也不允許 :,而且整個字串是用 fullmatch 比對、不是 $ 錨定的 regex——所以結尾多一個換行也會被拒絕。因此四個 id 空間在構造上互斥,任一空間的值都不可能被誤認成另一個:
principalcell(user:u1)——有標籤;- 舊的
user/social_clientcell——裸 id,沒有標籤; - row policy token(
$me、$me.department)——以$開頭; - IaC 身分 token(
$user:alice)與 actor key(client:<id>)——$記號與client:這個拼法都在文法之外。社群客戶的標籤之所以是smc:而不是client:,正是為了這一點。
加工後的讀取契約講的仍然是 social_client 與 chatroom,不是 smc 與 room:標籤是傳輸拼法,kind 才是詞彙。
建立 schema
以下是 columns.create 的 body。三種型別都只接受 name、type、required、description:
{
"name": "Assignee",
"type": "principal",
"required": false,
"description": "這筆紀錄屬於哪個使用者、客戶或聊天室"
}{
"name": "Reviewer",
"type": "user",
"required": false,
"description": "內部審核者"
}default_value 被禁止,因為在編寫 schema 的當下無法驗證預設負責人是否屬於本租戶;max_length 與 options 對不透明的身分沒有意義;附件與計算欄的設定欄位一概不接受。required 可以使用。
| 欄位 | 在任一 principal 欄位上的結果 |
|---|---|
default_value | 422 principal (user/social_client/principal) columns cannot have a default_value |
max_length | 422 principal (user/social_client/principal) columns cannot set max_length |
options | 422 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>)。user/social_client cell 必須是非空字串:400 Field '<display name>' must be a principal id string。
租戶歸屬與存活。 每種 kind 各批次查一次、沒有 N+1;principal 依標籤分派,舊的兩種依欄位型別分派:
| Cell | 必須解析到 | 拒絕訊息 |
|---|---|---|
user:<id>,以及 user 欄位 | 本表有效公司底下 is_deleted == False 的 User | 400 Unknown or inaccessible user ids: ['<id>'] |
smc:<id> | 所屬聊天室在該公司底下且未刪除的 SocialMediaClient | 400 Unknown or inaccessible social_client ids: ['<id>'] |
social_client 欄位 | 所屬聊天室在該公司底下的 SocialMediaClient(刻意不檢查聊天室存活——收緊會讓既有已寫入的 cell 被拒) | 400 Unknown or inaccessible social_client ids: ['<id>'] |
room:<id> | 該公司底下未刪除的 Chatroom | 400 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,再加上 ref 與 kind:
| 標籤 | 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 反推標籤,也不要送裸 id。user 的 name 取暱稱、沒有就退回 username;social_client 的 name 是各通道的顯示名稱(可能為空);chatroom 的 name 就是聊天室名稱。
舊的 user 與 social_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 都會拿掉 username 與 is_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 前面),一個看起來有意義、其實沒有的順序。各處使用的運算子集合完全相同:
eq、neq、in、is_null、is_not_null——僅此而已。
| 介面 | 行為 |
|---|---|
舊版 filters map | 精確等值;不會走 string/text 那條不分大小寫的部分比對分支。在 principal 上,未帶標籤的值是 400 Filter value for column '<display name>' must be a tagged principal cell (user:<id>, smc:<id> or room:<id>);在 user/social_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) |
| 運算元型別 | eq/neq 需要非空字串,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_contains | 在 principal 上被拒絕:400 Op '<op>' is not supported on principal column '<column>' — resolve the principal first and filter by eq/in on its ref。user/social_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/aggregate | group_by 可以;count 與 count_distinct 可以;sum/avg 被數值閘門擋下,min/max 被可排序閘門擋下 |
| 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,編輯時就是一個權限漏洞。
永遠不要把顯示名稱當成 eq/in 的 value。在 principal 上會被直接拒絕;在 user/social_client 上則會悄悄回傳零列,因為儲存的 cell 是一串原始 id。要依名稱過濾 user/social_client,正規做法是 name_eq/name_contains 運算子(伺服器端會把名稱解析成 id 再過濾);在 principal 上它們會被拒絕——請改用 custom_tables_resolve_principal,再用回傳的 ref 過濾。伺服器端對任何 principal 欄位都沒有全文姓名搜尋:agent find_records 的 q 只掃 string/text 欄位。
儲存檢視在設定當下不驗證 principal 運算子。stored_filters 對 principal 欄位使用 contains 或 gt 的檢視會在建立時被接受,只有在套用時才失敗。
伺服器端沒有辦法依 principal 欄位排序。請改用 group_by + count,或把姓名反正規化到一個 string 欄位再排序。principal 欄位的 group_by 以整個帶標籤字串為鍵,所以 user:x 永遠不會和 smc:x 混在一起。
Principal 欄位可以用的地方
三種型別都算已儲存純量,所以日常需求都是一級支援:
| 介面 | 支援情形 |
|---|---|
unique 規則成分 | 可以——這正是「負責人去重」的情境。空的或不存在的 cell 不受限制(SQL null 語意),使用 case_insensitive 時值會先轉小寫再雜湊 |
Upsert 的 match_column | 可以 |
lookup 目標欄 | 可以,但帶出的 cell 是原始字串 |
rollup 的 count_distinct 目標 | 可以 |
compare 規則 | 只有 eq/neq,而且只能對照同一種 principal 型別的另一個欄位。排序運算子、跨型別(principal 對 user、user 對 social_client)與 principal 對 string 都會被拒絕:400 principal column '<display>' supports only eq/neq against another column of the same principal type。principal 自成一個可比較類別——帶標籤的 cell 永遠不可能等於裸 id,跟舊型別配對在構造上就是永遠為 null 的比較 |
check 規則 | 只有 eq/neq/in/not_in。matches(正則)與排序運算子會被拒絕:400 principal column '<display>' supports only eq/neq/in/not_in checks |
transition 規則 | 可以——重新指派的狀態機(room:sales → user:lead)和 select 的狀態機一樣合法。matches 仍然被拒絕:帶標籤的身分不是正則的比對對象 |
規則與 trigger 的 when | is_null/is_not_null/eq/neq/in,且值必須是非空字串: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:user 或 string;social_client 可餵給 identity:social_media_client 或 string;principal 只能餵給 string——identity 型別的參數預期的是裸 id,每次執行都會拒絕或錯誤解析 user:u1 |
| Formula 引用 | 永遠不行——400 Formula cannot reference a principal column。負責人不是算術輸入 |
| Command DSL | principal 運算元可以和 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 就是一個普通純量字串,有正常的綁定表示法。(boolean、json 與 interval 不能當自然鍵,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'](不會默默拆解)。聚合允許 count、count_distinct、value_counts、mode、collect,但永遠不允許 min/max(principal id cells have no order. Supported: count, count_distinct, value_counts, mode, collect.)與 sum/avg。value_counts 上限 50 筆。
Row policy 與 $me
Principal 欄位加上 $me token 就是最標準的「只看到自己的資料」政策:
{
"read_filter": {
"and": [
{ "column": "col_a1a1a1a1_a1a1_4a1a_8a1a_a1a1a1a1a1a1", "op": "eq", "value": "$me" }
]
}
}$me 可用於 principal、user、social_client、string、text 欄位;$me.department 可用於 principal、string、text。在 user 或 social_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_record、custom_tables_update_record,以及 custom_tables_bulk_record_actions 的 insert 與 update 兩個分支,都允許人員 cell 寫成 $me 這個 token——去除前後空白、不分大小寫、僅此一種寫法。工具箱會在 _validate_insert_data 與 CRUD 歸屬閘門之前把它改寫成當事人,因此下游的一切(包含本頁的寫入契約)看到的都是普通的 cell,資料庫也永遠不會存進任何長得像 token 的東西。
| 欄位型別 | 內部使用者通道 | 外部 client 通道 |
|---|---|---|
principal | user:<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:user/social_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 流到寫入閘門。不以 $ 開頭的字串會原樣通過:user/social_client 欄位上的舊型原始 id,以及 principal 欄位上的原始帶標籤 cell,兩者都合法,但也都只是由寫入閘門(而非解析器)把關的同環境捷徑——可攜的寫法是 token。因為分類比對的是解析後的正規值,重新匯出已套用的文件會 plan 出 0 差異。
失敗模式全都是明確的:
| Token | 失敗訊息 |
|---|---|
$user:alice | principal token '$user:alice': no user with username 'alice' in this company / principal token '$user:alice': user 'alice' is deleted |
$smc:line:U123 | principal 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:Sales | no 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:Support | no 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 1 | no live chatroom named 'Tier 1' in department 'Support Desk' in this company / ambiguous — 2 live chatrooms match (['<id-a>', '<id-b>']) |
cell 裡的 $dept:Sales | principal 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>' |
| 無法解析 scope | cannot 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 批次查一次並限定租戶,涵蓋 user/social_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.department 的 read_filter:read_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 型別——包括 user ⇄ social_client 與 user ⇄ principal(值空間互不相交)——都不會做任何轉換。每個 cell 都會重設為欄位預設值,而 principal 欄位不能宣告預設值,所以永遠是 null。沒有「加上前綴」的 user → principal 轉換:把既有的負責人欄位轉成 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,兩種都要處理。 principalcell 一定要有標籤。裸 id 不是簡寫,而是 400。- 有沒有
ref就是分辨principalcell 與舊 cell 的方法。舊 cell 從來沒有ref/kind。 principalfilter 上未帶標籤的運算元在所有地方都是 400,這是刻意的:否則eq會靜默回零列、neq會回每一列。name_eq/name_contains在user/social_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 Playground 用 columns.create 加一個 principal 欄位,用 room:<某個存活聊天室的 id> 寫一筆紀錄,比較回應的 cell 與你送出的內容——接著在更新時把 ref 原封不動送回去。然後在該欄位上鑄造一個 "value": "$me" 的過濾式授權,再以該聊天室成員的身分讀取整張表——請見 Grant 類型與 row policy。