地理圍欄簽到/簽退
本準備套件對應已合併 PR1 #1988,來源 c15a12f9b01aa3e88e7b0cf65fe4735a48e03718。Capture ingress 與多跳規則 IaC 轉譯均已合併。#1987 已於 5.19.0 發行;owner 回報整合後 reader/manager 隱私控制通過。下列特定套件仍須驗收安裝、身分/grant 與 export/plan/apply round-trip。 Safety net 確認前,員工 execute grants 維持空陣列。本 how-to 僅使用已合併 PR1;文件檢查不是產品 E2E 或部署驗收。
安全模型
員工只有在已指派且啟用的站點範圍內才能簽到或簽退。呼叫者提供事件 key、事件種類與新取得的地理讀值,不提供 employee ID、原點、半徑或自稱的 src。
- 受限的持久化 v2 Command 以伺服器綁定的
$ctx.user_id找員工,解析原生指派站點 links、檢查完整有界原點集合,再原子新增一筆事件。 - 資料表
within_any安全網獨立檢查事件員工的指派站點。瀏覽器距離檢查與最近站點查詢都不是授權。 - 事件表使用
commands_only;員工取得 execute 權限而非直接寫表權限。Definer ceiling 只開 employee/site 讀取與事件新增。 - Location 採 manager 可讀的 column ACL,outputs 為空。站點、距離、receipts、history 與衍生結果須另訂讀取政策。
- 管理員仍能控制 schema、指派、Commands、rules 與 IaC。這是 action policy 加歷史紀錄,不是封存或不可變帳本。
拒絕 nearest 錯誤揭露仍不能消除位置推論。即使不回傳座標、最近站點名稱或距離,重複的 accept/refuse 結果仍可用於二分搜尋隱藏原點。Column ACL 與 private outputs 並不保證圍欄的隱藏原點無法被推測。
參閱座標 consumers、幾何、寫入政策與 definer authority。座標查詢依距離尋找或排序已儲存的紀錄;與最近據點查詢一樣,它們絕不授權簽到。
必要的來源邊界
src 由伺服器指定。互動式驗證本身不是 capture;白名單入口必須只在指定 capture 欄位的那次正規化設標記並立即還原,不可流入巢狀 automation、trigger、cascade、worker thread 或背景工作。
| Ingress | 必要的儲存來源 |
|---|---|
| 宣告的 geographic Command capture input、manual origin、非 service 的 JWT、實際 acting principal | device_geolocation |
單筆 private REST create/update、geographic 欄位 capture.capture=true、互動式 JWT | device_geolocation |
| Import v1/v2,不論檔案攜帶何種 metadata | import |
| IaC records | iac |
| Bulk/batch/mass update、複製列、public/anonymous forms、Agent/MCP、service token、API key、sandbox、edgeworker | client |
| 衍生/automation 寫入 | command:<id> 或 client,不可繼承裝置 capture |
| Undo/restore/history replay | 原儲存的 src、acc_m、at,原樣保留 |
此政策下匯入永遠不能取得裝置來源。 檔案 accuracy/time 可作為不受信任的 import metadata 保存;device-only source allowlist 會拒絕。此 ingress/import 邊界已合併於 PR1。不得把修正前匯入當作 device 證據;實際 tenant package 仍須獨立驗收。
讀值包含 decimal-string lat、lon、正數 accuracy_m 與帶時區的 captured_at;兩個長格式 capture metadata key 必須同時存在,最多允許五秒未來時鐘偏差。預設出勤 gate 為 sources:["device_geolocation"]、max_accuracy_m:"50"、max_capture_age_s:120、accuracy_policy:"strict";未四捨五入距離加回報 accuracy 必須完全落在半徑內。
原生 LINE location 是使用者可任選的 pin,不是裝置證明:channel:line、null accuracy、訊息事件時間。有效 numeric latitude/longitude 以 half-even rounding 按 1e-7 精度保留七位小數;event time 必須是正整數 milliseconds,格式不合法的 event/message/time/coordinates 不產生 reading;native handler 儲存此 location message 時,line_location metadata 為 null,沒有可用的 location reading。預設出勤拒絕。較弱 opt-in 必須明列 channel:line 並省略 maximum accuracy;helper 即使 strict 也只在幾何不確定性使用零,不捏造儲存 accuracy。本例不採此弱政策。
仍可能偽造裝置/定位;kernel 不作 GPS 裝置 attestation,也不實作第一方 capture 畫面。呼叫 UI 必須從已驗證的 Geolocation 流程取得讀值,不可用鍵入或匯入 pin 取代。
1. 準備 IaC schema 與指派
選定一個原生 CT scope。既有、有效且同公司的 username attendance_admin、demo_employee 是前提;套件不建立使用者或憑證。以可管理三張表且持續有效的 Command authority owner 執行,並依目標租戶替換可攜 identity tokens。
這是 schema/setup JSONL 區段,每行一個 resource。key 用於 IaC natural-key 解析,另設 unique rule 防止重複事件 key;Command-only 事件表不放 event seeds。
{"kind":"table","ref":"site","spec":{"name":"Attendance sites","key":"site_code","moderators":["$user:attendance_admin"],"settings":{"default_permissions":{"can_read":"none","can_insert":false,"can_edit":"none"},"column_acl":{"location":{"read":"managers","write":"managers"},"radius_m":{"read":"managers","write":"managers"},"active":{"read":"managers","write":"managers"}}}}}
{"kind":"column","table":"site","ref":"site_code","spec":{"name":"Site code","type":"string","required":true}}
{"kind":"column","table":"site","ref":"name","spec":{"name":"Site name","type":"string"}}
{"kind":"column","table":"site","ref":"location","spec":{"name":"Site location","type":"coordinate","coordinate_system":"geographic","axes":[{"key":"lat","precision":10,"scale":7},{"key":"lon","precision":10,"scale":7}]}}
{"kind":"column","table":"site","ref":"radius_m","spec":{"name":"Allowed radius (m)","type":"decimal","precision":10,"scale":3,"allow_negative":false,"required":true}}
{"kind":"column","table":"site","ref":"active","spec":{"name":"Active","type":"boolean","default_value":true}}
{"kind":"table","ref":"employee","spec":{"name":"Attendance employees","key":"employee_no","moderators":["$user:attendance_admin"],"settings":{"default_permissions":{"can_read":"none","can_insert":false,"can_edit":"none"},"column_acl":{"allowed_sites":{"read":"managers","write":"managers"}}}}}
{"kind":"column","table":"employee","ref":"employee_no","spec":{"name":"Employee number","type":"string","required":true}}
{"kind":"column","table":"employee","ref":"actor","spec":{"name":"Authenticated user","type":"user","required":true}}
{"kind":"column","table":"employee","ref":"allowed_sites","spec":{"name":"Assigned sites","type":"link","target":"site","cardinality":"many"}}
{"kind":"table","ref":"attendance_event","spec":{"name":"Attendance events","key":"event_key","moderators":["$user:attendance_admin"],"settings":{"write_policy":"commands_only","default_permissions":{"can_read":"none","can_insert":false,"can_edit":"none"},"column_acl":{"location":{"read":"managers","write":"all"},"employee":{"read":"managers","write":"managers"}}}}}
{"kind":"column","table":"attendance_event","ref":"event_key","spec":{"name":"Event key","type":"string","required":true}}
{"kind":"column","table":"attendance_event","ref":"employee","spec":{"name":"Employee","type":"link","target":"employee","cardinality":"one"}}
{"kind":"column","table":"attendance_event","ref":"event_type","spec":{"name":"Event type","type":"select","options":["check_in","check_out"],"required":true}}
{"kind":"column","table":"attendance_event","ref":"location","spec":{"name":"Captured location","type":"coordinate","coordinate_system":"geographic","axes":[{"key":"lat","precision":10,"scale":7},{"key":"lon","precision":10,"scale":7}],"capture":{"capture":true,"accuracy":true,"source":true,"captured_at":true},"required":true}}
{"kind":"column","table":"attendance_event","ref":"matched_site","spec":{"name":"Matched site","type":"link","target":"site","cardinality":"one"}}
{"kind":"column","table":"attendance_event","ref":"distance_m","spec":{"name":"Distance (m)","type":"decimal","precision":10,"scale":3,"allow_negative":false}}
{"kind":"rule","table":"attendance_event","ref":"event_key_unique","spec":{"type":"unique","name":"Unique attendance event key","columns":["event_key"]}}
{"kind":"rule","table":"attendance_event","ref":"assigned_site_capture","spec":{"type":"check","name":"Assigned active site capture","column":"location","op":"within_any","value":{"origins":{"via":"employee.allowed_sites","location":"location","radius":"radius_m","where":[{"column":"active","op":"eq","value":true}]},"sources":["device_geolocation"],"max_accuracy_m":"50","max_capture_age_s":120,"accuracy_policy":"strict"}}}
{"kind":"record","table":"site","data":{"site_code":"SITE_A","name":"Taipei site A","location":{"lat":"25.0339640","lon":"121.5644680"},"radius_m":"150.000","active":true}}
{"kind":"record","table":"site","data":{"site_code":"SITE_B","name":"Taipei site B","location":{"lat":"25.0359640","lon":"121.5664680"},"radius_m":"150.000","active":true}}
{"kind":"record","table":"employee","data":{"employee_no":"E001","actor":"$user:demo_employee","allowed_sites":["SITE_A","SITE_B"]}}站點原點是標為 IaC 的設定,不是員工 capture。只有管理員維護指派/半徑;不要給員工修改自己的 user 綁定或指派站點的權限。
2. 加入持久化 Command resource
將下列物件序列化成 一行 JSONL 接在 schema 區段後。Table/column 名稱是 IaC refs;REST definition 要用解析後 table IDs 與欄位名稱/key。Execute grants 不會建立 table grants。
{
"kind": "command",
"ref": "attendance_capture",
"spec": {
"name": "Record geofenced attendance",
"agent_enabled": false,
"execute_policy": "restricted",
"execute_grants": [],
"definition": {
"version": 2,
"authority": "definition",
"table_access": {
"employee": {"read":"all"},
"site": {"read":"all"},
"attendance_event": {"read":"none","insert":true}
},
"inputs": [
{"name":"event_key","type":"string","required":true},
{"name":"event_type","type":"string","required":true},
{"name":"location","type":"coordinate","coordinate_system":"geographic","capture":true,"required":true}
],
"steps": [
{
"kind":"assert", "name":"valid_request",
"condition":{"$and":[
{"$neq":["$ctx.user_id",null]},
{"$neq":["$input.event_key",""]},
{"$or":[{"$eq":["$input.event_type","check_in"]},{"$eq":["$input.event_type","check_out"]}]}
]},
"message":"attendance_request_invalid"
},
{
"kind":"select", "name":"employee_for_actor",
"from":{"table":"employee","as":"employee"},
"where":{"$eq":["$row.employee.actor","$ctx.user_id"]},
"select":{"id":"$row.employee.id"}, "max_rows":1
},
{
"kind":"let", "name":"employee_id",
"value":{"$single":{"relation":"$rel.employee_for_actor","expr":"$item.id"}}
},
{
"kind":"select", "name":"assigned_sites",
"from":{"table":"employee","as":"employee"},
"joins":[{"type":"inner","table":"site","as":"site","on":{"link":{"source":"$row.employee.allowed_sites","target":"$row.site.id"}}}],
"where":{"$and":[{"$eq":["$row.employee.id","$var.employee_id"]},{"$eq":["$row.site.active",true]}]},
"select":{"id":"$row.site.id","point":"$row.site.location","radius":"$row.site.radius_m"},
"order_by":[{"expr":"$row.site.id","direction":"asc"}], "max_rows":50
},
{
"kind":"let", "name":"origins",
"value":{"$collect":{
"relation":"$rel.assigned_sites", "max_items":50,
"expr":{"$json_parse":{"value":{"$concat":[
"{\"id\":",{"$json_stringify":"$item.id"},
",\"point\":",{"$json_stringify":"$item.point"},
",\"radius\":",{"$json_stringify":"$item.radius"},"}"
]},"on_error":"fail"}}
}}
},
{
"kind":"let", "name":"match",
"value":{"$within_any":["$input.location","$var.origins",{"$json_parse":{
"value":"{\"sources\":[\"device_geolocation\"],\"max_accuracy_m\":\"50\",\"max_capture_age_s\":120,\"accuracy_policy\":\"strict\"}","on_error":"fail"
}}]}
},
{
"kind":"assert", "name":"inside_assigned_site",
"condition":{"$json_get":{"from":"$var.match","pointer":"/matched","type":"boolean","on_error":"fail"}},
"message":"outside_geofence"
},
{
"kind":"insert", "name":"event", "table":"attendance_event",
"values":{
"event_key":"$input.event_key", "employee":["$var.employee_id"],
"event_type":"$input.event_type", "location":"$input.location",
"matched_site":[{"$json_get":{"from":"$var.match","pointer":"/origin_id","type":"string","on_error":"fail"}}],
"distance_m":{"$json_get":{"from":"$var.match","pointer":"/distance_m","type":"decimal","on_error":"fail"}}
},
"returning":{"id":"$new.id"}
}
],
"outputs": {}
}
}
}員工查詢不設截斷用 limit,找不到或不唯一都拒絕。指派站點依 ID 排序、上限 50,也不設 limit;合格列超額就拒絕,而非偷偷只取前 50。$collect 建立 {id,point,radius} objects,不是 rule resolver。不可 stringify/reparse $input.location:capture gate 要求 schema-typed coordinate subject,untyped JSON point 會移除 capture metadata。Typed reference 保留 stored evidence 而不重新指定來源;JSON origin array 仍只是幾何,不是 capture 證明。
Command match 可為 null:缺少/不可信 capture 回 matched:null,不是可用的 false。Assert 結果必須恰為 true;不可將未知 match 預設為成功,或用 negation 繞過圍欄。
呼叫者不可修改 Command、ceiling、員工映射或站點設定。不同 manager 重存會改 authority owner;owner 無效或失去管理權便 fail closed。agent_enabled:false 只是可見性選擇,不能取代 capture-origin enforcement。
3. 安裝原生 link 安全網
Schema 段已包含 assigned_site_capture。可攜式 employee.allowed_sites path 先跟隨事件員工 link,再跟隨該員工指派站點 link;location、radius 與 active predicates 於 site table 解析。Plan/apply 使用此 namespace-aware 轉譯,export 使用 linked tables 的 schema context 反向轉譯。必須保留三個 table 定義/refs。這是 source support,不是已完成的 owner round-trip 或 runtime 閘門。
Schema 明訂兩段 native-link hop(attendance_event.employee、employee.allowed_sites)、site location/radius 及原點篩選欄位 site.active 只能由 managers 寫入。每個 origins.where predicate 欄位也會在 authoring 檢查信任、求值時再次核對;未受保護的 filter 會在 authoring 拒絕,保護失效則回 origin_untrusted,而非使用該原點。僅拒絕一般 table edit,不能取代 column write-trust 宣告。既有 rule/trigger 仍引用這些原點相依時,ACL PATCH/IaC 不得移除其保護。這是 source-supported 安全網,不是出勤或 history 驗收。
驗收後供管理員 readback 或維護使用的原生 attendance table PUT …/rules 等價 body 為:
{
"rules": [
{"type":"unique","name":"Unique attendance event key","columns":["Event key"]},
{
"type":"check", "name":"Assigned active site capture",
"column":"Captured location", "op":"within_any",
"value":{
"origins":{
"via":"Employee.Assigned sites", "location":"Site location", "radius":"Allowed radius (m)",
"where":[{"column":"Active","op":"eq","value":true}]
},
"sources":["device_geolocation"],"max_accuracy_m":"50","max_capture_age_s":120,"accuracy_policy":"strict"
}
}
]
}PUT …/rules 取代整個 rule set:保留 unique rule 與其他既有控制,使用目前 schema 的名稱/key。兩跳路徑依事件員工解析其指派站點;rule 以 kernel authority 讀同租戶且相容的 linked origins,不會讓呼叫者讀到隱藏原點。遺失/刪除原點、null location、超額原點或沒有合格站點都 fail closed。
未完成此安全網的部署不可開放員工 execute access。Setup、Command authoring、rule 安裝與驗收都由管理員掌控;必須核對已 apply/export 的 rule,不可把合法 JSON resource 視為安裝證明。disclose_nearest:true 以 ct.coordinate_disclosure_unsupported 拒絕;errors 不揭露最近的隱藏站點或距離。
初始 Command 刻意設 execute_grants: [],因 IaC apply 立即同步授權。完成安全網安裝及 owner 驗收之後,才在保留的 package 將此欄改為 [{"principal_type":"user","principal_id":"$user:demo_employee"}],檢查 plan、解析後 actor,再 apply 放行。commands_only 限制允許的 operation,不會自行證明 geofence eligibility。
4. 使用新的第一方讀值呼叫
最終 source/package 驗收後,以解析後 Command ID 呼叫原生 POST …/commands/{command_id}/execute,使用員工互動式 JWT。此時間僅示範 wire format,不是可重用的即時讀值:
{
"inputs": {
"event_key":"E001-check-in-20261005-001",
"event_type":"check_in",
"location":{"lat":"25.0339640","lon":"121.5644680","accuracy_m":"5","captured_at":"2026-10-05T10:30:00Z"}
},
"idempotency_key":"attendance-check-in-20261005-001"
}簽退使用 event_type:"check_out"、新的 event key、獨立 idempotency key 與重新擷取的讀值。傳輸結果不明時,以同一 request、同一 key重試,不要在舊 retry key 下換讀值。Replay 以 Command + actor + key 為範圍,要求相同 validated input,仍檢查目前 execution authority/context;改 input 可回 idempotency_key_reuse,改 context 可回 idempotency_context_conflict。Event-key unique rule 是額外的重複效果防線,不取代 Command idempotency。
僅有 execute 權限的 definer 呼叫者通常收到 status-only 結果,不會拿到事件列、原點清單、站點、距離或隱藏座標;空 outputs 刻意不公開它們。Manager 透過一般 ACL 保護的讀取檢視允許的事件證據。
驗收清單
以下是 owner 的驗收要求,不是文件 lane 已執行的測試:
**Owner 已回報 History 驗收:**保留最初 consumer 揭露 manager-only 座標與 provenance 的歷史 RED,不因 source review 改判。Owner 現回報整合後 PR1 head dac07c2cf41dfedccaf65224c12e669ac2b48286 的一般讀者 history/derived-value 嚴格拒絕及獲准管理員精確值控制已通過;其座標產品來源與合併 PR1 相同。#1987 提供 canonical ACL/別名過濾與受限 principal 的已移除欄位投影。這不等於特定套件已驗收:啟用員工 execute grants 前,仍須驗證其 reader/manager context 與已安裝 safety net。
| 情境 | 必要結果 |
|---|---|
| 新鮮且精確的讀值位於啟用、已指派站點內 | 只提交一筆事件,站點/距離證據僅授權讀者可見 |
| 圍欄外、停用、未指派、無站點或員工映射不唯一 | 拒絕且無事件效果 |
| Null、缺 accuracy/time、過期、超過未來偏差或 accuracy 超額 | 拒絕且無事件效果 |
| 檔案匯入 capture-shaped metadata | import,永不為裝置來源;device-only rule 拒絕 capture_untrusted |
| Bulk/duplicate、Agent/MCP、service/API key、sandbox 或巢狀 automation | 無裝置來源或出勤效果 |
| LINE pin | 預設拒絕,較弱 explicit opt-in 是另行租戶政策 |
| 邊界及合格原點超額 | 使用未四捨五入 strict 幾何,不以截斷授權 |
| 重複 request/key 與獨立簽退 | Replay 不重複寫入,簽退為獨立事件 |
| Undo/history replay | 原 provenance 原樣還原,不重新變成新 capture |
| 未授權證據讀者 | 衍生值、history、receipts、errors 或 outputs 不洩漏精確座標/隱藏來源 |
員工 execute grants 維持空陣列時,由管理員檢查 IaC plan/apply及正常 readback。開放 execute access 前,owner 必須驗收實際 diff、解析後 identity/grants、持久化 Command/rules 及可攜 export/plan/apply round-trip。文件 build、JSON parse 或原始碼範例都不是 browser/staging/production 驗收。