第 1 批:權限地基(13 件 → 9 張卡)

§1

這批一句話

這批修的是「誰有資格動這筆東西」這個判斷本身——不是補一行呼叫,而是把判斷規則定下來、把判斷的入口做出來。第 2 批那 29 件裡有一大票是「補一道守門呼叫」,而它們要呼叫的那道守門就在這批裡;所以這批必須先做完、合回主線、套件發版,第 2 批才有東西可以接。現在就能開,沒有等任何前置。13 件分成兩種形狀:①「任務狀態操作」這一組(#37)是一條守門被十支端點共吃,跨 BE 與 jedi-detection 兩個 repo,是整批的關鍵路徑;②其餘十二件是各套件自己的「擁有者/角色」判斷(問卷、公告、設備清冊、意見回饋、專案成員),彼此互不相干,可以同時派。

§2

卡片清單

卡 卡名(做什麼) repo/套件 涵蓋 SUMMARY # 建議 model/effort 序列組
1-1 任務的「完成/退回」改成只有被指派人與管理者能做(新開一道嚴守門) BE 主專案 #37(BE 半) opus/medium A(第一棒)
1-2 弱點掃描八支改狀態端點改吃那道新的嚴守門 套件 jedi-detection #37(套件半) sonnet/medium A(接 1-1)
1-3 專案成員與任務指派寫入時,先反查填進來的東西是不是這個專案的 套件 jedi-task-platform #41、#109、#110 opus/medium 獨立
1-4 問卷還原歷史版本要驗版本歸屬;問卷討論改/刪要驗作者本人 套件 jedi-survey #38、#39 opus/medium 獨立
1-5 公告改/刪要驗是不是自己發的;發送對象改成必選 套件 jedi-bulletin #42、#111 opus/high 獨立(含一項待裁)
1-6 設備與資訊系統七支讀取補權限檢查;修改請求不准夾帶停用/啟用 套件 jedi-asset #43(守門半)、#112 opus/medium B(接 1-9)
1-7 意見回饋六支裸奔端點補權限,改/刪補「這筆是不是你的」,清單分兩套 套件 jedi-issue #45 opus/high 獨立(含一項待裁)
1-8 「檢查流程圖」那支端點補權限檢查與輸入上限 BE 主專案 #40 sonnet/medium 獨立
1-9 兩支資料庫修正:補設備讀取權限給稽核人員角色、放寬意見回饋的新增規則 BE 主專案(scripts/sql/) #44、#43(權限資料半) sonnet/medium B(1-6 之前)

序列組說明:

  • A:1-2 呼叫的方法是 1-1 做出來的。1-1 先合、套件側再改,否則 jedi-detection 會呼叫一個不存在的方法(症狀是 AttributeError 500,不是 403)。
  • B:1-9 的第一支 migration 必須先套上 DEV,1-6 才驗得起來。順序反過來的症狀是五個畫面的下拉選單安靜變空、不跳錯誤(見 1-6 的「功能不能壞」段)。
  • 1-3/1-4/1-5/1-7/1-8 互不碰同一個檔,可以同時派。

§3

卡 1-1:任務的「完成/退回」改成只有被指派人與管理者能做

  • 範圍:BE 主專案(~/Projects/Billows/Audit-Manager/compliance-manager-be),worktree 建議 wt-fix-b1-flow-engine-guard,涵蓋 #37 的 BE 半
  • 這張卡是整批的第一棒,1-2 等它。

每件的修法

  • #37 產品定義上「只能看、沒有待辦」的一般成員(viewer),可以在任務畫面按「完成任務」把別人的任務結案、也可以把流程退回上一關;稽核紀錄上的經手人就變成這個只能看的人

    • 修法:現在守門只問「你是不是這個專案的參與者」,任何角色都算通過。決策者已裁定的角色規則是完成/退回任務=被指派的人 + 管理者(原則十七的表)。所以要在既有那道「是不是這個專案的人」之後,再加一道「是不是這個任務的負責人、或是這個專案的管理者」。
    • 照抄對象:app/flow_control/service/job_batch_complete_service.py:47-60 的「批次完成」已經是正確寫法——先 assert_project_participant,再查角色是不是 manager,不是 manager 就逐筆比對 assignee。單筆照這個形狀抄。
    • 🔴 入口清單:
      • common/authz/workflow.py:29-49 — assert_workflow_project_participant(),判定政策所在(admin 放行/無 context 不守/解析不到 project_id fail-closed/任一參與者放行)。「任一參與者放行」就是這條的根。
      • app/flow_engine/service/workflow_execution_service.py:126-140 — assert_project_participant(),BE 的公開守門入口,十支端點都經過它
      • app/flow_engine/service/workflow_execution_service.py:660 — complete_job() 內呼叫守門處
      • app/flow_engine/service/workflow_execution_service.py:841 — revert_job() 內呼叫守門處
      • api/flow_engine/routes/flow_engine_route.py:140 / :165 — 兩支 route 的呼叫端(route 不改,只確認參數夠不夠判 assignee)
      • common/authz/__init__.py:34 / :134 / :156 — canonical 出口,新守門要在這裡登記匯出(原則:授權守門一律走 common/authz/,不另立新 helper)
    • 🔴 這支檔案 1,374 行,已破 800 行上限(docs/claude/oversized-files.md 有登記)。新邏輯一律新開檔(例如 app/flow_engine/service/job_operator_guard.py)再從原檔一行呼叫,不准往裡加;能順手把 assert_project_participant 那一段抽出去就抽,commit message 註明。
    • 功能不能壞:
      • 這支守門同時被佐證文件的新增/更新/刪除吃(app/flow_engine/service/job_evidence_service.py:87、:153、:178)。角色規則表也說佐證應該收嚴到「被指派人+管理者」,但佐證那三支不在第 1 批的範圍。所以本卡不可以直接把既有那道 assert_project_participant 收嚴——那會連帶改掉佐證的行為,而佐證的手測與驗收沒有排在這一批。見下方 D-b1-1。
      • 同一支守門還被 revert_job 的 known_project_id 旁路用著(:841,FR-050 修 StageRollbackService 誤殺留下的)。那個旁路只跳過反查鏈、不跳過判定,新守門要沿用同一個 known_project_id 參數,否則階段回退會 100% 誤擋 manager。
      • app/flow_engine/service/stage_rollback_service.py:152 會呼叫 revert_job——它走的是系統發起的階段回退,手測要包含這一條,不能只測畫面上的退回鍵。
    • 手測:
      1. 用 viewer 角色登入,開一個自己不是負責人的任務 → 按「完成任務」→ 要被拒(403,不是 500)
      2. 同一個 viewer,按「退回上一關」→ 要被拒
      3. 用該任務的被指派人登入 → 完成 → 要成功
      4. 用該專案的 manager 登入 → 完成別人的任務 → 要成功
      5. 批次完成(勾多筆一起完成)行為不變:viewer 打得到入口但逐筆被過濾、manager 全部成功
      6. 階段回退(launch_audit 之後由系統發起的那條)manager 仍可執行 → 這條專測 known_project_id 旁路沒被弄壞
      7. 佐證文件的上傳/刪除行為不變(viewer 仍可上傳)——這是刻意保留,證明沒誤動佐證那三支
  • 這張卡的手測總清單:上面七項全部親手點過;第 6、7 項最容易漏,它們是「沒有弄壞既有東西」的證據。

  • 需決策者先裁的:D-b1-1(見本頁末)

  • 交接給 1-2 的東西:新守門的方法名與簽名要寫進 Notion 卡回寫裡(1-2 的 runner 看不到這邊的 context,只靠 commit message 與卡上的回寫)。


§4

卡 1-2:弱點掃描八支改狀態端點改吃那道新的嚴守門

  • 範圍:套件 monorepo ~/Projects/Jedicogy/module/jedi-python-package,子目錄 jedi-detection,worktree 建議 wt-fix-b1-detection,涵蓋 #37 的套件半
  • 前置:卡 1-1 必須先合回主線。開工前確認 1-1 的 commit 在,並從它的 Notion 回寫取新守門的方法名。

每件的修法

  • #37(套件半)弱點掃描模組有八支會改狀態的功能(開始掃描、整組取消、重跑單台、取消單台、立即開始、刪單筆紀錄、整組刪、派工)吃的是 1-1 那道同一個守門;只改流程模組那兩支的話,這八支仍然對「只能看」的角色敞開——他可以對客戶的正式機器發動帶帳密的掃描、反覆取消再重派、刪掉失敗的執行紀錄
    • 修法:這八處目前都是 self._wf_svc.assert_project_participant(job.workflow_execution_id)。改成呼叫 1-1 做出來的那道嚴守門(方法名見 1-1 回寫),參數形狀不變。套件只負責呼叫,判定政策在宿主——這是既有設計(見 jedi_detection/domain/ports.py:200-231 的 TaskInfo 契約說明)。
    • 🔴 入口清單(八處全部人工開檔核對過,行號確實):
      • jedi-detection/jedi_detection/app/service/detection_orchestration_service.py:196 — 派工/立即開始
      • 同檔 :871 — 整組操作入口
      • 同檔 :925
      • 同檔 :1007
      • 同檔 :1032 — 整組取消
      • 同檔 :1097 — 刪單筆執行紀錄
      • 同檔 :1127 — 整組刪
      • 同檔 :1470
      • 注入點在宿主:di_containers/detection_tools/detection_orchestration_containers.py:77(workflow_execution_service=...)——這一行不用改,注進來的就是 1-1 改過的那個物件;列在這裡是給 runner 確認「為什麼改宿主就會影響套件」。
    • ⚠️ 同檔還有一處呼叫 complete_job(:1639,auto 完成模式,掃描跑完自動把任務結案)。那一支是系統代勞、不是使用者操作,收嚴守門會讓自動完成整組壞掉(症狀:掃描明明成功、任務卻永遠停在執行中)。✅ 已補查(2026-09-21):auto 路徑走的是「無 request context → 不守」那條,只要 1-1 的新守門沿用 assert_workflow_project_participant 就不用動這支;但這件事要寫死在卡片上,不能讓 runner 自己「順手收嚴」。 查到的鏈路:complete_job(BE app/flow_engine/service/workflow_execution_service.py:650)內唯一的守門是 :660 的 self.assert_project_participant(workflow_execution.id)(:126-139),它把判定委派給 common/authz/workflow.py:29 的 assert_workflow_project_participant,而那支第一件事就是 user = get_user_context();user is None 時直接 return 不守(:34-37,註解明寫「背景任務無 request context → 不在守門範圍」)。 auto 這條路徑確認沒有 user context:呼叫鏈是 agent 回報 → receive_result(純 mTLS、不掛 JWT,套件 detection_orchestration_service.py:2009 的 docstring 明文警告過同一件事,該處為此刻意改用派工單自記的 tenant_id 讀通知設定,因為 get_channel_config() 在 context 為 None 時會 raise)→ _close_group_if_terminal(:1579 與 :1599 兩個呼叫點)→ _auto_complete_job_if_needed(:1622)→ complete_job(:1639)。 要寫進卡片的三句:① 1-1 的新守門一律走 common.authz 既有的那支,不要另寫判定、不要把 user is None 改成 fail-closed(改了就是這條 auto 路徑全掛);② :1639 這支不動、不傳系統身分旁路;③ 手測必須含「掃描跑完 auto 完成照舊生效」那一項(已在下方手測第 5 項)——這一項是「沒把系統代勞的路擋掉」的唯一證據,不可省。
    • 🔴 這支檔案 2,498 行,遠超 800 行上限。本卡是「改既有呼叫」不是「往裡加」,照上限規則屬允許;但不准在這支檔案裡新增任何新函式。
    • 功能不能壞:
      • 八支都是弱點掃描頁面上的按鈕,manager 與被指派人的操作全部要照舊可用
      • TASK_STATUS_PROCESSING 這個字面值是套件與宿主的凍結契約(domain/ports.py:238-245),不要碰
    • 手測(DEV,弱點掃描頁面):
      1. manager 開始一次掃描 → 成功
      2. 被指派人取消整組 → 成功
      3. viewer 打同一組的取消 → 403
      4. viewer 打「刪除執行紀錄」→ 403
      5. 掃描跑完的 auto 完成照舊生效(任務自動變完成)→ 這條專測上面那個 ⚠️
  • 這張卡的手測總清單:八支各用 manager 與 viewer 各打一次(十六次),加上 auto 完成那一項。批次驗、不要逐支驗:一輪把十七項全跑完、收集所有失敗,再一次修完、再驗一輪。
  • 紀律補充:套件異動走 poetry path dependency(主專案 pyproject.toml 把 jedi-detection 的 pin 改 path 形式,改動不 commit);不發版、不推 Nexus。發版是第 1 批全部合完之後的獨立動作。

§5

卡 1-3:專案成員與任務指派寫入時,先反查填進來的東西是不是這個專案的

  • 範圍:套件 monorepo,子目錄 jedi-task-platform,worktree 建議 wt-fix-b1-task-platform,涵蓋 #41、#109、#110
  • 三件同一個形狀(「驗了人、沒驗物」)、同一組檔案,併一張。

每件的修法

  • #41 任何人自己開一個新專案(開的人自動是管理者),送出指派時「專案填自己的、任務填別人專案的」,系統只查他是不是自己那個專案的管理者就放行——於是他能把別人專案的任務登記成自己的指派;主系統靠這張表反查任務屬於哪個專案,塞一筆假紀錄可能騙過那道判斷,進而改動別人專案的任務狀態

    • 修法:寫入前先由任務反查它真正屬於哪個專案,跟請求填進來的專案比對,對不上就拒絕(回「找不到」而不是「無權限」,避免當成探測工具)。
    • 照抄對象:同一支檔案的刪除功能已經是這個寫法——jedi-task-platform/jedi_task_platform/participant/app/service/task_assignee_service.py:239-251:先 get_one() 撈出既有紀錄、if existing is None: raise NotFound、再比對 existing.project_id != project_id 就 raise NotFound、最後才守 manager。照這個順序抄。
    • 🔴 入口清單:
      • jedi-task-platform/jedi_task_platform/participant/app/service/task_assignee_service.py:140-144 — add_task_assignee(),守門在 :144(assert_project_manager(..., _dto.project_id, control_id=_dto.control_id)),_dto.project_id 是呼叫方填的,沒有反查
      • 同檔 :175 — batch_add_task_assignees()。已補查(2026-09-21):兩個假設都不成立,這支不必改。 它不經過 add_task_assignee,但它也什麼都不做——自 FR-038 Wave 2A 起是 dark stub,整支只有 docstring(:186-189)+ return [](:190),沒有任何 DB 寫入,所以「反查歸屬」無從補、也無風險可補。本卡不動這支。 ⚠️ 但它仍是活端點,且下游有人在打:套件 route jedi-task-platform/jedi_task_platform/participant/api/routes/task_assignee_route.py:27(POST /task-assignees/batch)→ FE compliance-manager-fe/src/views/.../TaskSetupView.vue:292 在「快速配置」流程呼叫 API.TASK_ASSIGNEES_BATCH(src/config/api/api.js:128)。這與 FR-048 文件所記的「FE 無呼叫者」相反——若第 3 批的死碼清除要刪這支,刪掉會讓 FE 快速配置收 404。這件事屬第 3 批的判斷,本卡只回報、不處理。
  • #109 某個專案的管理者在寫入「群組層」/「控制項層」成員時,填進來的群組或控制項可能根本不屬於那個專案,系統不比對就寫進去

    • 修法(第五棒裁定第 10 條:逐支補、底層不動):在每一支寫入方法的守門之前,補一道「這個群組/控制項所屬的專案,是不是等於填進來的專案」的比對。不要改 assert_project_manager 本身——那支的職責是判角色,不是判歸屬;混進去會讓兩個問題共用一套查法,日後分歧。
    • 🔴 入口清單:
      • jedi-task-platform/jedi_task_platform/participant/app/service/project_control_participant_service.py:59(新增)/:103(更新)/:136(刪除)— 三處都是 assert_project_manager(..., project_id, group_id=group_id, control_id=control_id),角色查了、歸屬沒查
      • jedi-task-platform/jedi_task_platform/participant/app/service/control_group_participant_service.py:82(新增)/:121(更新)/:151(刪除)— 同上,帶 group_id
      • ⚠️ 已補查(2026-09-21):project_group_participant_service.py 三支寫入不在本卡範圍,與第 3 批 #68 完全重疊(裁定刪除),本卡不動。 查證內容:jedi-task-platform/jedi_task_platform/participant/app/service/project_group_participant_service.py 的 add_project_group_participant()(:62)/update_(:90)/delete_(:117)確認整支檔案沒有 import assert_project_manager,一道角色守門都沒有(import 區 :1-10 只有 auth_context/transaction/DTO/entity/domain service)。但這三支是全系統零呼叫的死碼,不是活的入口: ① 套件自己沒有對應 route——jedi_task_platform/participant/api/routes/ 只有 control_group_participant_route.py/project_control_participant_route.py/project_participant_route.py/task_assignee_route.py 四支,沒有 project_group_participant_route.py; ② BE 主專案 grep -rn "add_project_group_participant|update_project_group_participant|delete_project_group_participant" --include=*.py . 零命中; ③ FE grep -rn "ProjectGroupParticipant|project-group-participant" src/ 零命中; ④ 唯一的 consumer 是 AI 儀表板走查詢那支(di_containers/dashboard_apis/participant.py:96-101,participant.get_project_group_participants),不碰這三支寫入。 結論:這正是第 3 批卡 N-2 的 #68(「群組層成員管理三支因系統啟動漏接一條線,整支完全不檢查權限」→ 裁定刪除三支、查詢留著、資料表保留)。本卡的 #109 範圍維持 project_control_participant_service.py 與 control_group_participant_service.py 兩支檔案共六處,不擴大。 順便把上面查到的 file:line 補給第 3 批當入口清單(N-2 的 #68 入口欄原本也是待補)。
    • 零引用查證:本件不含刪除動作,不適用。
  • #110 更新專案成員資料時,撈不到既有紀錄就整段跳過管理者檢查——目前靠後面另一道檢查頂住,實際結果是「查無資料」而不是「未經授權的寫入」;但哪天有人改成「查不到就當新增」,這道把關會靜默消失

    • 修法:把「撈不到就跳過檢查」改成「撈不到就明確拒絕」(raise NotFound),不要依賴後面那道檢查。
    • 照抄對象:同檔刪除功能(task_assignee_service.py:246-248)已經是 if existing is None: raise NotFound(...)。
    • 🔴 入口清單:
      • jedi-task-platform/jedi_task_platform/participant/app/service/task_assignee_service.py:208-214 — update_task_assignee(),if existing is not None and existing.project_id is not None: 才守門,existing is None 時整段 if 不進去、守門被跳過
      • jedi-task-platform/jedi_task_platform/participant/app/service/project_participant_service.py:108-112 — update_project_participant(),⚠️ 這一支的形狀不同(它是 if enforce_role and self._participant_role_service: 直接守 project_id,沒有 existing 反查)。開卡前首腦確認 #110 指的是哪一支;材料檔寫「更新專案成員資料」,字面像後者,但「撈不到既有紀錄就跳過」的程式形狀只在前者。
  • 功能不能壞:

    • 三件都是寫入路徑加一道比對,正常操作(在自己專案裡指派自己專案的任務)完全不受影響
    • enforce_role=False 這個參數在多處出現,是系統內部呼叫的旁路(例如建專案時批次灌初始成員)。新加的歸屬比對要不要吃這個旁路,要跟既有守門一致——不一致的症狀是建專案時灌成員失敗、而錯誤訊息看起來像「找不到專案」
    • 守門未接線時會 RuntimeError fail loudly(jedi_task_platform/participant/common/guard.py:45-52),這個設計不要動
  • 這張卡的手測總清單(DEV,專案規劃頁):

    1. 自己開一個新專案 A(自動是 manager),另外找一個別人的專案 B
    2. 在 A 裡送指派,任務填 B 的任務編號 → 要被拒(#41)
    3. 在 A 裡送群組層成員,群組填 B 的群組編號 → 要被拒(#109)
    4. 在 A 裡送控制項層成員,控制項填 B 的 → 要被拒(#109)
    5. 更新一筆不存在的任務指派 → 要回「找不到」(#110)
    6. 正常路徑全部照舊:在 A 裡指派 A 的任務、設 A 的群組成員、改 A 的成員角色、刪成員 → 全部成功
    7. 建一個新專案並帶初始成員 → 成功(這條專測 enforce_role=False 旁路沒壞)
    • 批次驗:一輪跑完 1~7,收齊失敗再一次修。
  • 紀律補充:poetry path dependency 開發、path 改動不 commit、不發版。


§6

卡 1-4:問卷還原歷史版本要驗版本歸屬;問卷討論改/刪要驗作者本人

  • 範圍:套件 monorepo,子目錄 jedi-survey,worktree 建議 wt-fix-b1-survey,涵蓋 #38、#39

每件的修法

  • #38 在自己有權限的那份問卷上按「還原歷史版本」時,把版本編號改成別部門問卷的版本編號,就能把別人的答案複製進自己這一份裡——這不只是看到,是把別人的內容搬進來

    • 修法:取出歷史版本之後,比對它屬不屬於當前這份問卷(answer_history 反查它的 task_survey_id,跟參數帶進來的 task_survey 比對),不同就拒絕。權限照舊走嚴的那條(被指派人本人或專案管理者),不跟著讀取放寬。
    • 🔴 入口清單(人工開檔核對):
      • jedi-survey/jedi_survey/app/service/question_answer_history_service.py:49-58 — revert_question_answer_from_history()。:53-54 的 assert_task_survey_writer() 守的是參數帶進來的那份問卷,:56 的 get_question_answer_history(history_uid) 則是照 uid 直接撈,兩者之間一行歸屬比對都沒有
      • jedi-survey/jedi_survey/api/routes/question_answer_history_route.py:41-47 — route 入口(RevertQuestionAnswerHistoryRoute.post),task_survey_uid 與 history_uid 都從 payload 拿
    • 功能不能壞:還原歷史版本後會透過 WebSocket 推 reload 給同一份問卷的其他人(route :59-61)——比對失敗要在推播之前就拒絕,不要推一個空事件出去。
  • #39 有問卷修改權限的人可以改掉別人寫的討論內容、而且改完還掛著原作者的名字;刪除是直接從資料庫抹掉、不留痕跡

    • 修法(兩半,都要做):
      • 改:一律禁止改別人的。動手前先載出那則留言比對 created_user,不是本人就拒絕。
      • 刪:管理者可以刪別人的,但要另開一條寫明白的管理路徑,刪掉之後記成「由某某管理員移除」,不能讓留言憑空消失。
    • 🔴 入口清單(人工開檔核對):
      • jedi-survey/jedi_survey/app/service/survey_discussion.py:46-55 — update_discussion(),verify_survey_discussion_exist_by_uid(uid) 只確認存在,沒有比對 created_user;:53 把 updated_user 設成操作人,created_user 原封不動,所以畫面上仍顯示原作者
      • jedi-survey/jedi_survey/app/service/survey_discussion.py:57-61 — delete_discussion(),整支只有一行 delete_by_uid(uid),硬刪、不比對作者、不留痕
      • jedi-survey/jedi_survey/app/service/survey_discussion_service.py:69-80 — 外層 wrapper,兩支都只是轉呼叫(改法落在內層,但這一層的簽名可能要多收一個「操作人 id」)
      • jedi-survey/jedi_survey/api/routes/survey_discussion_route.py:62-65(PUT,守 survey_update_capability)/:82-85(DELETE,守 survey_delete_capability)— 門檻不是零:要有問卷修改/刪除能力點才按得動,但那道門只問「你有沒有改問卷的權力」,不問「這則留言是不是你寫的」
    • 功能不能壞:
      • 「軟刪除留痕」需要資料表有可用的欄位。✅ 已補查(2026-09-21):survey.survey_discussions 沒有任何軟刪除欄位 → 這一半必須配一支 BE migration,開卡時拆成兩張卡。 實查 DEV(\d survey.survey_discussions)與出貨基線(scripts/init/02-schema.sql:16432-16443)完全一致,全部欄位只有:uid / user_id / survey_id / message / id / created_at / updated_at / created_user / updated_user / ref_id。沒有 is_deleted、沒有 deleted_at、沒有 deleted_user,連可借用的 status 都沒有。 連帶要寫進卡片的三件事: ① migration 屬主線(phase=active/envs=*)→ 紀律段要寫「出貨基線待重產」(新客戶裝的是 scripts/init/02-schema.sql 這份長好的結構,不同步則新裝客戶缺這支且無錯誤訊息)。只套 DEV,STG/POC 一律不碰。 ② 這張表有四條 RLS policy(select/insert/update/delete,全部以 survey.surveys 的可見性為條件)。改成軟刪除後,「已刪除」的列仍會通過 select policy ——所以「怎麼呈現」必須在讀取端(survey_discussion_service.py:31 的 get_survey_discussions)處理,不能指望 RLS 幫忙過濾。 ③ 既有 delete policy 仍允許實刪;migration 不要動 policy(軟刪除走 UPDATE 路徑即可),否則會連帶影響 surveys CASCADE 刪除的既有行為(survey_discussions_survey_id_fkey 是 ON DELETE CASCADE)。
      • 留言讀取端(get_survey_discussions,survey_discussion_service.py:31)要確認軟刪除的留言怎麼呈現——是顯示「由管理員移除」的佔位,還是整筆不回。這是產品呈現決定,卡上要寫清楚要哪一種(建議顯示佔位,才符合「不能憑空消失」的裁定)。
  • 這張卡的手測總清單(DEV,問卷填答頁):

    1. A 在問卷 X 上留一則討論
    2. B(有問卷修改能力點)試改 A 那則 → 要被拒
    3. A 改自己那則 → 成功,畫面上作者仍是 A
    4. 管理者刪 A 那則 → 成功,畫面上看得出「由某某管理員移除」,不是整筆消失
    5. B(非管理者)刪 A 那則 → 要被拒
    6. 在問卷 X 按還原歷史版本,選 X 自己的舊版本 → 成功,答案正確還原,其他人的畫面收到 reload
    7. 同上但把版本編號換成問卷 Y 的 → 要被拒,且不推 reload
    • 批次驗:一輪跑完 1~7。
  • 需決策者先裁的:無裁決項,但補查結果改變了開卡形狀——survey_discussions 確認無軟刪除欄位,故本卡依原定規則拆成兩張:卡 1-4a(套件側:#38 歸屬比對 + #39 的「改」那一半 + 讀取端呈現)、卡 1-4b(BE scripts/sql/ migration:加軟刪除欄位,只套 DEV,出貨基線待重產)。1-4b 要先套上 DEV,1-4a 的軟刪除那一半才驗得起來。

  • 紀律補充:poetry path dependency、不發版。


§7

卡 1-5:公告改/刪要驗是不是自己發的;發送對象改成必選

  • 範圍:套件 monorepo,子目錄 jedi-bulletin,worktree 建議 wt-fix-b1-bulletin,涵蓋 #42、#111
  • 🔴 這張卡含一項待裁(D-b1-2),裁定之前只能做 #42 那一半。

每件的修法

  • #42 改公告、刪公告只問「你能不能改公告」,不問「這則是不是你發的」——一個部門的編輯者能改掉或永久刪掉別部門的公告。公告是大家會相信的內容,一則被動手腳的公告能直接誤導全公司;而且刪公告是整筆從資料庫移除、內容救不回來

    • 修法:改與刪之前先確認「這則公告是不是自己發的」(比對 created_user)。
    • ⚠️ 決策者已判斷「公告刪了就刪了是合理的、不需要留存」(見 docs/security-report/M17-bulletin.md:112)——所以不要順手把硬刪改成軟刪,即使那張表有 is_deleted 欄位。這張卡只補歸屬判斷。
    • 🔴 入口清單(人工開檔核對):
      • jedi-bulletin/jedi_bulletin/app/service/bulletin_service.py:284-300 — update_bulletin(),直接 BulletinEntity(<strong>kwargs) + update_bulletin(),沒有先載出既有公告比對 created_user**
      • jedi-bulletin/jedi_bulletin/app/service/bulletin_service.py:303-315 — delete_bulletin(),get_bulletin_by_uid 只為了清橋表,沒有比對作者
      • jedi-bulletin/jedi_bulletin/api/routes/bulletin_route.py:66-73 — PUT,守 bulletin_update_capability
      • jedi-bulletin/jedi_bulletin/api/routes/bulletin_route.py:77-82 — DELETE,守 bulletin_delete_capability
    • 功能不能壞:
      • delete_bulletin() 的先清橋表再刪主表的順序不可反(bulletin_org_units.bulletin_id 的外鍵沒有 CASCADE,順序反了會撞 ForeignKeyViolation 回 409)。新加的比對要放在這兩步之前。
      • 管理者要不要能改/刪別人的公告?卡上要寫明:建議「自己發的 + 具備管理能力點者」,與 #39 的刪除裁定同一形狀。
  • #111 用網址直接開一則公告時系統什麼都不檢查(不看部門、不看是不是草稿、不看有沒有到發布時間),所以調部門之後舊部門的公告用舊網址照樣打得開;公告列表碰到「沒有被分配部門」的帳號就整個不過濾、全部給看(含草稿、含過期),而「要不要過濾」這個開關還是呼叫端自己在請求裡傳的

    • 修法(已裁定,原則十四「讓意圖變明確」):發送對象改成必選——「全公司」或「指定部門」,兩者擇一,都沒選就不能發布;讀取時一律照那個值過濾,清單與單筆用同一套規則。一個設計解掉三件事(#111 的兩條 + #42 旁邊「改公告沒帶發送對象會靜默清空」那個附帶問題)。
    • 🔴 入口清單(人工開檔核對):
      • jedi-bulletin/jedi_bulletin/app/service/bulletin_service.py:212-236 — get_bulletins_and_pager(),:222 是 if _filter.auth and user_org_unit_id: ——user_org_unit_id 為空時整個部門過濾被跳過(elif 那一支只加 owner_created_user,不限部門)
      • 同檔 :239-255 — get_bulletins(),同一個形狀(:246)
      • 同檔 :258-265 — get_bulletin()(單筆),只有 get_bulletin_by_uid 一行,什麼都不檢查(這是 #111 的第 3 條)
      • 同檔 :284-300 — update_bulletin() 的 :297-299:先 delete_by_bulletin_id 清掉整組部門、再用 payload 重建。payload 沒帶 org_units 就等於清空 → 那則公告變成所有人可見(#42 的附帶問題)
      • jedi-bulletin/jedi_bulletin/api/routes/bulletin_route.py:57-62 — 單筆讀取 route,只有 @auth_required
      • auth 這個開關是呼叫端傳的:FE 只有 compliance-manager-fe/src/views/bulletins/BulletinManage.vue:82 一處在傳 auth: true(首腦已 grep 全 FE 確認只有這一處)。改成伺服器判定之後,這一行要跟著拿掉。
    • FE 現況(首腦已查,runner 不用重查):compliance-manager-fe/src/views/bulletins/BulletinForm.vue:42-43 的 org_units 前端已經是 required。所以「必選」在畫面上已成立,缺的是後端也認與**「全公司」這個明確選項**。
    • 功能不能壞:
      • 既有公告資料裡已經有一批 org_units 是空的(那正是 #111 的成因)。改成必選之後,那些舊資料要落到哪一邊?留空會讓它們讀取時全被擋掉(資料等於廢掉,原則十三的反面),塞「全公司」會讓原本只想發給某部門的變成全公司可見。這是 D-b1-2 的核心。
      • 首頁與公告列表都走 BulletinManage.vue(首腦已確認 BulletinList.vue 只是把它包一層、Home.vue 只是 router.push)——手測只要顧這一個畫面。
  • 這張卡的手測總清單(#42 部分可先做):

    1. A(部門甲的公告編輯者)發一則公告
    2. B(部門乙的公告編輯者)試改 A 那則 → 要被拒
    3. B 試刪 A 那則 → 要被拒
    4. A 改自己那則 → 成功,且發送對象沒有被清空
    5. 管理者改/刪 A 那則 → 依卡上寫定的規則(建議放行)
    6. 刪一則有綁部門的公告 → 成功、不回 409(這條專測清橋表順序沒被弄壞)
    • #111 部分的手測等 D-b1-2 裁定後補
  • 需決策者先裁的:D-b1-2(見本頁末)

  • 紀律補充:poetry path dependency、不發版。若 D-b1-2 裁定要加欄位,migration 與 FE 各另開一張小卡,不要讓這張卡跨三個 repo。


§8

卡 1-6:設備與資訊系統七支讀取補權限檢查;修改請求不准夾帶停用/啟用

  • 範圍:套件 monorepo,子目錄 jedi-asset,worktree 建議 wt-fix-b1-asset,涵蓋 #43 的守門半、#112
  • 🔴 前置:卡 1-9 的第一支 migration(補 device.read / information-system.read 給稽核人員角色)必須先套上 DEV,否則這張卡一驗就會看到「五個畫面的下拉選單安靜變空」。

每件的修法

  • #43 管理員把某人的「查看設備」權限關掉,那人只是側邊選單看不到入口,把網址貼上去照樣打開整張設備與資訊系統清冊(主機名稱、網路位址、作業系統版本、各系統的機密性等級、部署模式、授權邊界、負責人)——等於把客戶內部網路的組成攤開。這不是漏掉,是程式碼裡寫明的刻意取捨,現已裁定推翻

    • 修法(已裁定採甲案):七支讀取功能各加一道權限檢查,用的是同一支檔案裡寫入功能已經在用的現成機制(capability_required),不要造新東西。權限點名保留、字串凍結。
    • 🔴 入口清單(人工開檔核對,七支齊全):
      • 設備四支:jedi-asset/jedi_asset/api/routes/device_route.py:37-38(DeviceListRoute.post,清單)/:56-57(DeviceMenuRoute.get,選單)/:68-69(DeviceDetailRoute.get,明細)/:106-107(DeviceReferenceRoute.get,被引用查詢——刪除前顯示「將解除 N 筆關聯」那個)
      • 資訊系統三支:jedi-asset/jedi_asset/api/routes/information_system_route.py:42-43(選單)/:59-60(清單)/:102-103(明細)
      • 七支目前都只有 @auth_required,一個 @capability_required 都沒有;同檔的寫入六支全都有(device :78/:90/:124,IS :82/:114/:135)
      • 那句刻意取捨的註解在:jedi-asset/jedi_asset/plugin/contract.py:27(「read 兩項 BE route 不守(守門只在寫入類),但前端選單與…」)。修完要把這句改掉,否則下一個讀它的人(含 AI)會被過期脈絡帶偏。
      • 能力點名(凍結,不可改):contract.py:31 device.read、contract.py:35 information-system.read。AssetPluginConfig(contract.py:66-78)目前只有六個寫入能力點欄位、沒有兩個讀取的——要照同一形狀補兩個 config 欄位與兩個 DEFAULT_* 常數。
    • 🔴 功能不能壞(這一段是本卡最關鍵的部分,比守門本身更容易出事):
      • 會擋到誰(首腦已查證的數字,runner 不用重查):兩顆讀取權限14 個角色裡 13 個持有、1 個沒有。唯一沒有的是某客戶的「稽核人員」角色,掛 1 個使用者(全庫 41 個使用者)。新裝客戶踩不到(出貨初始資料只建一個管理員角色、八顆權限全給);踩得到的是已經自訂過角色的既有客戶。
      • 影響面比表面大:這七支同時是別的功能的下拉選單資料來源,實查到五處:①專案規劃頁 ②任務設定頁 ③合規文件的設備分頁 ④合規文件的資訊系統分頁 ⑤合規文件匯入時的資產挑選器。
      • 🔴 症狀是「安靜變空」不是「跳錯誤」:這五處呼叫後端的地方全部都是把錯誤接住、只寫進開發者主控台、然後把清單設成空的。使用者看到的是一個空的下拉選單,並且以為公司根本沒建過設備資料。驗收如果只測「資產管理頁打不打得開」,會完全測不到這個。
      • 所以 1-9 的 migration 要先套:補權限進「稽核人員」角色,五處才會照舊有資料。
      • ⚠️ 別漏掉設備的「被引用查詢」(device_route.py:107)。按得到刪除鍵的人必然已有刪除權限,替它補讀取檢查沒有實際衝擊,但漏掉就會變成「四支讀取只補了三支」的不對稱,日後沒人知道為什麼少一支。
  • #112 只給了修改權限、刻意不給刪除權限的操作人員,直接送一個「停用」欄位,就能讓任何一個資訊系統從所有選單與清單裡消失,稽核專案就選不到它了

    • 修法(兩個方向要一起處理,只堵一邊等於只修一半):
      • 方向一(停用):從修改用的資料格式把 is_active 拿掉;或收到 is_active=false 時改要求刪除權限。
      • 方向二(悄悄啟用):資料存放結構把 is_active 預設成 True,而修改流程是「拿送進來的內容重建一份完整資料、再整筆覆蓋回去」——所以只有修改權限的人送出一個不帶 is_active 的修改請求,就會把一個已被停用的資訊系統悄悄重新啟用。
    • 🔴 入口清單(人工開檔核對,三處都還在原狀):
      • jedi-asset/jedi_asset/api/serializers/information_system.py:59 — InformationSystemUpdateSchema.is_active(load_default=None, allow_none=True),修改用的格式收這個欄位
      • jedi-asset/jedi_asset/infra/repository/information_system_repo_impl.py:127 — model.is_active = entity.is_active,照單寫入
      • jedi-asset/jedi_asset/infra/repository/information_system_repo_impl.py:134-140 — deactivate(),刪除功能做的確實是同一件事(model.is_active = False)
      • jedi-asset/jedi_asset/domain/entity/information_system_entity.py:27 — is_active: bool = True,這就是方向二的來源(不帶欄位 → entity 拿預設 True → 覆蓋回去變成啟用)
      • 設備那半沒有這個欄位,是另一回事,不要順手動。
    • 功能不能壞:正常的「刪除資訊系統」(走 delete route :136,守 information_system_delete_capability)行為不變;正常修改(不碰啟用狀態)行為不變。
  • 這張卡的手測總清單:

    1. 先確認 1-9 的 migration 已套上 DEV(SELECT 一下稽核人員角色有沒有那兩顆能力點)
    2. 用有那兩顆權限的角色:資產管理頁的設備清單/選單/明細/被引用查詢、資訊系統的選單/清單/明細 → 七支全部照舊可用
    3. 用沒有那兩顆權限的角色(自建一個測試角色,把兩顆拿掉):七支 → 全部 403
    4. 🔴 同一個沒有權限的角色,逐一打開這五個畫面,確認下拉選單「是否仍然有資料」:①專案規劃頁 ②任務設定頁 ③合規文件的設備分頁 ④合規文件的資訊系統分頁 ⑤合規文件匯入的資產挑選器。期待結果依卡上寫定的決定(若採「全補」則這五處對無權限者會空,那是預期行為、要在回報裡明講;若決定把「當選單用」的排除在守門外,則要仍有資料)
    5. 只有修改權限的帳號送 is_active=false → 要被拒
    6. 只有修改權限的帳號送一個不帶 is_active 的修改請求給一個已停用的系統 → 那個系統不可以被重新啟用
    7. 有刪除權限的帳號正常刪除一個資訊系統 → 成功
    • 批次驗:一輪把 2~7 全跑完(約 20 個請求/畫面),收齊失敗再一次修。不要逐支驗。
  • 驗收打折要當場講明:如果 DEV 上湊不出「自訂過角色的既有客戶」這個情境(要自己手動建角色、拿掉能力點來模擬),回報裡要寫明這是模擬的,不可報成「已驗證既有客戶不受影響」。

  • 紀律補充:poetry path dependency、不發版。


§9

卡 1-7:意見回饋六支裸奔端點補權限,改/刪補「這筆是不是你的」,清單分兩套

  • 範圍:套件 monorepo,子目錄 jedi-issue,worktree 建議 wt-fix-b1-issue,涵蓋 #45
  • 🔴 這張卡含一項待裁(D-b1-3),裁定之前可以先做「補權限」與「補歸屬」兩半,清單分流那一半要等。

每件的修法

  • #45 任何一個一般員工在意見回饋頁面上,就能改掉或刪掉別人的回饋,連帶把開發團隊正在處理的那張外部問題單也一起關掉,而稽核紀錄還會把操作人記成合法本人。這是目前所有問題裡唯一一條「一般員工現在就能實際操作利用」的——不需要任何特殊權限、不需要技術手段,在正常操作介面上就做得到
    • 修法(兩層都要補,缺一不可):
      • 一、權限檢查:用現有的統一守門機制(capability_required),不要另外寫一套。這一層不卡任何決策——十顆能力點在資料庫裡全部已定義好、也已綁在前端選單上,缺的純粹是後端那一段接線。
      • 二、歸屬檢查:改或刪之前先確認那筆是不是自己的(或有沒有管理權限)。要放在業務邏輯那一層,因為要先把資料撈出來才知道要判誰。
    • 🔴 入口清單(人工開檔核對):
      • route 層,六支只有 @auth_required:jedi-issue/jedi_issue/api/routes/feedback_route.py:35-36(FeedbacksRoute.post,清單)/:52-53(單筆明細)/:59-60(新增)/:74-75(改)/:91-92(刪)/:102-103(刪附件)
      • 唯一有守門的:同檔 :115-117(FeedbackExportRoute,守 feedback_export_capability)
      • 另外兩支對外入口:jedi-issue/jedi_issue/api/routes/issue_member_route.py:12-13(全站使用者名冊,登入就能撈、回所有客戶的人含電子郵件——這一支已裁定整塊移除,歸第 3 批的 M23 成員名冊,本卡不動)/jedi_issue/api/routes/label_route.py:13-14(標籤下拉,內容是系統預設分類選項、不是客戶資料,不必守)
      • service 層,改與刪從頭到尾沒有任何「這筆是不是你的」比對:jedi-issue/jedi_issue/app/feedback/service/feedback_service.py:241-243(update_feedback(),get_feedback_issue_by_uid(uid) 拿到就直接改)/:279-281(delete_feedback(),同樣拿到就直接刪)
      • 跨出系統那一段:feedback_service.py:282-296 — 刪除時會先把 GitLab/GitHub 上那張 issue 設成 closed(set_gitlab_issue_closed / set_github_issue_closed),再 set_issue_closed、最後才刪本地。所以「任何員工都能關掉開發團隊正在處理的問題單」是這幾行。歸屬檢查要放在這一串之前。
      • 能力點清單(已定好,字串凍結):jedi-issue/jedi_issue/plugin/contract.py:39-48 — feedback.create / .read / .update / .delete / .export / feedback-view.read。同檔 :32-34 的註解自己寫明「BE route 目前只守 feedback.export 一項;其餘五項同樣是前端選單與 route_capabilities 在認」——修完要把這句改掉。
      • AssetPluginConfig 的對應物:contract.py:94 目前只有 feedback_export_capability 一個欄位,要照同一形狀補其餘五個。
    • 功能不能壞(首腦已查證的三項綠燈,runner 不用重查):
      • 這些入口有沒有被別的頁面借去當資料來源? 沒有——只有意見回饋自己那兩支頁面在呼叫。(與 1-6 的設備清冊形狀完全不同:那邊是「一個讀取入口被四個不相干的頁面當資料來源」,所以那邊要先補權限資料、這邊不用。)
      • 補下去會不會擋掉現在的人? 不會——DEV 14 個角色、41 個使用者,14 個角色五顆權限全部都有,零缺漏。
      • 新客戶裝起來會不會踩到? 不會——出貨初始資料只建一個管理員角色、五顆全給,兩條選單路由也都掛好了。
      • 實際效果:把前端本來就在認的那套規則,在後端也認一次。今天不會有任何人被擋;等到哪天管理員真的去取消某個角色的勾選,那一格才會生效。
    • 清單分兩套(D-b1-3 的所在):
      • 裁定結果:「我的意見回饋」那一頁只回自己建立的;管理頁回整個客戶的(但要真的檢查呼叫的人有沒有管理權限)。
      • 🔴 但要修的是兩件事、不是一件:後端目前分不出這兩頁是誰在呼叫。前端確實有兩個路由、選單也各自綁了一顆能力點,但兩頁用的是同一個畫面元件、打的是同一支入口——首腦已核對:compliance-manager-fe/src/views/feedback/FeedbackManageTabView.vue:65 用 :view="permissions.length === 1" 這個前端算出來的旗標切換唯讀模式,而 SystemFeedbackManage.vue:73 兩種模式打的都是同一個 API.FEEDBACKS。後端只看到「有人要清單」,就把整個客戶的回饋全部回傳。
      • 所以順序是:先讓後端能分辨這兩種呼叫(沒有這一步,第二步做不到),再兩邊各自套不同規則。
      • 連帶一件:自己那頁只看得到自己的,改與刪自然也只改得到自己的;但後端仍然要獨立檢查一次——「看不到所以改不到」是前端在擋,把網址直接貼上去就繞過去了。
  • 這張卡的手測總清單:
    1. A 送一則意見回饋
    2. B(一般員工、有全部五顆能力點)試改 A 那則 → 要被拒(歸屬)
    3. B 試刪 A 那則 → 要被拒,且外部問題單沒有被關掉(這條要去 GitLab/GitHub 看,或確認整合沒啟用時看 log 沒有送出 close)
    4. A 改自己那則、刪自己那則 → 成功
    5. A 刪自己那則的附件 → 成功;B 刪 A 那則的附件 → 要被拒
    6. 建一個測試角色、拿掉 feedback.update → 該角色連自己的都改不動(403,不是 500)
    7. 匯出行為不變(原本就有守門)
    8. 清單分流(等 D-b1-3):A 打「我的意見回饋」只看到自己的;管理者打管理頁看到全客戶的;一般員工把管理頁網址貼上 → 只拿到自己的或 403,不可以拿到全客戶的
    • 批次驗:一輪跑完 1~7(8 待裁),收齊失敗再一次修。
  • 需決策者先裁的:D-b1-3(見本頁末)
  • 紀律補充:poetry path dependency、不發版。feedback-view.read 的命名多了 -view(對應唯讀檢視那張頁 system-feedback-view),字串凍結不要順手統一。

§10

卡 1-8:「檢查流程圖」那支端點補權限檢查與輸入上限

  • 範圍:BE 主專案,worktree 建議 wt-fix-b1-flow-template-validate,涵蓋 #40
  • 獨立小卡,不依賴任何其他卡。

每件的修法

  • #40 「檢查流程圖」這支功能任何登入帳號都能打——同一個檔案裡其他功能都掛了權限檢查,只有它沒有;送進來的內容也沒有長度上限、節點數與連線數都不設限
    • ⚠️ 原本報告寫「打幾次就讓全站停擺」,實測後確認不成立(它走的是另一套檢查器,處理一千個節點只要 0.027 秒,從頭到尾沒碰到那兩支會算不完的程式)。那段敘述已整個移到 M06 第 3 條(歸第 5 批)。本卡只修越權與輸入上限兩件,不要順手做 DoS 那條的治本。
    • 修法:補權限檢查(照同檔既有的三個 require_capability 常數挑一個,這支是編輯器用的 inline 檢查,語意上對 flow_template.update);加上內容長度與節點數上限。
    • 🔴 入口清單(人工開檔核對):
      • api/flow_engine/routes/flow_template_route.py:130-144 — FlowTemplateValidateRoute.post,method_decorators = [jwt_required()] 之外一個 require_capability 都沒有
      • 同檔 :21-26 — 三個現成的能力點 decorator(_flow_template_create / _update / _delete),照抄挑一個,不要新造
      • 同檔 :47/:68/:78/:90/:108/:121 — 其餘六支全都掛了,只有 validate 沒掛(漏寫不是設計)
      • api/flow_engine/serializers/flow_template.py:31-32 — FlowTemplateValidateRequestSchema.bpmn_xml = fields.Str(required=True),沒有 validate=validate.Length(max=...)。同檔其他欄位有用 validate.Length,照抄
      • app/flow_engine/service/flow_template_app_service.py:140-152 — validate(),直接 BpmnTopologyValidator(rules).validate(bpmn_xml),沒有節點數/連線數上限
    • 上限數字(已定案,不用重推):分岔深度 8 層、節點總數 100 個(依據見 docs/security-report/M06-flow-engine.md 的 {#limits} 段)。XML 字串長度上限報告沒給數字 → 卡上寫「runner 依 100 個節點的實際 XML 大小推一個寬鬆值並在 commit message 寫理由」,不要憑空定。
    • 功能不能壞:
      • 這支是 FE 編輯器拖 sequenceFlow 時 debounce 呼叫的(route docstring 寫明),回應形狀不可變:topology 違規不 400、回 200 + {valid: false, violations:[...]} 讓 FE 渲染 inline 警告。超過上限時要回什麼? 建議也走 violations 形狀(多一個 rule),不要改成 400——改成 400 會讓編輯器的 inline 警告變成整頁錯誤(原則十一:安全措施不可以擋住正常客戶)。這一點卡上要寫死。
      • publish()(同檔 service :108-113)也呼叫 _validate_bpmn 與 _validate_bpmn_topology。新加的上限如果放在共用的 validator 裡,會連帶擋住既有超過 100 個節點的範本無法發布。✅ 已補查(2026-09-21):DEV 與出貨初始資料都沒有任何範本接近 100 個節點,100 這個上限放在共用 validator 裡也不會擋到任何既有資料。 查到的實際數字:
        • DEV(compliance.workflow_templates,59 筆):節點數最大 3 個(<bpmn: 標籤總數最大 14、含 diagram 標籤)。WHERE 節點數 > 100 查出 0 筆。這 59 筆全是 [a] ... / [b] ... 形狀的控制項稽核項自動產生範本,不是人畫的。
        • 出貨初始資料(scripts/sql/seeds/bpmn/ 四支 .bpmn):builtin-full-audit-with-review.bpmn 9 個節點(10 條 flow,最複雜的一支)/builtin-full-audit.bpmn 7 個/builtin-self-assessment.bpmn 5 個/builtin-internal-check.bpmn 4 個。與 docs/security-report/M06-flow-engine.md 記的「最複雜出貨範本 9 節點 2 層分岔」完全吻合。
        • 注意:scripts/init/04-seed-core.sql:566,599 明文驗證 workflow_templates 必須為 0 筆(D11.2:建資源庫時才自動產生),所以新裝客戶一開始連一筆範本都沒有,只有上面那四支 .bpmn 會被灌進去。 結論:上限放共用 validator 是安全的,不必為了 publish 路徑另開一條。但打折處要照實寫進 commit message:開發環境裡沒有任何一份是客戶自己畫的範本,「客戶實際會畫多複雜」我們沒有真實樣本,100 這個數字是拿現有最大值(9)的十倍推出來的。
    • 手測:
      1. 一般登入帳號(沒有流程範本能力點)打 validate → 403
      2. 有能力點的帳號打 validate,送一張正常流程圖 → 200 且 valid: true
      3. 同上,送一張有 topology 違規的圖 → 200 + violations(形狀不變,FE 的 inline 警告照舊出現)
      4. 送一張 150 個節點的圖 → 回 violations(不是 400),訊息看得出是超過上限
      5. 送一個超長字串 → 被 schema 擋下
      6. 在 FE 流程編輯器裡實際拖一條線 → inline 警告照舊跳出來(這條最重要,是「沒弄壞正常客戶」的證據)
      7. 既有內建範本仍可發布(專測上面那個 ⚠️)
  • 這張卡的手測總清單:上面七項。第 6、7 項不可省。

§11

卡 1-9:兩支資料庫修正——補設備讀取權限給稽核人員角色、放寬意見回饋的新增規則

  • 範圍:BE 主專案 scripts/sql/,worktree 建議 wt-fix-b1-migrations,涵蓋 #44、#43 的權限資料半
  • 🔴 這張卡要先做完並套上 DEV,卡 1-6 才驗得起來。
  • 🔴 只套 DEV。STG/POC 一律不碰(環境異動鐵律)。

每件的修法

  • #43(權限資料半)補上設備/資訊系統讀取權限給唯一沒有它的那個角色

    • 為什麼這件事在 migration 這張卡:卡 1-6 要給七支讀取端點補守門,補的同時必須把那顆權限補進唯一沒有它的那個角色,否則五個地方的下拉選單會安靜變空(不跳錯誤)。這一半先落地,1-6 才有安全的施工順序。
    • 首腦已查證的現況:兩顆讀取權限 14 個角色裡 13 個持有、1 個沒有;唯一沒有的是某客戶的「稽核人員」角色,掛 1 個使用者。新裝客戶踩不到(出貨初始資料只建一個管理員角色、八顆全給)。
    • 🔴 入口清單:
      • scripts/sql/packages/_capabilities.sql:35 — ('device.read', 'device', 'read', 'device read access', FALSE),能力點本身已存在,不用新建
      • scripts/sql/packages/_capabilities.sql:49 — ('information-system.read', ...),同上
      • scripts/init/04-seed-core.sql:277 / :350 — 出貨初始資料把兩顆給 Administrator 角色的那兩行(不用改,列出來是證明新客戶不受影響)
      • scripts/init/04-seed-core.sql:450 / :469 — route_capabilities 把兩顆綁到 device-manage / information-system-manage 兩條選單路由(不用改)
      • 要新增的:一支 migration,把兩顆 role_capabilities 補給那個缺的角色。先 SELECT 確認缺的是哪一個 role_id(DEV 上現查,不要照抄任何文件裡的 id)。
    • 功能不能壞:這是加權限不是收權限,對任何人都只會變寬不會變窄。
  • #44 一個沒有被分配部門的一般使用者送意見回饋一定會失敗,而且畫面不給任何理由——管理員去查這個人的權限,每一項都對,就是送不出去。新客戶剛裝好、部門還沒建起來時特別容易踩到

    • 這不是資安漏洞,是一個完全靜默的功能故障。
    • 修法(已裁定):放寬那條資料庫規則——送意見回饋跟你在哪個部門本來就無關,那條規則從一開始就不該要求部門。(另兩個選項已明確不採用:「規定帳號一定要有部門」會影響所有既有帳號與建帳流程,代價不成比例;「只把錯誤訊息講清楚」沒有解決卡住這件事。)
    • 🔴 入口清單(人工開檔核對):
      • scripts/sql/packages/jedi_issue/004-feedback-issues-rls-grants.sql:55-58 — feedback_issues_insert policy: WITH CHECK (app_tenant_allowed_for_session(tenant_id) AND (can_read_all_orgs = 't' OR app_org_allowed_for_session(org_unit_id)))
      • 對照同表另三條(:49/:62/:68,select/update/delete):那三條都是 is_super_admin = 't' OR app_tenant_allowed_for_session(tenant_id),既給超級管理員留旁路、也不要求部門。只有 insert 這一條沒有旁路、而且是唯一要求使用者必須有部門的。 這正是「權限查起來每一項都對、就是送不出去、畫面也不給理由」的來源。
    • 🔴 施工時要一併確認:放寬之後,沒有部門的人送出來的回饋,客戶歸屬仍然要正確記錄下來——org_unit_id 留空可以接受,但 tenant_id 不能空。要在手測裡實際 SELECT 出那一列確認。
    • 功能不能壞:意見回饋清單的讀取靠 select policy(:49)過濾,不吃 insert policy,所以放寬 insert 不會讓別人看到不該看的。

這張卡的共同紀律(migration 專用,不可省)

  • 檔頭要有 -- Date:,每個語句加日期註解
  • 沒有新建 table → 不必加 GRANT ... TO cm_app;但要確認現有 GRANT 不受影響
  • 收尾必 INSERT public.schema_migrations
  • 一律 psql --single-transaction -v ON_ERROR_STOP=1 -f <檔> 用 cmmgr 套(cm_app 受 RLS 擋系統層寫入會 silent fail)
  • 只套 DEV(本機 localhost:5432,連線資訊查 .env)。STG/POC 一律不碰,那是上版動作不是開發動作
  • 🔴 這兩支都是主線 phase=active/envs=* 的 migration → 回寫時要明講「出貨基線待重產」。重產要寫 188 基線庫,屬決策者裁示,runner 只回報不自己動。
  • 絕不把密碼寫進 migration 檔(連線資訊只寫帳號/host/port/db 名,密碼寫「請查 .env」)

這張卡的手測總清單

  1. 套完之後 SELECT 確認那個「稽核人員」角色已經有 device.read 與 information-system.read
  2. 用那個角色登入 → 側邊選單看得到設備與資訊系統的入口
  3. 建一個沒有被分配部門的一般使用者 → 用他送一則意見回饋 → 要成功
  4. SELECT 出那一列 → tenant_id 有值、org_unit_id 可以是空
  5. 有部門的使用者送回饋 → 照舊成功
  6. 超級管理員送回饋 → 照舊成功
  7. 意見回饋清單的可見範圍沒有變寬(用兩個不同客戶的帳號各看一次)
    • 批次驗:一輪跑完 1~7。

§12

決策者要裁的事(本批共 3 項)

D-b1-1:#37 的新守門,要新開一支還是直接把既有那支收嚴?

問題:現在有一道守門叫「是不是這個專案的參與者」,任何角色都算通過。這道守門同時被三組功能吃:①完成/退回任務(本批要收嚴)②弱點掃描八支改狀態端點(本批要收嚴)③佐證文件的上傳/更新/刪除(角色規則表也說要收嚴,但不在本批)。直接把那支收嚴,第三組會跟著改;新開一支則要在十處換呼叫。

我的建議:新開一支嚴守門(例如「是不是這個任務的負責人或這個專案的管理者」),既有那支不動。理由是佐證那三支的手測與驗收沒有排在這一批,順手改掉等於做了一件沒人驗的變更;而角色規則表本來就要求佐證也收嚴,留給後續一棒照同一支新守門接上去,是加一行呼叫的事。

D-b1-2:#111 公告「發送對象必選」,既有那批發送對象是空的公告要落到哪一邊?

問題:裁定是「發送對象改成必選——全公司或指定部門,兩者擇一,都沒選就不能發布」。但資料庫裡已經有一批公告的發送對象是空的(那正是這條的成因)。這批舊資料要怎麼處理?留空會讓它們讀取時全被擋掉(等於廢掉那些公告),一律當成「全公司」會讓原本只想發給某部門的變成全公司可見。而且要做到「分得出刻意選全公司」與「忘了選」,資料上可能需要多一個欄位(不能靠「橋表空集合」,那正是現在分不出來的原因)。

我的建議:多一個「發送範圍」欄位(全公司/指定部門),舊資料一律標成「全公司」並在上線公告裡說明。理由是那些公告現在實際上就是全公司都看得到(這條的症狀正是如此),標成全公司是把現狀寫實、不是擴大可見範圍;而留空會讓客戶的舊公告憑空消失,那比較像壞掉。若採此案,卡 1-5 要拆成三張(套件 + BE migration + FE 加選項)。

D-b1-3:#45 清單分兩套,後端要怎麼分辨「我的意見回饋」與「管理頁」這兩種呼叫?

問題:裁定是「自己那頁只回自己建立的、管理頁回整個客戶的(但要真的檢查管理權限)」。但前端兩頁用同一個畫面元件、打同一支入口,後端只看到「有人要清單」。要分辨就得改契約:①同一支入口多一個明確參數(例如「範圍=我的/全部」),送「全部」時後端驗管理能力點;②開第二支入口給管理頁。

我的建議:①同一支入口多一個參數。理由是既有原則二講「權限檢查走一條路由+登記,不為每個情境各開路由」;而且第二支入口要重做分頁、排序、匯出的整套契約,前端也要改兩處。多一個參數的代價是 FE 要在管理頁那邊明確送值(一行)。