Skip to Content
核心概念權限與 ACLGrant 類型與 row policy

Grant 類型與 row policy

Grant 由三個基本能力與可選限制組成:can_readcan_insertcan_edit,再加上 row filters 與 visible_columns。選擇最接近 principal 的 grant 類型,能讓 revoke、稽核與 fallback 行為更容易理解。

共用 permission shape

{ "can_read": "filtered", "read_filter": { "and": [ { "column": "col_aaaaaaaa_aaaa_4aaa_8aaa_aaaaaaaaaaaa", "op": "eq", "value": "北區" } ] }, "can_insert": true, "can_edit": "filtered", "edit_filter": { "and": [ { "column": "col_bbbbbbbb_bbbb_4bbb_8bbb_bbbbbbbbbbbb", "op": "eq", "value": "草稿" } ] }, "visible_columns": [ "col_aaaaaaaa_aaaa_4aaa_8aaa_aaaaaaaaaaaa", "col_bbbbbbbb_bbbb_4bbb_8bbb_bbbbbbbbbbbb", "col_cccccccc_cccc_4ccc_8ccc_cccccccccccc" ] }

can_read / can_edit 可為:

  • none:不可讀或編輯任何列。
  • own:只限 created_by 是目前 user/client 的列。
  • filtered:只限對應 filter 成立的列。
  • all:所有 ACL 可見列;若有 SCP,仍須通過 SCP floor。

can_insert 是獨立 boolean。在 ladder none = 0 < filtered = 1 == own = 1 < all = 2 上,若 edit level 高於 read level,伺服器會把 read 提升到相同 level;而當該 level 是 filtered 時,read 會沿用同一條 predicate,因此 read none + edit filtered 的 grant 最後讀到的正好是自己可編輯的範圍,而不是什麼都讀不到。filteredown 同級,彼此不合併。

Row filter grammar

Row policy 是一棵布林樹(PR #1127 起):節點是 {"and": [node, ...]}(每個子節點都成立)、{"or": [node, ...]}(至少一個成立)、{"not": node}(嚴格補集),一個 predicate leaf {"column", "op", "value"},或一個 link_target leaf。群組不可為空;每個節點只能帶自己那一種 key,多一個 key 就拒絕;root 可以是任一種節點——舊的 {"and": [predicates]} 只是其中一棵合法的樹。上限:深度 ≤ 5(單一 predicate 算深度 1)、整條 policy ≤ 24 個 predicate、in list ≤ 100 筆。錯誤訊息會帶路徑(row policy or[1].not …)。

Predicate 的 column 必須用 internal col_<hex> key,且只能指向 stored scalar 欄位——stringtextintegerfloatbooleandatedatetimeselectusersocial_clientprincipal(以及 id)。Computed 欄位(link、rollup、lookup、formula)與 attachmentmulti_selectjson 都會被 422 拒絕。可用 operator 為 eqneqgtgteltlteincontainsis_nullis_not_nullin 帶非空 scalar list(最多 100 筆),null operator 不帶有效 value。

身分型別的欄位——tagged 的 principal 型別,以及兩個舊的裸 id 型別 usersocial_client——加上 id,只接受 eqneqinis_nullis_not_nulleq / neq 必須給非空字串,in 必須給非空的字串 list。身分值是不透明值,因此排序類或 contains 的 predicate 會被直接拒絕,而不是編譯成沒有意義的字典序比較。

兩類欄位的差別在於操作元可以是什麼:

欄位型別literal操作元$me$me.department
principal(tagged)必須是 tagged cell——user:<id>smc:<id>room:<id>。其他一律 422 row policy and[0] column 'col_…' principal filter operand must be a tagged principal cell (user:<id>, smc:<id> or room:<id>)一個 tagged cell 的 SET該部門 tagged cell 的 SET
user / social_client(舊)裸 id 字串呼叫者的 scalar id寫入 grant 時直接拒絕——不屬於 string / text / principal
string / text任意字串呼叫者的 scalar id呼叫者的 scalar department_id

本頁與 ACL 各頁一律以 「principal 欄位」指 tagged 的 principal 型別;要指舊的 usersocial_client 欄位時會明講型別名稱。

notneq 不一樣。 值比較不命中空 cell(見下方 callout),所以 {"not": {col eq X}} 包含空白 cell,而 {col neq X} 排除它們。要「有值且不同」用 neq;要「除了 X 之外的全部」用 not

Fail-closed 帶極性。 無法解析的 token(沒有 identity 的 $me、沒有部門的 $me.department)、壞掉的節點、找不到的 link,一律往「縮小」的方向折疊:and 底下整組不命中、or 底下該分支被丟掉而其餘分支照常生效、not 底下該分支變成永不命中——root 永遠不會因為某個失敗而變成「全部列」。

Row policy 可以沿本表的 link 欄位跨到被連結那張表,對它的列做判斷(一跳):

{"link": "<link 欄位的 col_<hex>>", "quantifier": "any", "target": {"column": "<被連結表的 col_<hex>>", "op": "eq", "value": "$me"}}
  • target 是對被連結表評估的 row policy 節點:scalar predicate 與 and/or/not 任意組合,但不能再放 link leaf(一跳)。target 內的 token 依被連結表的欄位型別配對與解析——上面的例子就是「派給我的列」(Staff.TS帳號 eq $me)。
  • quantifierany = 至少一個被連結列成立;all = 每個被連結列都成立。沒有被連結列時 all 為空真,所以 all 必須明確寫 require_presenttrue = 沒有目標列就不命中);any 不可帶 require_present(本來就不命中)。
  • 只算未刪除的目標列(目標列進垃圾桶,授權即刻消失;還原即恢復),且目標表的 SCP channel floor 一併套用。目標表自己的 row policy 與 column ACL 不套用——policy 作者(本表管理者)決定揭露什麼。
  • 上限:每條 policy ≤ 6 個 link leaf;target 佔一層深度,裡面的 predicate 計入 24 個。
  • {"link", "op", "value"}(SCP 的 link_membership不接受——改寫成 link_target{"column": "id", "op": "in", "value": [...]}

一筆 department grant 就能讓每位業務只看到派給自己的案子、主管看到自己帶的人的案子——不用在每張表複製身分欄位:

{"or": [ {"link": "col_負責業務", "quantifier": "any", "target": {"column": "col_TS帳號", "op": "eq", "value": "$me"}}, {"link": "col_負責業務", "quantifier": "any", "target": {"column": "col_主管", "op": "eq", "value": "$me"}} ]}

Edit filter 的 post-image 是用寫入後的 link 判斷的:業務把案子改派給別人,案子會離開自己的範圍,因此回 403;改派由主管或以 command 執行。

Level 與 filter 必須雙向一致。filtered 卻缺 filter 是 422can_read "filtered" requires a read_filter);filter 出現在其他 level 同樣是 422read_filter is only valid when can_read is "filtered")——是拒絕,不是忽略。因此送出 can_read: "all" 又夾帶殘留的 read_filter 會寫入失敗,而不是安靜地讓該 principal 看到所有列。

Read filter 進入 SQL query;edit filter 同時檢查 update 的 pre-image 與 post-image,避免把列「搬入或搬出」未授權範圍。單筆不可見 read 回 uniform 404,而非洩漏列存在;可讀但不在可編輯範圍內的列則回 403 This record is outside your editable scope.。REST、agent toolkit(含跨表 join 與 link 巡覽工具)、compose commands 與 public read 走同一組 SQL / Python enforcement,同一條 policy 在每個介面縮出同一組列;agent session 在部門表上另有所在房間的天花板(見 effective permissions)。

Predicate 的 value 也可以是每次請求解析的 token——$me$me.department$today$today±Nd$now——這讓同一筆 grant 對每位檢視者都能表達「我的列」或「未來七天」。在 principal 欄位上,兩個身分 token 都會解析成一個 tagged cell 的 SET,而不是單一 scalar id,命中該 cell 所指的內部 user、social client 或房間;見 row policy tokensprincipal 欄位上的 SET 語意

兩筆 grant 的指派模型

principal 欄位加上兩個 SET token,取代了一整類逐人維護的 grant。案件表只帶一個 principal 欄位 負責人,兩筆 grant 就做完整件事:

GrantRow policy持有者看到什麼
業務的 department grant{"column": "負責人", "op": "eq", "value": "$me"}指派給自己 TS 帳號(user:)、自己 LINE 身分(smc:)、或自己所屬任一房間(room:)的列
主管的 per-user grant{"column": "負責人", "op": "eq", "value": "$me.department"}指派給部門任一成員部門任一房間的所有列

(實際存下來的 policy 用的是欄位的 internal col_<hex> key,這裡寫顯示名稱只是為了好讀。)在解析階梯上 per-user grant 排在 department grant 前面,這就是主管那一筆要寫成同一張表的 user grant 的原因。不用在每張表複製身分欄位、不用為每位主管開一筆 grant,換部門的業務也會自動換範圍——department SET 是每次請求重新計算的。

由此推出的四個行為都是契約,不是巧合:

  1. 可以指派給自己不在的房間。 寫入的檢查只有租戶與存活兩項。把列指派給別隊的房間,永遠不會讓寫入者自己的範圍變寬。
  2. 業務不能把列改派出自己的範圍。 edit_filter 也會判斷 post-image,寫入後若離開 $me SET,回 403 This record is outside your editable scope.
  3. 交接的做法是「指派給自己所屬的房間」。 那個房間本來就在業務自己的 SET 內,所以列仍然看得到,同時對該房間可見。
  4. 跨隊搬移是主管的寫入,因為只有 $me.department 的範圍同時涵蓋兩隊。

要找出該寫進 cell 的確切值,用 custom_tables_resolve_principalkinduser | social_client | chatroom),把回傳的 ref 原樣寫入;不要自己組 tag:id 字串。它掛在 analyst 子代理的讀取 toolkit 上,且只在有已驗證的內部 principal 時才會建立。

值比較不再命中空值 cell。 Row policy 的 SQL lane 已修正為與 Python twin 一致:eqneq、範圍 operator、incontains 在 cell 為「key 不存在/SQL NULL/明確 JSON null""」時一律為 false,is_null / is_not_null 是唯一能詢問空值的方式。因此 neq 的「不是我的」範圍現在會排除沒有歸屬的列,任何在有空白 cell 的欄位上使用 neq、範圍或 contains 的既有租戶 policy 都會因此縮小可見範圍。完整說明(哪些值算空)見 row policy tokens。自 teamsync-backend PR #1169 與 #1170 起,一般 query lane 也套用同一條規則,所以 read_filterstored_filters search 對每一個空白 cell 的判定一致。

刪除或改型別 row policy 引用到的欄位會以 409 與具型別的衝突資訊拒絕(dependent_row_policy,欄位隱藏 map 則是 dependent_column_acl)。所有 grant 介面都納入判斷——user、department、client 與 chatroom grants,settings.default_permissions,以及 public-read token 的 read_filterlink_target 也受保護:刪除本表的 link 欄位,或刪除對方表上被 target 引用的欄位,都會回 409 並指出是哪張表的哪條 policy。少了這道保護,欄位被移除後 predicate 會退化成命中所有列,等於把整張表交給 filtered principal。

User grants:所有 scopes

curl -X POST \ "$BASE_URL/private/module/custom_tables/department/11111111-1111-4111-8111-111111111111/tables/22222222-2222-4222-8222-222222222222/permissions" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "user_id": "33333333-3333-4333-8333-333333333333", "can_read": "all", "can_insert": true, "can_edit": "own" }'

permissions.grant 是 user + table 的 upsert/replace。Managers 已有 implicit full access,不能再寫 explicit grant。

Grant 寫入會鎖住它的 parent permission 列,所以兩位 moderator 同時編輯同一張表的 grants 有可能在資料庫層相撞。經過伺服器自身的處理之後——死結要等有限次行程內重試用盡、鎖等待逾時則第一次發生——這種相撞現在會回 409 {"detail":"Concurrent write conflict (lock); please retry the request."};它以前是以一個什麼都沒說的 500 {"detail":"Internal Server Error"} 逸出,完全看不出該重試。當下沒有任何東西被寫入,而這個路由本身是 upsert,所以原樣重送同一個請求是安全的。詳見併發與 lock 衝突

Revoke user grant 會回報使用者落到哪一層

Explicit per-user grant 會覆蓋該使用者原本會解析到的結果,不論 level 高低。因此移除它有三種可能結果,不是一種:權限放寬(最常見也最令人意外——這筆 grant 原本就是用來收窄的)、收窄(這筆 grant 比 fallback 更寬),或不變。落點也不一定是 table default:解析會依序往下走 department grant → 合併後的 internal chatroom grants → settings.default_permissions → system fallback。

正因如此,permissions.revoke 不再只回一個成功訊息。三種 scope 都回傳 RevokePermissionResponse

{ "message": "Permissions revoked successfully", "effective_permissions": { "can_read": "all", "can_insert": false, "can_edit": "none", "is_manager": false }, "widened_access": true, "warning": "Revoking this grant WIDENED the user's access on can_read: the access they fall back to is broader than the grant that was removed." }

effective_permissionspermissions.me 是同一個四欄 model,且是在 revoke 之後才解析出來的。Revoke 前的解析、刪除、以及 revoke 後的解析都在同一把 permission lock 與同一個 transaction 內完成,所以回報的效果保證描述的是這次 revoke,而不是同時發生的 upsert。沒有 grant 資料列時,刪除會 rollback,路由回 404

widened_access 是在 ordinal ladder none = 0 < filtered = 1 == own = 1 < all = 2 上逐 axis 比較的結果:can_read 升一級、can_edit 升一級,或 can_insertfalsetrue。只有放寬會產生 warning,而該 warning 刻意只描述效果、不指明原因——resolver 不回傳 provenance,所以不要告訴讀者放寬是來自 table default。

widened_access: false 不等於「什麼都沒變」。把一個 filtered 範圍換成另一個 filtered 範圍(或換成 own)在 ladder 上是平移,會回報 false,但可見的列可能完全不同。不論這個旗標為何,revoke 後都要重新載入資料。

這份放寬回報只存在於單一使用者的 revoke。Bulk permissions lane 仍只回計數,department、client、chatroom grant 的 revoke 也仍回一般成功訊息,提供這些操作的 UI 必須自行重新呼叫 permissions/me

Department 與 chatroom grants

  • Department grant 把一張表分享給整個 department,有兩組刻意分開的介面。departmentPermissions.grant 只適用 chatroom table,不得視為全 scope family;departmentTableGrants.grant 同時涵蓋 departmentcompany table,並以 {target_department_id} 指名受贈方。
  • Chatroom grant 只用於 department-scoped table,透過 chatroomPermissions.grant 建立。audienceinternalexternal;同一 room 的兩個 audience 是獨立 grant rows。
  • Channel SCP 的 flat form 還要求 internal chatroom grant 帶非空 scope_values。這些值不是 visible_columns,而是 link target record IDs。詳見 SCP 與 ACL

需要 non-chatroom 介面,是因為 departmentPermissions 透過 chatroom id 綁表,department 與 company table 都沒有這種 binding。departmentTableGrants 在兩種 scope 補上 REST list/upsert/update/revoke;chatroomPermissions 則仍是把 department table 分享給 room 的獨立介面。Department grant families 使用相同 permission payload 與 manager gate,但 scope matrix 不可合併。

新家族內部,POST 是 upsert;PATCH 會先要求該 grant row 已存在(404 {"detail":"Department permission not found"})。接著兩種動詞都會用同一個 company-anchored query 重新驗證 target:它必須仍存在且屬於呼叫者的公司,否則回 404 {"detail":"Department not found"}PATCH 的檢查順序很重要:grant row 不存在時由第一個 404 勝出;row 存在、但 department 後來消失或移到別家公司時,則到第二個 404,不會繼續允許 patch。read_filteredit_filtervisible_columns 在兩個介面上都會被寫入、也會被 resolver 實際採用,但 DepartmentPermissionResponse 完全沒有這些欄位——兩邊的 list 都不會回吐它們,要顯示 row policy 的 UI 必須自己保留寫入時的內容。

Chatroom grant 對 out-of-scope room member開啟表格;對原本就在 table scope 的 internal user,只能按 axis 放寬 defaults/system baseline,在 REST 上不會因加入一個較窄 room 而被降權。但在 agent session 裡,所在聊天室的授權列同時是部門表的天花板——該房沒有授權列就是無權存取,管理者也一樣。細節見 effective permissions 的 agent session 警語。

部門表的門由誰打開

Department-scoped table 在整個 by-id 介面前面有一道 membership door——table detail、records、views、columns、exports、staged changes 都在門後。department_id 與該表不同、且角色低於 CAN_MANAGE_COMPANY(預設 3)的呼叫者,只有握有下列鑰匙之一才會被放行:

  • 一筆 explicit per-user grant,
  • 一筆 explicit table moderator 列,
  • 一筆對應其 department_id 的 department grant,
  • 一筆透過他所屬活房間的 internal audience chatroom grant,
  • 該表的 settings.default_permissions.audience: "company"

Department grant 與 moderator 列這兩支是後來才補上的,而它們修掉的是同一種失敗,前後發生兩次:授權(或 moderator 列)在清單上顯示為已授權、也解析得出實際 level——moderator 的情況甚至是 is_manager: true——但每條讀取與資料列路由仍回 403 {"detail":"Insufficient permissions for department-scoped table."}。resolver 放行、門口擋下。

門只決定進不進得來,權限仍由 resolver 決定:can_read: "none" 的 department 授權列會打開門,然後在讀取時以另一句 403 擋下。撤掉最後一把鑰匙門就重新關上,而 revoke 路由不會告訴你這件事。

把部門表分享給所有部門

settings.default_permissions.audience 只有 "scope"(預設)與 "company"。在部門表上設 "company",會讓同公司的每個使用者對這份 defaults 都算 in-scope,同時拿到一把門鑰匙,取代逐部門建立 department 授權列。用 defaultPermissions.update 寫入——三種 scope 都只有 PATCH,任何 scope 都沒有 PUT 版本。

"company" 在另外兩種 scope 會被拒絕——跨房間分享走的是 chatroom grant,company 表上所有人本來就在 scope 內——而且兩條通道的回應不同:

位置狀態碼detail
chatroom 或 company 表上的 PATCH .../tables/{table_id}/default-permissions422settings.default_permissions.audience "company" is only valid on department-scoped tables
POST .../tables,key 放在 settings.default_permissions400同一句,或 settings.default_permissions.audience must be "scope" or "company", got '<value>'

第二句永遠不可能來自 default-permissions 端點:audience 在那裡是 literal,非法值會在閘門之前就以一般 422 驗證錯誤被擋下。建立通道收的是 raw settings dict,所以只有它產得出兩句。IaC 的 table 行在 plan 與 apply 兩端以完全相同的方式驗證,因此過時的 apply 也溜不過乾淨的 plan。

PATCH .../default-permissions 取代整個 default_permissions 物件,而且只有 "company" 會被寫入——"scope" 會被丟掉,好讓既有 settings blob 保持位元組相同。因此之後任何一次省略 audience 的呼叫都會靜靜地取消這份分享。請先載入現況物件,再整份送回去。

打開開關前還有兩個後果要知道。既然同公司的每個人現在都算 in-scope,insider-protection 的合併也適用於他們:在 REST 上,同一張表上較窄的 internal chatroom grant 對他們不再收窄,而且若該 grant 帶有 visible_columns,這份 allowlist 會連同被丟棄——原本寫來當限制的分享會安靜地變成無作用。在綁定 acting room 的 agent session 裡則相反,因為那裡房間授權是天花板:沒有授權的房間解析成全部 none,被授予 can_read: "own" 的房間也維持 own,不會跟著放寬成 all

Client、permission request 與 passphrase

Manual client grant 可用於 chatroom、department 與 company table:clientPermissions.grant。Client 的 owncreated_by_client 判斷;revoke 後可能回落到其他 client access mechanism。

Client 可以對 chatroom table——或分享進他所在聊天室的 department table——提出 permission request:moderator 以 permissionRequests.list 檢視,再呼叫 permissionRequests.approvepermissionRequests.reject。Approve body 是管理員最終核准的 permission shape,不應直接信任 request 中的字串;成功會建立 explicit client grant。Reject 是 terminal decision,可附 review_note

申請生命週期在 2026-07-28 版變得誠實。它修掉的失敗在管理後台看得到,是一個自相矛盾的畫面:成員權限顯示已授權,權限申請卻同時顯示待審核——因為直接走 clientPermissions.grant 授權只寫入權限,既不結案申請、也不通知客戶;客戶最後聽到的一句話是「請等待審核」,於是再也沒試過。現在不管管理者走哪扇門,生命週期都是對稱的:

  • 直接授權會結案該客戶在這張表上的待審申請,並推播訊息告訴他現在能做什麼。每筆待審申請各自結案:新授權涵蓋申請要求的每個軸時標成 approved(review_note 為「Access set directly by a manager.」);授權沒有涵蓋申請——也就是比較窄——時標成 rejected,review_note 會列出實際給了什麼(read/insert/edit)。沒有記錄請求範圍的舊申請,任何非空授權都算滿足;沒有 superseded 這種狀態——佇列最終只會是 approvedrejected
  • 拒絕會通知申請人,並附上審核者的 review_note——這個欄位以前存了卻從未給人看過。
  • 審核者通知終於送得到真正的審核者:新開房間的申請會送達房間建立者(以前送達零個人)、部門表的申請會通知部門與公司管理者(以前整段被跳過)、LINE 群組/聊天室客戶現在真的送得出申請——通道閘門以前只認得一對一通道的值。

Client permission request 仍是上面所述的 chatroom/department 獨立流程;passphrase 型的 clientAccess 則可設定於 chatroom、department 與 company table:

{ "passphrase_enabled": true, "passphrase": "a-long-random-passphrase", "passphrase_permissions": { "can_read": "own", "can_insert": true, "can_edit": "own" } }

clientAccess.update 設定;raw passphrase 只在寫入時使用,伺服器保存 hash。它是多人共用 credential,無法提供 per-client revoke 與歸屬稽核,能用 manual grant 時優先用 manual grant。

passphrase_permissions 支援完整的 row policy 介面,包含 token。原始 token 會被複製到每個以 passphrase 加入的 client 自己的 explicit grant 上,之後每次請求各自解析,所以 passphrase grant 上的 $me 指的是加入的 client,而不是設定的 moderator。管理員親自寫入的 client grant 永遠不會被 passphrase 加入流程覆蓋。

Moderators 與 defaults

Department / company table 可用 moderators.addmoderators.remove 管理 explicit moderators。Creator 與適用的 scope manager 同樣是 implicit managers;manager 的 effective permissions 固定為 read all、insert true、edit all,且不受 column hiding。

指派錨定在該表的 effective company(department → company,絕不用可為 null 的原始欄位),而不是路徑上的 scope。因此同公司但不在該部門的使用者可以被設為部門表的 moderator——例如總部同仁管理分部部門的表——與 IaC 的 moderator 通道以及 per-user grant 一直以來的行為一致。這個家族只掛在部門與公司兩個 scope,兩句 per-scope 訊息收斂成同一句 404 {"detail":"User not found or not in the same company"};department scope 以前回的是 User not found or not in this department,並直接拒絕這種指派,公司那一句則沒有變。它 fail closed:effective company 解析不出來時直接跳過查詢,任何人都加不進去。

移除是純粹的降權,對目標使用者不做任何成員資格判斷,所有 scope 皆然。只要通過 manager 閘門,任何 moderator 列都能移除,包含跨部門與 IaC 產生的列;唯二的失敗是 404 {"detail":"Table not found in this department"}404 {"detail":"User is not a moderator of this table"}。沒有同部門限制——若以現在的部門歸屬設限,正好是那些列會永遠無法透過 API 移除。

Manager 閘門本身現在把租戶錨點放在 explicit-moderator 捷徑之前。被設為某表 moderator、之後又換到另一家公司的使用者,過去仍保有對該表 schema、rule、trigger 與 grant 的寫入授權,而且掛在這個 dependency 上的每條路由都繼承了這個漏洞;這種請求現在回 403 {"detail":"Insufficient permissions. (Different Company)"}

defaultPermissions.update 可用於所有 scopes,但只在沒有更高優先 grant、且 user 原本就在 table scope 時作 fallback——唯一的例外是部門表上的 audience: "company",它把「在 table scope 內」重新定義成整個公司。Defaults 支援 row filters,不支援 visible_columns;全表欄位限制請用 columnAcl.update

Warning Grant updates 是 replace/upsert 語意。編輯 UI 必須先載入完整現況,未顯示或漏送的 filter / visible column 設定可能被清除。

Trigger 寫入改以觸發者的權限執行

Trigger 的 record 寫入不再擁有 system authority。由使用者寫入所觸發的 run——event 為 createdupdateddeletedrestored——其 create_recordupdate_recorddelete_record action 會以觸發這次 run 的那個 principal 的 ACL 執行。在此之前,trigger lane 是模組內唯一沒有 ACL 的寫入介面,低權限使用者可以更新一列,讓別人的 trigger 去寫他自己永遠寫不了的資料。

判別依據只有 event 名稱,沒有別的。其他所有成因——包含帶著真實 record_iddate_column_reached 排程——都算 system-caused,維持 system authority,因此整個排程自動化介面不受影響。不要用「有沒有 record id」來推論權限來源。

歸屬(attribution)沒有改變,而且與權限不是同一件事。 寫入的列仍然以 trigger 的 created_by(儲存這份設定的 moderator)標記。現在「誰寫了這一列」與「是誰的權限允許這次寫入」是兩個不同的答案。

User-caused run 適用的檢查就是一般那幾道:建立看 can_insert、更新看 can_editcan_editown 時逐列檢查 pre-image、為 filtered 時同時檢查 pre-image 與 post-image,另外套用該 grant 的 hidden column 集合。以下情況直接拒絕:無法判定觸發的 principal、principal 已非有效帳號(軟刪除、停用、未驗證或已過期)、principal 與 action 目標表分屬不同公司、目標表已無法解析。若觸發的 principal 本身是目標表的 manager,則完全不套用欄位與 filter 限制。

被拒絕不會變成使用者自己那次寫入的 4xx——使用者的寫入仍然成功。它會落成一筆 ok: false 的 action receipt,run 轉為 failed 並保留已完成的部分軌跡,等 grant 調整後可重試。權限是以「每個目標表」為單位在該 run 內解析並快取,因此多表 trigger 可能在一個目標表被允許、在另一個被拒絕,而第一次拒絕會中止其餘 action。非 record 類 action——webhook、API 呼叫、通知、頻道訊息——完全不受觸發 principal 限制。

這個機制上線前就已存在的 run 沒有 actor 快照,仍以 system 執行,那不是 bug。各 action 的契約見 trigger actions

Token 的 valid_until 以 naive UTC 儲存

兩個 REST token mint(public-read 與 callback)都會先把帶時區的 valid_until 正規化為等值的 naive UTC 再儲存。服務端是對 naive 欄位比較,所以帶 +08:00 的到期時間過去只存下牆上時間的數字,token 會比宣告的到期時間多活一個時差。過去送出 2026-08-01T00:00:00+08:00 並觀察到 token 在台北 00:00 失效的 UI,現在會看到它在台北 08:00 失效——這才是正確的時間點。建議直接送 UTC 以免混淆。

Last updated on