---
title: U8 檢查結果：流程背景作業
---

# U8 檢查結果：流程背景作業（排程總表、流程圖存檔、孤兒清理、覆核輪承襲）

> 檢查日期 2026-09-25｜對應卡片 CM-2160（母卡 CM-2152）｜檢查範圍 8 個檔案 1,716 行｜工具 run ID `wf_fc0b9dcb-c09`｜驗證章 `verified`

## 一句話結論

**這棒沒有找到跨公司的洞。有兩個「同公司內權限太鬆」的問題：一條工具報、三人面板 3 票通過；一條是我照卡片追出來的。另外有一個「將來會靜默失效」的陷阱，我在 DEV 實測重現了。**

1. **🟡 低（工具報，面板 3:0）**：在「流程圖編輯器」存檔時把方塊刪掉，後端會把對應的稽核任務連同證據一起硬刪。這條路只問「你有沒有改範本的權限」，**不問你是不是這個專案的管理人**；規劃頁刪同一筆任務則要管理人才行。同一件事有兩個入口，門鎖不一樣。FR-112 設計時決策者裁過這個做法（2026-09-20），工具建議重新評估，因為刪任務會連帶刪掉證據。
2. **🟡 低（runner 開檔追出，未經三人面板）**：「證據蒐集執行進度」這支查詢（套件路由，本棒範圍內的查詢是它的資料來源）只驗登入，**不驗你是不是這個專案的成員**。同公司任何人把網址換成別的專案編號，就能看到那個專案的任務完成數字。
3. **🟡 陷阱（runner DEV 實測，未經三人面板）**：孤兒清理排程的「孤兒」判定是「這筆綁定指向的任務查不到」。如果排程沒有帶系統身分，資料庫隔離會讓它**看不到任何任務，於是把所有綁定都當成孤兒刪掉**。現在排程有帶身分，所以不會出事。但這個判定與身分完全綁死，程式碼上沒有任何保險。實測見 U8-3。

卡片問的核心問題「這八支用原生 SQL 直寫沒隔離的表，資料是不是從同一個已驗證的物件來」，答案是**是**：來源與目的地的編號都是程式自己從同一筆已驗證的輪次或流程實例推出來的，沒有任何一個是使用者各自傳進來再拼起來的。詳見「卡片點名的疑點逐條回答」。

## 這一棒在檢查什麼

這 8 支是「背景作業」：大多不是使用者直接按按鈕觸發，而是排程定時跑，或別的功能跑到一半順帶呼叫。

| 檔案 | 行數 | 做什麼 | 誰會觸發 |
|---|---:|---|---|
| `core/scheduler.py` | 550 | 所有背景排程的總表：9 支排程，各自幾點跑、用什麼身分跑 | 服務啟動時掛上，之後按時間自動跑 |
| `app/flow_control/service/workflow_xml_sync_service.py` | 246 | 流程圖編輯器存檔時，比對新圖與資料庫任務，決定要新建、保留還是刪除哪些任務 | 使用者在編輯器按存檔（`PUT /module-frame/item/xml/<uid>`） |
| `app/flow_control/service/job_binding_orphan_cleanup_service.py` | 174 | 每天清一次「指向已刪任務的綁定資料」 | 排程，每天 03:10 UTC |
| `infra/flow_control/repository/job_binding_orphan_query.py` | 129 | 上面那支的 SQL（數、取樣、刪） | 同上 |
| `app/flow_control/service/reverify_inheritance_service.py` | 73 | 開「覆核輪」時，把上一輪的指派、部門／設備綁定、證據、問卷複製到新一輪 | 稽核員按「發起覆核」 |
| `infra/flow_control/repository/reverify_clone_query.py` | 176 | 上面那支的 SQL（對五張表做 `INSERT … SELECT`） | 同上 |
| `infra/flow_control/repository/task_execution_query.py` | 292 | 「開始執行任務」與「執行進度」的查詢與批次改狀態 | 專案管理人按「開始執行任務」／打開規劃頁 |
| `infra/flow_control/task_existence_query.py` | 76 | 回答「這個任務存不存在、屬於哪條流程」的轉接器，給留言、人員指派這些功能用 | 被別的功能呼叫 |

**先釐清三件事實**（2026-09-25 15:13～15:31 +08，在 DEV 唯讀查證；刪除測試全部整段回滾）：

- 卡片提到的五張「沒隔離」表，DEV 現況是：`job_evidences`、`job_execution_devices`、`job_execution_org_units`、`workflow_templates_trans` 都**沒開**資料庫隔離（`relrowsecurity = f`）。**`task_assignees` 已經開了**（FR-094 CM-1800，`scripts/sql/packages/jedi_task_platform/003-participant-rls.sql`，規則是「看得到專案才看得到指派」）。所以盤點表第 6 節寫「`task_assignees` 無 RLS」**已經過期**。
- 排程怎麼跑：9 支排程全部用 `system_context("<名字>")` 包住，也就是**「一個具名系統身分，掃全部客戶」**，沒有任何一支是逐家客戶切換身分的。對這些排程來說，資料庫隔離等於沒開（系統身分會直接繞過）。這跟 U5／U6 看到的一樣。
- `job_evidences` 對任務表的外鍵是 `ON DELETE CASCADE`：任務被刪，證據列會被資料庫連帶刪掉。U8-1 的影響就是從這裡來的。

## 掃到什麼（總覽）

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度＋為什麼 | 來源 |
|---|---|---|---|---|---|---|
| U8-1 | 在流程圖編輯器存檔刪方塊，會硬刪對應任務，這條路不問「你是不是這個專案的管理人」 | 同公司裡有「改範本」權限但不是該專案管理人的人，可以刪掉別的專案的稽核任務，連同指派、綁定與**證據列**（資料庫連帶刪），也可以替它新增任務 | ① 有 `module-frame.update` 權限 ② 知道目標專案某個查核項目背後的範本編號 ③ 那個專案的當前輪次還在「規劃中」，或系統查不到它屬於哪一輪（舊資料約一半如此）。覆核輪開出來時就是「規劃中」而且已經複製了證據，也符合 | `app/flow_control/service/workflow_xml_sync_service.py:91`（只要有要增減的任務，就先從流程實例反查專案、要求專案管理人；查不到專案就擋，不要放行） | 🟡 低：只在同公司內；要有範本修改權限；而且這是決策者 2026-09-20 裁過的設計（FR-112 design.md 第 307 行） | 工具 F1，面板 3:0；runner 開檔核對 |
| U8-2 | 「證據蒐集執行進度」只驗登入，不驗是不是專案成員 | 同公司任何登入帳號把網址換成別的專案編號，就能看到那個專案這一輪的任務總數、完成數、進行中數、未開始數 | 同公司任一登入帳號＋目標專案編號（網址上看得到的長碼） | 套件 `jedi_task_platform/task/app/service/task_execution_service.py:139`（比照同檔 `:69` 的 `_check_manager`，至少呼叫 `assert_project_participant`） | 🟡 低：外洩的只是四個數字；跨公司被 `projects` 表的隔離擋住 | runner 開檔追出（未經三人面板） |
| U8-3 | 孤兒清理的判定與「排程有沒有帶系統身分」綁死；沒帶身分時，全部綁定都會被判成孤兒並刪除 | 如果哪天有人把 `system_context` 拿掉（例如重構、或改成逐家客戶跑），這支排程每天會把四張綁定表**全部清空**（留言、設備、部門、問卷綁定），只留一行 WARNING | 不是攻擊，是將來的程式修改。現在排程有帶身分，**今天不會發生** | `infra/flow_control/repository/job_binding_orphan_query.py:121`（刪除前先確認這個 session 真的看得到全部任務，否則整輪停手、記 ERROR） | 🟡 低／陷阱：今天不會觸發，但一觸發就大量刪資料，而且沒有錯誤訊息 | runner DEV 實測（未經三人面板） |

> **現況（2026-10-01）**：本棒各條後來的處理結果如下（過程紀錄保留，不改）。
>
> - U8-1（流程圖刪方塊硬刪任務）＝總表第 201 項，✅ 已修（CM-2208，commit `1516d90bc`，1.21.0 出貨）。
> - U8-2（執行進度只驗登入）＝總表第 202 項，✅ 已修（CM-2211，套件 commit `cdb83526`，1.21.0 出貨）。
> - U8-3（孤兒清理沒帶身分的陷阱）＝總表第 203 項，✅ 已修（CM-2211，commit `fe928859f`，1.21.0 出貨）。

**工具報的：一條（U8-1），三人面板 3 票全數確認。** U8-2、U8-3 是我照卡片逐支追、再到 DEV 實測出來的，沒有經過三人面板。

## 工具報的逐條

### F1 → U8-1 🟡 在流程圖上刪方塊＝刪任務，這條路不問你是不是專案管理人（低，3:0）

- **在哪**：`app/flow_control/service/workflow_xml_sync_service.py:124`（逐筆呼叫 `delete_job_from_diagram`）。刪除本體在 `app/flow_control/service/job_service.py:217-228`，那支方法**刻意不做專案管理人檢查**，註解寫明理由。
- **怎麼走到的**：入口是 `api/module_frame/routes/module_frame_item_route.py:95-112`，只掛了 `@require_capability("module-frame.update")`，也就是「你這個角色能不能改範本」。接著 `app/module_frame/service/module_frame_item_service.py:69-70` 把整份 XML 交給 `sync()`。`sync()` 會把**資料庫裡有、新圖上找不到編號**的任務全部列成「要刪」（`:85-86`），然後逐筆硬刪（`:121-124`）。
- **為什麼跟規劃頁不一樣**：規劃頁刪任務走 `job_service.py:212` 的 `_require_manager(project_uid)`（FR-112 design.md 寫的 `:179` 是當時行號），要是專案管理人。編輯器這條只有範本權限＋`:91-92` 的「輪次凍結」檢查。那道檢查在**查不到輪次時直接放行**（`:141-143`，倉儲那支註解說舊資料約一半查不到）。
- **刪了會連帶刪什麼**：刪任務時程式會清問卷快照、task_survey 與四張綁定表。`job_evidences` 對任務是 `ON DELETE CASCADE`（DEV `pg_constraint` 15:31 實查），**所以證據列會被資料庫連帶刪掉**。
- **為什麼只到同公司**：範本、流程實例、任務三張主表都有開隔離（`workflow_templates`、`workflow_executions`、`job_executions`），呼叫者看不到別家客戶的範本就拿不到它的流程實例。存檔端點在使用者的請求裡跑，受隔離約束。
- **是設計不是漏寫**：`docs/features/FR-112-2609-planning-task-parallel-flow/design.md:307` 寫「編輯器刪任務走範本能力守門（`module-frame.update`），不要求專案管理人身分（決策者 2026-09-20 裁）」。理由是存檔端點只拿得到範本編號，多數範本反查不到專案。**所以這條要決策者決定要不要重新裁，不是單純的 bug。** 工具提出重新評估的理由是：當初的考量是「刪任務」，但實際上連證據也會一起消失。
- **建議修法**：`sync()` 算出有要增減的任務時，先從流程實例反查專案。反查順序照倉儲已有的兩條路：`workflow_execution_control_mapping.round_id → project_audit_rounds.project_id`，或 `task_assignees.project_id`。查到就要求專案管理人；查不到就**擋下增減**（只准改走向）。不要再像現在這樣放行。
- **與總表的關係**：第 137 項（改範本流程圖不檢查範本歸屬）、第 153 項（匯入只問建立權限）都在同一支 `update_module_frame_item_xml`，但問的是「範本是不是你家的」。本項問的是「你是不是這個專案的管理人」，**是不同的缺口，建議另登一項**。修第 137 項時可以一起看。
- **HIGH 級自行核對**：工具評為低，不屬 HIGH。以上檔案行號我逐一開檔確認過。

## runner 自行追出的兩條

### U8-2 🟡 「證據蒐集執行進度」同公司任何人都看得到別的專案（低）

- **在哪**：資料來源是本棒範圍內的 `infra/flow_control/repository/task_execution_query.py:214`（`get_prep_job_progress`）。守門該在的地方在套件：路由 `jedi_task_platform/task/api/routes/task_execution_route.py:56-60` 只掛 `@jwt_required()` 和 `@require_license("project")`；服務 `jedi_task_platform/task/app/service/task_execution_service.py:138-141` 直接轉呼叫查詢，沒有任何角色檢查。
- **對照組**：**同一支服務檔**的 `start_task_execution`（`:69`）有呼叫 `_check_manager`。同一個專案、同一個頁面，「按開始」要管理人，「看進度」連成員都不驗。這是「有人守了一半」的「只檢查了寫入卻沒檢查讀取」那一型。
- **外洩什麼**：四個數字：這一輪任務總數、已完成、進行中、未開始。
- **為什麼只到同公司**：查詢一開始用專案編號找當前輪次（`task_execution_query.py:61-66` join `compliance.projects`），`projects` 有開隔離。所以別家公司的專案編號查不到輪次，會走舊鏈 fallback，一樣 join `projects`，最後回 0。
- **建議修法**：`get_prep_job_progress` 開頭照 `_check_manager` 的寫法先解析專案，再呼叫 `assert_project_participant`（`common.authz` 已有，不要另寫）。
- **是不是舊帳**：我在總表與既有掃描報告搜 `task-execution/progress`、`get_prep_job_progress`、`TaskExecutionProgressRoute` 都零命中。FR-048 的端點盤點只列了 `start-task-execution`。**判斷是淨新增。**

### U8-3 🟡 孤兒清理「沒帶身分就把全部當孤兒刪」的陷阱（低／陷阱）

- **在哪**：判定條件是 `infra/flow_control/repository/job_binding_orphan_query.py:121-128` 的 `DELETE … WHERE NOT EXISTS (SELECT 1 FROM compliance.job_executions …)`。
- **為什麼是陷阱**：四張綁定表（留言、設備、部門、問卷）**沒開隔離**，所以不管誰來查都看得到每一列。但 `job_executions` **有開隔離**，沒有身分時一列都看不到。「任務查不到」在沒身分的情況下對每一列都成立，於是每一列都被判成孤兒。
- **DEV 實測**（15:27～15:29 +08，`job_execution_devices` 共 5 列，全屬客戶 102，都指向真實存在的任務）。原封不動跑這支檔案的 DELETE 句，三種身分各一次，**每次整段 ROLLBACK**：

  | 身分 | 刪掉幾列 | 應該刪幾列 |
  |---|---:|---:|
  | 系統身分（`app.is_super_admin = t`，排程現況） | 0 | 0 |
  | 沒有身分（`session_scope` 沒身分時的樣子） | **5** | 0 |
  | 客戶 131 的一般使用者（`allowed_tenant_paths = /1/131/`） | **5** | 0 |

  跑完再數一次，還是 5 列，沒有殘留。同一時間用 count 查另外三張表：沒身分時，部門綁定 5 列全判孤兒、留言 6 列全判孤兒；問卷綁定表本來就 0 列。
- **今天會不會發生**：不會。`core/scheduler.py:447` 有 `with system_context("job_binding_orphan_cleanup"):`，service 的 docstring（`job_binding_orphan_cleanup_service.py:33-40`）也寫了「必須由呼叫端設好身分」。總表第 46 項的修正正是補上這個身分。
- **為什麼還要登**：這條規則目前**只靠註解和呼叫端記得**。第 46 項講的是「沒身分會掃到 0 筆、靜默不做事」，**剛好反過來**：對「NOT EXISTS 判孤兒」這種寫法，沒身分不是掃到 0 筆，而是**全部判成孤兒**。也就是第 46 項那句「省略身分就會靜默掃到 0 筆」對這支檔案不成立，漏帶身分的後果是大量刪資料。將來若有人照 U5／U6 的建議把排程改成「逐家客戶切換身分」，這支就會把每家客戶看不到的別家任務的綁定全刪掉。
- **建議修法**：`delete_orphans` 刪之前先做一次自我檢查。例如 `SELECT current_setting('app.is_super_admin', true) = 't'`，不是就整輪停手並記 ERROR。或者把判定改成「帶著任務所屬客戶一起比」，但綁定表沒有客戶欄位，這條比較難。另外 `JOB_BINDING_ORPHAN_CLEANUP_MODE=report` 這個逃生門已經存在，可以在修之前先在客戶環境設成 report。
- **我沒能做的實測**：本想直接把 service 以 report 模式（只數不刪）在本機起一次，比較有沒有身分的差別。但本機起 app 時撞到 `FlowControlJobRepoImpl` 少實作 `resolve_project_id_for_job` 的 TypeError（應是平行修正線的套件介面先行、主專案還沒跟上），起不來。**所以實測改在資料庫層直接跑同一句 SQL，沒有經過 Python 那層。** 這是打折處。

## 卡片點名的疑點逐條回答

| 卡片問的 | 答案 | 依據 |
|---|---|---|
| 🔴 `reverify_clone_query.py` 對四張表 `INSERT … SELECT`：來源與目的的編號是不是從同一個已驗證物件來？ | **不成立（安全）**。唯一入口是套件 `launch_reverify`（`jedi_compliance_audit/app/service/audit_round_app_service.py:569`）：先用網址的輪次編號撈母輪（`:583`），**用母輪自己的 `project_id` 驗稽核員角色**（`:586`），新輪由程式自己建、`project_id` 直接抄母輪（`:623-627`）。clone 時的配對 `get_job_pairs`（`reverify_clone_query.py:31-53`）只吃「母輪 id」與「新輪 id」兩個數字，而且兩個都是程式從同一個母輪推出來的，**沒有任何編號是使用者另外傳的**。每支 clone 只用 `pair` 裡的舊／新任務編號做 `WHERE`，寫入的專案、控制項欄位是從來源列原樣複製 | 開檔 |
| 同上，`workflow_xml_sync_service.py` 更新 `workflow_templates_trans` | **部分成立，已是舊帳**。實際寫入在 `flow_control_job_repo_impl.py:524-534`，`WHERE workflow_template_id = :tid`。`tid` 來自 `get_template_context_by_uid`（`:955-968`），而那個查詢 join 了有隔離的 `workflow_templates` 與 `workflow_executions`，所以跨公司的範本在使用者請求裡查不到、`tid` 拿不到。**但範本歸屬本身沒檢查**＝總表第 137 項（已登），不另計 | 開檔＋`02-schema.sql` 查 policy |
| `core/scheduler.py`：每個排程用誰的身分跑？有沒有逐家客戶切換？ | **9 支全部是具名系統身分掃全庫，沒有任何一支逐家客戶切換**：`drive_sync_worker`(`:177`)、`webhook_channel_renewer`(`:211`)、`framework_parse_job_cleanup`(`:248`)、`license_expiry_state_machine`(`:305`)、`tamper_fs_db_sync`(`:353`)、`detection_execution_timeout`(`:399`)、`job_binding_orphan_cleanup`(`:447`)、`log_partition_maintenance`(`:497`)、`integrity_spot_check`(`:68`)。其中「需要跨客戶才做得完」的（清孤兒、續 webhook、到期狀態機、分區維護）用系統身分是合理的；`drive_sync_worker` 處理的每一筆工作其實都有單一客戶，用系統身分是過度授權，**這一點 U5／U6 已登**，不另計 | 開檔 |
| `job_binding_orphan_cleanup_service.py`：「孤兒」判定會不會誤判活資料？ | **在現行呼叫方式下不會；少帶身分就會全誤判**。見 U8-3（DEV 實測）。另外兩點不成立：①任務是硬刪、沒有「軟刪後復活」的歧義（`delete_job` 走 `session.delete`）；②判定與刪除在同一句 SQL，沒有先查後刪的時間差 | 開檔＋DEV 回滾實測 |
| 有人守了一半：只驗「你是誰」沒驗「是不是你的」 | **成立一條**：U8-2（進度查詢只驗登入）。U8-1 屬「同一件事兩個入口兩套門」 | 開檔 |
| 有人守了一半：背景排程假設呼叫者一定是自己人 | **不成立**。排程沒有外部呼叫者；`drive_sync_worker` 處理的工作列是使用者請求排進去的，那是 U5／U6 的範圍 | 開檔 |
| 有人守了一半：防重放／計次只在單一程序內生效 | **有一處，但不構成問題**：`scheduler.py:189` 的 `max_instances=1` 只防同一程序內重疊。註解（`:483`）寫明排程只在 gunicorn master 跑一份（CM-1939 實測），多台機器才會重複。到期狀態機靠資料庫的 CAS 保證冪等（`:274`），孤兒清理本身冪等。**不另計** | 開檔 |
| 有人守了一半：「查不到」與「沒權限」混成同一個回應 | **不成立**。`task_existence_query.py` 查不到回 `None`，由呼叫端決定 404；本棒範圍內沒有把權限錯誤吞掉的地方 | 開檔 |
| 註解寫「這裡刻意不檢查」但上一層沒人真的檢查 | **成立一條**：`job_service.py:223` 註解寫「該端點自己有 `module-frame.update` 這道能力守門，租戶隔離由 RLS 負責」。上一層確實有這兩道，**但兩道都不回答「你是不是這個專案的管理人」**＝U8-1 | 開檔 |

**範圍內沒問題的：**

- `task_existence_query.py`：只是轉呼叫，建構時就強制注入、不會降級成「查不到就跳過守門」（`:45-57`，arc-review I-6 已修掉的舊病），沒問題。
- `task_execution_query.py` 的 `activate_jobs`：用 `job_ids` 批次改狀態，沒帶專案條件，但 `job_ids` 是同一個 service 從「已驗管理人的專案」查出來的（`task_execution_service.py:69-76`），不是使用者傳的。`get_detection_jobs_without_execution` 同理。
- `job_binding_orphan_query.py`：表名用字串拼接，但有白名單（`:62-70`），沒有 SQL 注入面。

## 可信度分兩層

- **「這幾條存在嗎」**：U8-1 經過三人面板完整投票（3 票全投、零漏投），驗證章 `verified`。U8-2、U8-3 是我開檔追的；U8-3 在 DEV 用資料庫層實測重現，**未經三人面板投票**。
- **「只有這幾條嗎」**：範圍 8 支我逐支通讀完。範圍外讀了追下去需要的檔：`job_service.py` 刪除段、`module_frame_item_service.py`、`module_frame_item_route.py`、`flow_control_job_repo_impl.py` 存檔相關五支方法、套件 `jedi_compliance_audit` 的 `launch_reverify` 與 `_check_role`、套件 `jedi_task_platform` 的 `task_execution_service.py` 與 `task_execution_route.py`、`jedi_common` 的 `session_scope` 與 `system_context`。**打折處**：
  - U8-3 實測在資料庫層直接跑同一句 SQL（用 `SET LOCAL` 模擬三種身分），沒有經過 Python 的 `session_scope`。本機起 app 撞到平行線造成的介面缺實作，起不來。
  - U8-1、U8-2 沒有起後端打真的 HTTP 請求，都是讀程式碼判斷。
  - DEV 查詢時間點 2026-09-25 15:13～15:31 +08；平行修正線可能改變資料與隔離設定。

## 給首腦的註記

- 盤點檔 `scan-inventory.md` 第 6 節「`compliance.task_assignees` 無 RLS」已過期：DEV 已開（CM-1800）。U6 報告若沿用這個前提，那一張表的結論要重看。
- U8-3 與總表第 46 項方向相反（第 46 項說「沒身分＝掃到 0 筆」），建議在第 46 項補一句「對 NOT EXISTS 型的判定是相反的，沒身分＝全部判中」，避免將來有人照第 46 項的描述推論這支漏身分時無害。

## 執行概況

| 項目 | 數值 |
|---|---|
| run ID | `wf_fc0b9dcb-c09` |
| 報告目錄 | `CLAUDE-SECURITY-20260925-070348/`（不入版控） |
| 驗證章 | `verified`（stamp `CLAUDE-SECURITY-REVISION-e1c923033405-dirty.json`；dirty 是平行線未 commit 的改動，不在範圍內） |
| 掃描參數 | `--effort low`、focus attack-surface、範圍 8 檔 |
| 研究員 | 2 派 2 回（主掃 1＋密鑰掃 1，密鑰零發現），**零失敗、零重試** |
| 候選／投票 | 1 候選、3 票全投，3:0 |
| agent 總數 | 5 個，0 錯誤 |
| 耗時 | 約 24 分鐘 |
| 淨新增 | 低 3（U8-1 面板；U8-2、U8-3 runner） |
