附件複製
自後端 5.22.0 起,version 2 的寫入型 Command 可以在 definition 最上層帶一個 attachment_copies 陣列。每個項目就是一份 grant:不論由誰執行,這個 Command 都可以把自己某個 select step 所讀資料列的附件欄位中的檔案,複製到自己某個 insert 或 update step 所寫資料列的附件欄位。
blob id 不是授權。附件來源規則只允許已登入的使用者附加另一張資料表所持有的檔案,而且前提是該使用者在那張表上讀得到;外部 client、public callback 與不帶使用者的通道則完全不能跨資料表複製。因此,若有一個 Command 要為客戶的對話建立案件,並把專案的報價單 PDF 歸檔進去,這位客戶會被以 Unknown or inaccessible attachment ids: [...] 拒絕(透過 REST 是 400),但同一個 Command 對讀得到該檔案所在專案資料列的員工卻能成功。grant 只針對一次複製改變這件事:檔案由 Command 自己的 select 選出,呼叫者從不指名 blob id,而以 grant 作者(不是呼叫者)的權限為準。
你所核准的資料流向。 任何讀得到目的資料列與欄位的人,包含呼叫者本人,都讀得到每個被複製的檔案。該資料列的其他讀者,即使讀不到來源資料表,也拿得到這個檔案。所以 exposure 是必填,而它唯一接受的值 destination_row_readers 就是在指明這個流向。
grant 的格式
| 欄位 | 必填 | 契約 |
|---|---|---|
source.step | 是 | 持有檔案的 select step 名稱;step 名稱為 1–32 個 a-z、0-9 與 _ 字元 |
source.table | 是 | 該 step 讀取的資料表:名稱或 id,1–128 個字元,儲存為 id |
source.column | 是 | 要複製來源的附件欄位:顯示名稱或內部 id,1–128 個字元,儲存為內部 id |
target.step | 是 | 寫入複本的 insert 或 update step 名稱 |
target.column | 是 | 該 step 所屬資料表上接收檔案的附件欄位:顯示名稱或內部 id,1–128 個字元,儲存為內部 id |
exposure | 是 | 字面值 destination_row_readers |
granted_by | 否,唯讀 | 儲存這一版的使用者。由伺服器寫入,你送的值一律丟棄;超過 36 個字元的值會被 request 驗證拒絕 |
一個 Command 最多 8 個項目,且 step 的每個目的欄位只能有一個項目;同一欄位的第二個項目會以 A destination column takes one grant 被拒絕。空陣列會被丟棄,不會儲存。項目內出現未知的 key,會被 request 驗證拒絕(422)。
完整範例
下面這個 Command 依名稱查出一個專案,並新增一筆案件,案件的 Quotation 欄位接收該專案 Quote 欄位的檔案。由外部 client 執行時,如果沒有 attachment_copies 項目,同一個 Command 會以 Unknown or inaccessible attachment ids 被拒絕,因為這份報價單是另一張資料表紀錄上的檔案,不是這位 client 自己對話裡的檔案。呼叫者仍然需要來源資料表的讀取權:select 以呼叫者自己的權限執行(外部 client 則看它在該資料表上的 client 權限),所以讀不到 projects 的呼叫者,在任何複製之前就會得到 403 Read access not granted for this table.。grant 只增加複製檔案的權限。
{
"version": 2,
"inputs": [
{ "name": "project", "type": "string", "required": true },
{ "name": "title", "type": "string", "required": true }
],
"steps": [
{
"kind": "select",
"name": "pick",
"from": { "table": "projects", "as": "p" },
"select": { "quote": "$row.p.Quote" },
"where": { "$eq": ["$row.p.Name", "$input.project"] },
"max_rows": 1
},
{
"kind": "insert",
"name": "create_case",
"table": "cases",
"values": {
"Title": "$input.title",
"Project": "$input.project",
"Quotation": { "$single": { "relation": "$rel.pick", "expr": "$item.quote" } }
},
"returning": { "id": "$new.id" }
}
],
"outputs": {
"case_id": { "value": { "$single": { "relation": "$rel.create_case", "expr": "$item.id" } } }
},
"attachment_copies": [
{
"source": { "step": "pick", "table": "projects", "column": "Quote" },
"target": { "step": "create_case", "column": "Quotation" },
"exposure": "destination_row_readers"
}
]
}儲存時,伺服器會把名稱解析成 id 並蓋上作者。讀取已儲存的 Command 時,grant 會長成下面這樣,granted_by 是儲存它的使用者:
{
"source": {
"step": "pick",
"table": "22222222-2222-4222-8222-222222222222",
"column": "col_44444444_4444_4444_8444_444444444444"
},
"target": { "step": "create_case", "column": "col_77777777_7777_4777_8777_777777777777" },
"exposure": "destination_row_readers",
"granted_by": "55555555-5555-4555-8555-555555555555"
}哪些 Command 符合資格
一份 grant 只涵蓋定義本身所選出的那一次複製,不會更寬。伺服器在 Command 儲存時(建立、整份取代,以及不寫入的 POST …/commands/validate)檢查下列規則,並在執行該定義時再檢查一次形狀與 authority 規則:已儲存但不再符合的定義不會放行任何複製,它的複製會改依這個 Command 沒有 grant 的情況判斷。儲存時的拒絕是 422,帶 error: "invalid_expression"、phase: "author"、attachment_copies[<index>] 形式的 path(該列另有說明者除外),以及下列其中一則訊息:
| 規則 | 訊息 |
|---|---|
source.step 是這個 Command 的 select step | source.step must name a select step of this Command |
target.step 是 insert 或 update step | target.step must name an insert or update step of this Command(path 以 .target.step 結尾) |
| select 在寫入之前執行 | source.step must run before target.step |
select 讀取 source.table | source.step must read source.table |
select 沒有 join、group_by、having 或 distinct | source.step must read source.table alone (no join, grouping or distinct) |
select 以自己的 where 選列 | source.step must choose its rows with its own where |
select 以單純的 $row.<alias>.<column> 投影該欄位 | source.step must project source.column directly |
寫入 step 沒有 on_conflict,也沒有 for_each | target.step must be a plain insert or update (no on_conflict or for_each) |
目的值恰好是 $single 或 $first,內容為 $rel.<source step> 與該投影的 $item.<field> | target.column must be written exactly by $single or $first over source.step's projection of source.column |
| 兩個欄位都是附件欄位 | Grant column must be an attachment column(path 以 .source.column 或 .target.column 結尾) |
目的值若是其他任何東西,例如字面值、$input 參照或別的表達式,都會被這條「恰好」規則拒絕;$first 另外要求 select 宣告 order_by,這與其他地方相同。還有三條規則針對整個 Command(path 為 attachment_copies):
- Version 2、寫入模式。 version 1 的 Command 或
mode: "query"的 Command 不能帶 grant:attachment_copies requires a version-2 write Command。 - 不能有
proposal_policy。 被擱置的 proposal 重放的是凍結值,沒有任何 grant 涵蓋它們:attachment_copies cannot be combined with proposal_policy: a held proposal replays frozen values that no grant covers。 - 只限 invoker authority。 在
authority: "definition"的 Command 上設 grant,儲存時會被拒絕,訊息以attachment_copies requires invoker authority開頭。在 definer 權限下,來源select會用 definer 的table_accessceiling 執行,而不是呼叫者自己的讀取權,呼叫者就可能取得自己讀不到的資料列上的檔案。因為 Command 以 invoker authority 執行,select 用的是呼叫者自己的權限:讀不到來源資料表的呼叫者(外部 client 則看它在該資料表上的 client 權限),在任何複製之前就會得到403 Read access not granted for this table.,對呼叫者隱藏的來源欄位會讓 select 以403與column_not_readable停下來,而呼叫者只能拿到該 select 依呼叫者所選的 input 傳回的那些資料列上的檔案。grant 增加的是複製檔案的權限,不是讀取權。select 的where就是邊界:請把它寫成這些 input 只能選到你打算公開的資料列。
誰能撰寫 grant
grant 只能由已登入的使用者透過 Commands REST API 撰寫。
- 撰寫門檻。 儲存的使用者必須和任何 Command 一樣,管理這個 Command 引用的每一張資料表;做不到的人,不論有沒有 grant,都會得到一般的
404(建立時是Referenced table not found in this scope)。 - 存活的人類帳號。 儲存的使用者必須是存活的帳號(未刪除、未停用、已驗證、未過期),而且不是服務帳號。他們還必須讀得到來源欄位、寫得到目的欄位;否則儲存會以
403被拒絕:
{
"detail": {
"error": "attachment_copy_grant_forbidden",
"path": "attachment_copies[0]",
"message": "the grant's author must be able to read its source column and write its destination column"
}
}- 作者就是儲存這一版的人。 每一次
POST、PUT與 validate 都會把granted_by蓋成儲存的使用者,並丟棄用戶端送來的值,所以重新儲存這個 Command 的使用者,會成為它所有 grant 的作者。PUT會取代整份定義:沒有attachment_copies的PUT會移除 grant。 - IaC 拒絕 grant。
definition帶有attachment_copies的 IaCcommandline,在 apply 時失敗,是200apply 回應內的單行錯誤,detail 為attachment_copies are authored over the Commands API only。Export 不會把這個 key 寫回,而更新受管 Command 的 apply 會用文件取代整份已儲存的 definition,所以這次更新會移除透過 REST 加上的 grant。 - Composer 拒絕 grant。 Command composer的核准卡不顯示 grant,所以
save_command會拒絕宣告了 grant 的草稿,也拒絕編輯已帶 grant 的 Command,不論草稿保留或拿掉 grant。它回傳{"saved": false, "errors": [{"status": 422, "detail": {"error": "attachment_copies_rest_only", "path": "definition.attachment_copies", ...}}]},且不寫入任何東西。 - Agent 與 MCP 只能執行 Command。 它們沒有建立或編輯 Command 的工具,外部 client 也沒有撰寫用的路由。
每次複製檢查什麼
執行時,伺服器不信任呼叫者送來的任何東西。針對被授權 step 的目的欄位,它依序檢查:
- 部署開關是開的(見下方)。
- 作者仍然符合資格。
granted_by使用者是公司內存活、且不是服務帳號的帳號,仍讀得到來源資料表的來源欄位,仍寫得到目的欄位(insertstep 要有 insert 權限,updatestep 要有不是none的 edit 權限)。 - 檔案來自 select 讀過的資料列。 這個值會對照 Command 自己的
select為它取用的來源資料列來檢查。每一列都必須仍然存在、未被刪除、仍是 select 讀取時的版本、來源欄位仍持有該檔案,而且作者依資料列層級的讀取規則讀得到。
不論哪一項失敗,grant 一律不放行,該次複製改依這個 Command 沒有 grant 的情況判斷。自己無法複製該檔案的呼叫者(例如外部 client、不帶使用者的通道,或讀不到持有該檔案之紀錄的使用者)會得到與沒有 grant 的 Command 相同的答案(透過 REST 是 400 與純字串 detail Unknown or inaccessible attachment ids: [...]),所以分不出是 grant 被拒絕,還是檔案根本不存在;讀得到這類紀錄的已登入呼叫者仍會經由一般規則拿到檔案,而且不會為它記錄稽核項目。原因只會寫進伺服器 log,且只記 id。因此作者的權限在每一次複製都會重新檢查:作者被停用、離開公司或失去權限的當下,grant 就停止生效。
稽核項目
每一次被接受的複製,都會在與 Command 相同的 transaction 內寫進該 Command execution 的 authorization_snapshot.attachment_copies。execution 列本來就記錄了 Command 與呼叫者(invoker_id 或 invoker_client_id);每個項目再補上作者、step、來源資料列、目的資料列與檔案 id,絕不含檔案內容。每個目的資料列與欄位只有一個項目,只有 succeeded 的 execution 才帶這份清單,而且只列出 grant 放行的複製:失敗的執行不會記錄任何項目,不靠 grant、本來就由一般規則放行的檔案不會列出。
{
"grant": 0,
"author_id": "55555555-5555-4555-8555-555555555555",
"step": "create_case",
"source": {
"table_id": "22222222-2222-4222-8222-222222222222",
"column": "col_44444444_4444_4444_8444_444444444444",
"record_ids": ["33333333-3333-4333-8333-333333333333"]
},
"destination": {
"table_id": "66666666-6666-4666-8666-666666666666",
"column": "col_77777777_7777_4777_8777_777777777777",
"record_id": "88888888-8888-4888-8888-888888888888"
},
"blob_ids": ["99999999-9999-4999-8999-999999999999"],
"caller": { "user_id": null, "client_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }
}grant 是該項目在 attachment_copies 中的位置,step 是目的 step,caller 是執行這個 Command 的使用者或外部 client。請用執行稽核紀錄的路由讀取:GET …/commands/{command_id}/executions 與 GET …/commands/{command_id}/executions/{execution_id},它們會回傳 authorization_snapshot;invoker 看得到自己的那一列,管理這個 Command 所引用之每一張資料表(也就是該 execution 凍結的 table_ids)的 manager 看得到其餘的。
仍然被拒絕的情況
grant 只涵蓋它所指名的那一次複製。下列每一種情況,grant 都不放行,該次複製改依這個 Command 沒有 grant 的情況判斷(外部 client 會得到與現在相同的 Unknown or inaccessible attachment ids 答案);標明「儲存時拒絕」的情況,則在儲存時就被拒絕:
- 沒有 grant 的 Command、grant 沒有指名的目的欄位,或 grant 沒有指名的來源欄位。
- 經由
$input,或恰好$single/$first形式以外的任何表達式傳入的 blob id(被授權的欄位在儲存時拒絕;其他每個欄位在執行時拒絕),以及其他公司的檔案。 authority: "definition"的 Command(grant 在儲存時被拒絕;已儲存、帶有 grant 的 definer 權限定義不會複製任何東西)。- 經由凍結重放執行的複製,也就是 approval release 或 proposal invoke:那裡沒有任何東西會重新評估 Command,所以沒有 grant 適用。grant 與
proposal_policy並用,在儲存時被拒絕。 on_conflictupsert 或for_each目標,以及任何 version 1 的 Command(儲存時拒絕)。- 作者已不再存活、已不在公司內,或已無法讀取來源或寫入目的的複製。
關閉 grant
部署可以不改程式碼就拒絕所有 grant。營運人員為 API 與執行 Command 的 worker 設定環境變數 CUSTOM_TABLE_COMMAND_ATTACHMENT_GRANTS 並重新啟動它們。未設定、空字串或只有空白(預設),以及不分大小寫、忽略前後空白的 on、1、true、yes、enabled,都會接受 grant;其他任何值,包含 off、0、false 或打錯字,都會拒絕全部 grant。
開關關閉期間,被授權的複製會改依這個 Command 沒有 grant 的情況判斷:自己無法複製該檔案的呼叫者會得到同一個 Unknown or inaccessible attachment ids 錯誤,也不會留下稽核項目。開關停止的是 grant 被使用,不是被儲存:帶有 grant 的 Command 仍可儲存,重新打開開關後,每一份已儲存的 grant 會一次全部恢復生效,包含開關關閉期間儲存的 grant。
延伸閱讀
- 附件型別——grant 對單次複製所覆寫的來源規則。
- Definition version 2——
select與寫入 step、$single與$first。 - 執行語意——複製所記錄的稽核紀錄。
- 執行權限——誰能執行 Command,以及 definer authority 改變了什麼。