第 2 批派工計畫——只驗登入、不檢查歸屬

第 2 批:只驗登入、不檢查歸屬(29 件 → 9 張卡)

§1

這批一句話

這 29 件全是同一個病:系統知道你是誰(登入過了),但沒問「這筆資料是你的嗎」——於是同一家公司內任何一個能登入的帳號,換個編號、送個空白查詢,就讀得到、甚至改得掉別部門、別專案、別客戶的稽核資料。修法幾乎都是「在原來只問登入的地方,補一行去問第 1 批做好的權限地基」,程式改動很小,但入口要抓全——同一個功能常有兩三個入口(清單/單筆/選單/匯入/AI 儀表板),漏一個就等於沒補。

排在第 2 批的理由:這批是 145 件裡「一般帳號就能打、而且真的拿得到別人資料」的主力,嚴重度最集中。但它依賴第 1 批——第 1 批把「誰能碰這筆資料」的判斷(專案參與者檢查、任務角色規則、檔案歸屬查詢通道)做成可以被呼叫的地基,這批 29 件才有東西可以呼叫。

能不能現在開:🔴 不能。要等兩件事:①第 1 批的程式合回 feature/review;②第 1 批動到的套件(jedi-task-platform、jedi-flow-engine、jedi-file-upload、jedi-survey)發版或至少在本機 path dependency 可用。這兩件到位後,九張卡彼此互不相干、可同時派(唯一要留意的序列組見下表)。


§2

卡片清單

卡 卡名(做什麼) repo/套件 涵蓋 SUMMARY # 建議 model/effort 序列組
2-1 檔案下載/預覽/換通行證/刪除四個出口,取檔前先問這個檔掛在哪、你碰不碰得到 套件 jedi-file-upload + BE 接線 + FE 驗證 #3 opus/high A
2-2 問卷的六支讀取功能補上「編號必填 + 你是這個專案的人」,含多人共編房間 套件 jedi-survey #5、#6、#48、#49、#50、#51 opus/medium —
2-3 稽核流程的證明清單、留言、階段歷程、當前階段四處補歸屬檢查,流程範本讀取補權限 套件 jedi-flow-engine + BE api/flow_engine + 套件 jedi-compliance-audit #7、#8、#52、#53、#115 opus/high B
2-4 專案人員名冊與任務指派清單四支查詢補「編號必填 + 你是這個專案的成員」 套件 jedi-task-platform #54、#55、#58、#59 sonnet/medium —
2-5 專案摘要報告五支讀取與稽核輪次選單補參與者檢查 BE 主專案 + 套件 jedi-compliance-audit #56、#57 sonnet/medium B
2-6 AI 儀表板 26 支查詢加「這支要什麼權限」欄位並在呼叫前檢查,拿掉專案清單的視同管理員後門 套件 jedi-ai-dashboard + BE di_containers + 套件 jedi-common #60、#61、#62、#114 opus/high —
2-7 查任務詳細資料補「這筆任務屬不屬於網址上那個專案」,改與刪那兩支也補同一句 BE 主專案 #63 sonnet/medium —
2-8 合規資源庫三個入口補歸屬檢查、建立入口補權限與用量上限 BE 主專案(oscal) #46、#65 opus/high C
2-9 刪除佐證文件與文件庫文件時,核對「要刪的那筆屬不屬於你管的那份計畫」 BE 主專案(oscal) #64 sonnet/medium C
2-10 弱點檢測「測試連線」補權限宣告,八支讀取功能補權限並修掉誤導的說明文字 套件 jedi-detection #4、#47、#113 opus/medium —

序列組說明:A 只有一張,獨立。B(卡 2-3 與 2-5)都會碰 api/flow_engine/routes/stage_rollback_route.py 家族與 jedi-compliance-audit 的 audit_round_app_service.py,不要同時派。C(卡 2-8 與 2-9)都改 BE app/oscal/service/ 下的檔,且 2-9 要照抄 2-8 建立的寫法,2-8 先、2-9 後。

卡數 10 張、上表寫 9 張——是因為 2-9 是 2-8 的續作、同一個 worktree 接著做(同組 C),計畫上算一個工作區、兩張卡分開驗收。


§3

卡 2-1:檔案四個出口,取檔前先問這個檔掛在哪、你碰不碰得到

  • 範圍:套件 jedi-file-upload(主體)+ BE core/plugins/file_upload.py(接線)+ FE 手測。worktree 建議 wt-fix-b2-file-upload。涵蓋 #3。
  • ⚠️ 這張是本批唯一需要「新增一套機制」的卡(其他八張都是補一行)——工作量最大,不要跟別張比。

每件的修法

  • #3 任何一個登入帳號,知道檔案編號就能下載、刪除別人的稽核證據,還能換發一張不必登入就能下載的通行證傳到公司外面(同一個洞的四個出口)

    • 修法(照 docs/security-report/M02-file-upload.md:217 已拍板的形狀):一個共用通道 + 一張登記表。檔案模組留一個空位說「我需要一段能回答『這個人能不能碰這個檔』的程式」,各業務模組把自己的回答程式登記進來;取檔時先查出這個檔屬於哪一類、屬於誰,再呼叫對應那一段。那份登記是程式裡的一張對應表(十幾列),不是資料庫的表——規則是活的邏輯(要查成員、看角色、算狀態),做成資料表等於用資料庫寫程式。
    • 歸屬資料一直都在,不必新建欄位、不必回填舊資料:檔案那張表上的 ref_id 是轉檔 PDF 的自我參照(全庫 2,821 筆只有 2 筆有值)、owner_user_id 96% 是空的,真正的歸屬在下游三張表反過來指著檔案——job_evidences.file_id(稽核證據)、ssp_reference_documents.file_id(安全計畫佐證)、issue_upload_file_mapping.file_uid(意見單附件)。
    • 四個出口問的權限不同,不可照抄同一句:下載/預覽/換通行證三個問「這件事你看得到嗎」,刪除要問「你改得動嗎」——同一個專案裡能看證據的人比能刪證據的人多很多,刪除照抄下載的判斷等於把刪除權放給所有看得到的人。
    • 三條規則要定死(漏一條這套修法就有破口):①查不到登記的類型 → 拒絕(不是放行);②沒有任何模組認領的孤兒檔 → 拒絕;③拒絕時回「找不到這個檔案」而不是「你沒有權限」(後者等於告訴對方這個編號是有效的,可以一個一個試)。
    • 刪除還要改成可復原(原文:證據被取走還做得下去,證據被刪就做不下去了,而且不可逆)。
    • 🔴 入口清單:
      • jedi-file-upload/jedi_file_upload/api/routes/upload_file_route.py:141(UploadFileDownloadRoute.get,下載;現在只有 @file_access_guard(bind_uid_arg="uid"))
      • jedi-file-upload/jedi_file_upload/api/routes/upload_file_route.py:173(UploadFilePreviewAsPDFRoute.get,PDF 預覽;同上)
      • jedi-file-upload/jedi_file_upload/api/routes/upload_file_route.py:123(UploadFileAccessTokenRoute.get,換通行證;現在只有 @auth_required,第 135 行直接簽 st 出去,一句歸屬都沒問)
      • jedi-file-upload/jedi_file_upload/api/routes/upload_file_route.py:91(UploadFileRoute.delete,刪除;只有 @auth_required,第 97 行直接 delete_file(uid))
      • jedi-file-upload/jedi_file_upload/app/service/upload_file_service.py:37(get_upload_file,「取檔那一層」——報告點名這一層最該補,因為它已經把檔案找出來、最有條件問「這是誰的」)
      • jedi-file-upload/jedi_file_upload/app/service/upload_file_service.py:47(delete_file)、:53(delete_files)
      • jedi-file-upload/jedi_file_upload/api/guards.py:42(file_access_guard,委派宿主 adapter 的位置——新增的「問歸屬」空位掛這裡)
      • jedi-file-upload/jedi_file_upload/domain/ports.py(新 port 的落點;現有 IUploadFileProvider:54、IUploadProviderFactory:108 是同一份契約檔)
      • jedi-file-upload/jedi_file_upload/plugin/contract.py(FileUploadAdapters 要加一個欄位收宿主的登記表;⚠️ 該 dataclass 沒有 capability_required 欄位,多塞 key 就是 TypeError,見 core/plugins/file_upload.py:113 的註解)
      • core/plugins/file_upload.py:110-111(BE 接線:file_access_guard=signed_token_or_jwt、signed_token_issuer=issue_signed_token 就在這兩行,登記表要從這裡遞進去)
      • 三個業務模組的回答程式落點(各查自己那張關聯表):app/flow_engine/service/job_evidence_service.py(稽核證據,可直接複用 app/flow_engine/service/workflow_execution_service.py:126 的 assert_project_participant)、app/oscal/service/ssp_control_implementation_service.py(SSP 佐證)、意見單附件的回答程式在套件 jedi-issue(jedi-issue/jedi_issue/infra/issue_upload_files/models/issue_upload_file.py:8 就是 issue_upload_file_mapping 那張表,BE 側零引用)——這張卡因此會動到第四個套件,開卡時要寫進範圍
      • FE 呼叫點(改完要一個一個點過):src/utils/signedDownloadUtil.js、src/components/grc/DocumentPoolPanel.vue、src/components/grc/EvidencePreviewDialog.vue、src/components/grc/ReferenceDocumentList.vue、src/components/grc/JobExecutionDrawer.vue、src/components/grc/AuditControlRef.vue、src/components/grc/ModuleFrameDocumentPoolPanel.vue、src/views/feedback/system/SystemFeedbackView.vue、src/views/feedback/system/SystemFeedbackForm.vue、src/views/module_frame/ModuleFrameTemplateEditView.vue(端點常數在 src/config/api/api.js:91-94)
    • 功能不能壞:🔴 這張卡風險最高的地方是「孤兒檔一律拒絕」——全庫 2,821 筆檔案裡,三張下游表認不出來的那些(例如歷史遺留、系統產生的轉檔 PDF)會立刻變成下載不了。手測前先在 DEV 用唯讀 SQL 數一遍「三張表都認不出來的檔有幾筆、是什麼」,若數量可觀要回報決策者再動(見 D-b2-1)。其餘手測:每一種檔案用途(稽核證據、SSP 佐證、意見單附件、系統回饋附件、範本文件庫)都要做「自己的檔下載得到、預覽開得起來、通行證換得到、該刪的刪得掉」,再拿另一個沒參與該專案的帳號打同一個編號,四個出口都要回「找不到」。
    • 零引用查證:不適用(本件不刪東西;刪除改成可復原屬新增行為)。
  • 這張卡的手測總清單:

    1. 稽核證據:任務詳情頁上傳一個檔 → 下載 → 預覽(Office 檔要轉 PDF)→ 刪除,四個動作都要成功。
    2. 換一個「不是這個專案參與者」的帳號,拿步驟 1 那個檔的編號打下載/預覽/access-token/delete,四個都要回「找不到」(不是 403)。
    3. SSP 佐證文件、範本文件庫、意見單附件、系統回饋附件各做一輪步驟 1。
    4. window.open 那條路(原生 GET 帶不了 bearer、先換 st)要實際點過——這是 signedDownloadUtil.js 的主要路徑,最容易被改壞。
    5. 轉檔 PDF 預覽(/file/pdf-preview)要在「檔案存在物件儲存」與「remote_agent 儲存」兩種情況各測一次。
  • 需決策者先裁的:見文末 D-b2-1、D-b2-2。


§4

卡 2-2:問卷六支讀取功能補上「編號必填 + 你是這個專案的人」

  • 範圍:套件 jedi-survey。worktree 建議 wt-fix-b2-survey。涵蓋 #5、#6、#48、#49、#50、#51。
  • 這張卡的共同修法(六件都套,不要各自發明)(出自 docs/security-report/M05-survey.md:135-156):
    • 讀取的門檻定在「這個專案的參與者」——只要是這個專案的成員,不管掛什麼角色都讀得到;查不到角色的一律擋掉。寫入維持原本較嚴的規則不變(被指派人本人或專案管理者)。也就是讀比寫寬一級。
    • 不必新寫判斷邏輯:寫入側用的是套件自己那支(jedi_survey/plugin/assembly.py:57-59 已經把宿主的 project_role_guard 接進來了),讀取側用主系統共用的「是不是這個專案的參與者」(common/authz/project.py:101 assert_project_participant),兩支都已經存在,沿用同一條接線即可、不必另外拉線。
    • 🔴 不可以相信呼叫端送上來的專案編號。問卷本身不帶專案編號,必須先從任務反查出它屬於哪個專案,才知道要拿誰去比對。
    • 🔴 必填與歸屬檢查兩件必須一起做,只做一個等於留後門:只加必填 → 我填別人的編號照樣讀得到;只加歸屬檢查 → 我兩個條件都不填,它不篩,而且連「要檢查誰」都不知道,整道檢查直接跳過。順序是先強制給編號,有了編號才有東西拿去檢查。
    • 已確認並接受的含意:門檻定在參與者,代表專案裡「只能看」的角色也讀得到稽核答案與審核意見,但動不了。

每件的修法

  • #5 同一家公司內任何有帳號的人,送一個空白請求到問卷的「查填答歷史明細」,就能讀走全公司每一份問卷的每一版答案與稽核審核意見(🟠 高,本批問卷組最嚴重的一支——外洩的是受檢單位坦承的缺失與稽核人員的判斷,是稽核過程本身外洩)

    • 修法:問卷編號改必填,拿到編號後確認呼叫者是不是該問卷所屬專案的參與者。
    • 🔴 入口清單:jedi-survey/jedi_survey/api/routes/question_answer_history_detail_route.py:18(QuestionAnswerHistoryDetailsRoute.post,掛在 /project-survey/history-details,見 api/routing.py:107)→ jedi-survey/jedi_survey/app/service/question_answer_history_detail_service.py:19(get_all_question_answer_history_detail(**kwargs),kwargs 全可空就是空白查詢回整張表的成因)
  • #6 任何登入帳號拿一個問卷編號去讀答案,系統不問這份問卷是不是你的,別部門的作答內容、分數與審核意見全看得到(🟠 高)

    • 修法:取出問卷之後補一句「呼叫者是不是這個專案的參與者」,用現成的判斷,一行就好。同一支服務裡另外三支寫入方法每一支都有檢查,只有這支讀取漏掉——照抄那三支的寫法。
    • 🔴 入口清單:jedi-survey/jedi_survey/api/routes/question_answer_route.py:28(TaskSurveysAnswersRoute.get,掛 /project-survey/answers/<uid>)→ jedi-survey/jedi_survey/app/service/question_answer_service.py:90(get_answer_list_by_task_survey_uid)。已有檢查可照抄的三支寫入:同檔 :104 update_task_survey_answer、:217 checkpoint_task_survey_answer、:376 patch_task_survey_answer
  • #48 任何登入帳號送空白請求到「列出任務問卷」,就拿到整個客戶全部問卷的編號、所屬專案、狀態、受檢設備與部門(🟡 中,是 #6 的入場券)

    • 修法:專案編號改必填,再確認呼叫者是不是該專案的參與者。
    • 🔴 入口清單:jedi-survey/jedi_survey/api/routes/task_survey_route.py:29(TaskSurveysRoute.post,掛 /task-surveys)→ jedi-survey/jedi_survey/app/service/task_survey_service.py:59(get_task_surveys(<strong>kwargs))。單筆那支 task_survey_route.py:40(TaskSurveyRoute.get)→ task_survey_service.py:66(get_task_survey)也要一起補**,否則清單擋住了、單筆還通
  • #49 任何登入帳號送空白請求到「列出填答歷史」,就能看到全公司跨所有專案誰在什麼時候改了哪份問卷(🟡 中;同時提供 #5 與第 1 批 #38 需要的版本編號)

    • 修法:問卷編號改必填,再確認呼叫者是不是該問卷所屬專案的參與者。
    • 🔴 入口清單:jedi-survey/jedi_survey/api/routes/question_answer_history_route.py:32(QuestionAnswerHistoriesRoute.post,掛 /project-survey/histories)→ jedi-survey/jedi_survey/app/service/question_answer_history_service.py:35(get_all_question_answer_history(<strong>kwargs))。選單那支 question_answer_history_route.py:22(QuestionAnswerHistoryMenuRoute.get)→ 同 service :42(get_question_answer_history_menu)一起補**
  • #50 任何登入帳號送空查詢到「問卷討論列表」,就把全公司的討論串撈回來——裡面是稽核過程的缺失細節與審查意見(🟡 中)

    • 修法:兩個查詢條件改必填,再確認呼叫者是不是該專案的參與者。
    • 🔴 入口清單:jedi-survey/jedi_survey/api/routes/survey_discussion_route.py:25(SurveyDiscussionsRoute.post,掛 /survey-discussions)→ jedi-survey/jedi_survey/app/service/survey_discussion_service.py:31(get_survey_discussions(survey_uid, task_survey_uid)——兩個參數就是報告說的「兩個查詢條件」,目前都可空)
  • #51 多人同時填問卷的「房間」想進哪間就進哪間,可以旁聽別專案正在編輯中的問卷並抓到當下的答案快照(🟡 中)

    • 修法:進房間前先確認呼叫者是不是該問卷所屬專案的參與者,讀答案快照那條路要用同一道檢查守住(兩條,不是一條)。
    • 🔴 入口清單:jedi-survey/jedi_survey/app/handler/fill_survey_socketio_handler.py:59(on_join,第 66 行 room = data.get('room') 直接吃呼叫端給的房號、第 102 行 join_room(room) 就進去了);同檔要一起看的還有 :130 on_editing、:142 on_update、:182 on_edit、:203 on_endedit(每支都自己 data.get('room'),只守 on_join 不夠——這五支不必先 join 也能指定房號)。⚠️ 這是 SocketIO 路徑、不是 REST,RUN_MODE=socketio 才載(jedi_survey/plugin/__init__.py:149),手測要另起那個模式
  • #59 全系統共用的取資料底層有一條「查詢條件全沒填就不過濾」的規則,送一個空白查詢就等於回整張表(🟡 中,已裁定:底層不動、只修有洞的那幾支)

    • 修法:🔴 這件不單獨修、也不開卡。決策者已裁「底層不動,只修有洞的那 3 支」——那 3 支就是本卡的 #48/#49/#50 與卡 2-4 的 #54/#58 的「編號改必填」。反悔條件:新功能第四次撞到同一件事,就改底層。
    • 為什麼併進這裡:它是 #48/#49/#50/#54/#58 背後的同一個病灶,本卡與卡 2-4 把必填補完,這件就等於處置完畢。runner 做完要在模組頁與 SUMMARY 把 #59 標「已依裁定處置(逐支補必填,底層未動)」。
  • 這張卡的手測總清單:

    1. 自己參與的專案:任務問卷列表開得出來、單筆開得起來、答案讀得到、填答歷史看得到、歷史明細展得開、討論串列得出來——六處都要正常。
    2. 換一個沒參與該專案的帳號,拿步驟 1 的編號打同六支,全部要被擋(回「找不到」)。
    3. 六支都送完全空白的請求,全部要被拒(不是回整張表、也不是回空清單)。
    4. 問卷寫入路徑(填答、送審、通過/退回)要確認行為一點都沒變——這張卡只動讀取。
    5. RUN_MODE=socketio 起一份,兩個瀏覽器同時開同一份問卷確認共編正常;再拿沒參與的帳號指定別人的房號,要進不去、也抓不到答案快照。

§5

卡 2-3:稽核流程四處補歸屬檢查,流程範本讀取補權限

  • 範圍:套件 jedi-flow-engine、BE api/flow_engine/ + app/flow_engine/、套件 jedi-compliance-audit。worktree 建議 wt-fix-b2-flow。涵蓋 #7、#8、#52、#53、#115。序列組 B(與卡 2-5 不同時派)。
  • 這張卡的共同修法(出自 docs/security-report/M06-flow-engine.md:149-179,稱「統一修法」):套與卡 2-1 同一個形狀——一個共用通道 + 一張登記表,先查出這筆資料屬於哪一類、屬於誰,再呼叫對應的那一段。三條規則同樣定死:查不到登記 → 拒絕;沒人認領 → 拒絕;拒絕回「找不到」不回「沒權限」。
    • 🔵 好消息:BE 這邊反查鏈已經做好了——app/flow_engine/service/workflow_execution_service.py:126 的 assert_project_participant(workflow_execution_id, known_project_id=None) 就是「由流程實例反查專案、再問是不是參與者」的現成通道(fail-closed、任何角色都放行、只擋非參與者),:141 的 resolve_project_id_from_workflow 是公開入口。要問「這筆流程資料是誰的」一律走這支,不要另外兜一條鏈(同一個問題兩套查法遲早分歧,而守門判錯的症狀是靜默放行)。

每件的修法

  • #7 任何登入帳號知道一個任務編號,就能讀走別家客戶的稽核證明清單(含檔案編號),拿到編號可以接著直接下載檔案本體(🟠 高,⚠️ 部分修)

    • 🔴 已修的是哪道、這張卡要補哪道:已修=任務紀錄那張表的資料庫層客戶隔離(跨客戶已經擋住)。未修、本卡要補=①證明文件表仍然沒有資料庫隔離、連客戶欄位都沒有;②應用程式層那道「你是不是這個專案的人」一行都沒加。本卡只做②(程式層);①屬改結構,歸 FR-094 那一級,本卡不做但要在回寫時點名。
    • 修法:讀取前呼叫 workflow_execution_service.assert_project_participant(...)(同檔的新增、更新、刪除都已經有做,只有讀取漏掉,明顯是漏寫不是設計——照抄它們)。
    • 與卡 2-1 是同一條鏈:那邊是「拿到編號就能下載」,這邊是「連編號都送給你」。兩張卡都要補,補一邊等於沒補。
    • 🔴 入口清單:api/flow_engine/routes/job_evidence_route.py:27(JobEvidencesRoute.get,清單)、:64(JobEvidenceRoute.get,單筆)→ app/flow_engine/service/job_evidence_service.py:46(get_job_evidences_by_job_execution_uid)、:67(get_job_evidences_uid)。已有檢查可照抄:同 service :79 add_job_evidence、:146 update_job_evidence、:165 delete_job_evidence
  • #8 任何登入帳號能讀走別人專案的整串稽核討論,也能往裡面塞留言——留言會即時推播給該專案全體成員,看起來就像同事發的(🟠 高,這組五條裡唯一「能寫」的)

    • 修法:套統一修法;另外補留言的長度與筆數上限(報告明列的額外要求)。讀取要補「是不是專案參與者」;寫入照原則十七的規則表——任務留言新增:專案參與者都可以(現況已符合),讀取要補。
    • 🔴 入口清單:api/flow_engine/routes/flow_engine_route.py:46(WorkflowExecutionCommentRoute.get,讀;第 52 行拿 uid 直接 get_workflow_execution,一句歸屬都沒問)、:65(同類別的 put,寫)。可照抄的既有寫法:jedi-task-platform/jedi_task_platform/task/app/service/job_comment_service.py:45(它已經在呼叫 self._workflow_execution_service.assert_project_participant(we_id)——同產品內同類留言的正確寫法就在那裡)
    • 功能不能壞:留言會即時推播(app/notification/handler/notification_socketio_handler.py:41 join_room),補完要確認正常參與者留言後全體成員仍收到推播。
  • #52 任何登入帳號知道一個稽核輪次編號,就能讀走別家客戶的階段歷程,含退回理由的完整內容(🟡 中;退回理由是稽核人員親筆寫的「哪裡有問題」)

    • 修法:套統一修法。網址上明明帶了專案編號,程式從頭到尾沒拿來用——要用它去問參與者身分,並且不能只信網址上那個編號,要從輪次反查它真正屬於哪個專案再比對。
    • 🔴 入口清單:api/flow_engine/routes/stage_rollback_route.py:56(RoundStageTransitionsResource.get,收了 project_uid 與 round_uid 兩個參數,第 63 行只把 round_uid 傳下去、project_uid 丟掉)→ jedi-compliance-audit/jedi_compliance_audit/app/service/audit_round_app_service.py:870(list_stage_transitions(round_uid, locale),第 872 行取輪次、第 877 行列歷程,中間沒有任何守門)。套件側可用的守門:jedi-compliance-audit/jedi_compliance_audit/common/guard.py:47 assert_project_role/:58 assert_project_role_fallback(宿主已接線,plugin/contract.py:99 project_role_guard)
  • #53 「查目前在哪個階段」用你自己填的專案編號查角色、用你自己填的輪次編號撈資料,兩者不核對,角色不符也只是把「能不能推進」標成否、其他資料照樣整包回傳(🟡 中)

    • 修法:套統一修法——兩個編號要核對(由輪次反查它屬於哪個專案,跟填進來的專案比對,不一致就拒絕);角色不符不能只是把旗標標否,要擋下整個回應。
    • 🔴 連帶必做:「推進」與「退回」兩支寫法完全一樣、都沒核對,現在沒事是靠運氣不是靠設計——只是剛好被下游套件的另一道檢查擋住。要一併補上,不要繼續依賴下游套件擋(哪天有人整理那個套件把那道檢查改掉,漏洞自己回來而且沒人會發現)。
    • 🔴 入口清單:api/flow_engine/routes/stage_advance_route.py:27(StageInfoResource.get)→ app/flow_engine/service/stage_advance_service.py:82(get_current_stage_info;第 83 行 _resolve_workflow_context(round_uid) 用輪次撈資料、第 113 行 _get_user_role(project_uid, user_id) 用呼叫端給的專案查角色——兩個來源不同、從不比對,第 114 行只把結果塞進 user_can_advance)。推進:api/flow_engine/routes/stage_advance_route.py:48(StageAdvanceResource.post)→ app/flow_engine/service/stage_advance_service.py:191(advance_stage,第 289 行同一個模式,第 292 行雖然會 raise 但用的仍是呼叫端給的 project_uid)。退回:api/flow_engine/routes/stage_rollback_route.py:28(StageRollbackResource.post)→ app/flow_engine/service/stage_rollback_service.py:73(rollback_stage,第 95 行 _get_user_role(project_uid, user_id) 同病)
  • #115 同一家客戶內沒被授權的帳號可以讀走全部流程範本與系統內建範本的完整內容(階段設計、角色指派、判斷條件)——前端選單擋住了,但功能本身是敞開的(⚪ 低)

    • 修法:套統一修法;或者產品決定「人人可看」就把權限與選單綁定一起拿掉——不要前後端規則不一致(見 D-b2-3)。跨客戶讀不到(那張表的資料庫隔離有開,已實際查證),所以這條只是同客戶內的越權。
    • 🔴 入口清單:api/flow_engine/routes/flow_template_route.py:33(FlowTemplatesRoute.get,清單)、:62(FlowTemplateRoute.get,單筆)——兩支只有 class 層的 method_decorators = [jwt_required()],同檔的 post :49/put :70/delete :80 都掛著 _flow_template_create/_flow_template_update/_flow_template_delete(定義在同檔 :54-56 一帶),讀取兩支照抄即可
  • 這張卡的手測總清單:

    1. 自己參與的專案:任務證明清單列得出來、單筆開得起來;流程留言讀得到、發得出去、其他成員收到推播。
    2. 稽核輪次的階段歷程時間軸看得到(含退回理由);「當前階段」橫幅正常顯示、推進/退回按鈕該亮的亮。
    3. 流程範本清單與單筆:有權限的帳號看得到、沒權限的被擋(與前端選單的顯示一致)。
    4. 換一個沒參與該專案的帳號,把上面每一支都打一遍,全部要回「找不到」。
    5. 🔴 交叉打:專案填自己的、輪次/任務填別人的,「當前階段」「推進」「退回」三支都要拒絕(這是 #53 的核心,一定要親手試)。
    6. 留言長度上限與筆數上限:貼一段超長文字、連續送很多筆,要被擋且有清楚訊息。

§6

卡 2-4:專案人員名冊與任務指派清單補「編號必填 + 你是這個專案的成員」

  • 範圍:套件 jedi-task-platform(participant 子模組)。worktree 建議 wt-fix-b2-participant。涵蓋 #54、#55、#58、#59(#59 見卡 2-2 說明,兩張卡合起來才算處置完)。
  • 共同修法(出自 docs/security-report/M10-task-platform.md:153-161):三步,一步都不能省——①查詢與選單兩支都補「你是不是這個專案的成員」(只補一支另一支照樣拿得到同樣資料);②專案編號改必填、沒給就拒絕(不改必填,「空白查詢回整張表」這條路還在);③同型四處一起修(分開修會漏)。已確認所有呼叫這支功能的地方都帶專案或任務編號,改成必填不會影響現有功能。
    • 🔵 守門已經接好線:jedi-task-platform/jedi_task_platform/participant/common/guard.py:32 的 assert_project_manager 是現成的模組級函式(宿主實作是 common/authz/project.py:16,經 participant/domain/ports.py:82 的 IProjectRoleGuard 注入,缺了會拒絕掛載)。⚠️ 但本卡要的是「參與者」不是「manager」——讀取門檻是任一參與者(common/authz/project.py:101 assert_project_participant),port 目前只宣告了 assert_manager,要在 IProjectRoleGuard 加一支 assert_participant 並在宿主 core/plugins/ 對應處接線(見 D-b2-4)。

每件的修法

  • #54 同一家公司裡任何能登入的人,對專案成員查詢送一個連專案編號都不填的空白查詢,一次拿到公司內全部專案的成員名冊(🟡 中)

    • 為什麼要緊:開發環境實測兩千多筆,每筆有專案編號、誰是管理者、成員登入帳號與暱稱——外洩的登入帳號可以拿去做針對性的密碼攻擊,「誰是哪個專案的管理者」是挑社交工程目標用的名單。跨客戶已被資料庫擋住,但同一家公司內這就是一份完整名冊。連「要查哪個專案」都不必知道。
    • 🔴 入口清單:jedi-task-platform/jedi_task_platform/participant/api/routes/project_participant_route.py:39(ProjectParticipantsRoute.post,掛 /project-participants,見 participant/api/routing.py:61;第 46 行 <strong>req_data 直接餵進 service)、:20(ProjectParticipantsMenuRoute.get,掛 /project-participants/menu,見 routing.py:61;project_id/project_uid 兩個參數都預設 None**)。已有守門可照抄:同檔 :55 post/:71 put/:84 delete
  • #55 同公司裡不是這個專案的人,在網址上換一個編號送出查詢,就讀到別人專案在群組層與控制項層的完整人員配置(🟡 中)

    • 為什麼要緊:用連續猜測的編號就能讀走同公司任何一個專案在群組層與控制項層的完整人員配置,而且會順便把專案層的成員一起撈出來——一次請求拿到一個專案三個層級的人員與角色全貌。
    • 🔴 入口清單:jedi-task-platform/jedi_task_platform/participant/api/routes/project_control_participant_route.py:24(ProjectControlParticipantsMenuRoute.get,掛 /project-control-participants/menu,見 routing.py:71;六個參數全預設 None)、:52(ProjectControlParticipantsRoute.post,掛 /project-control-participants,第 57 行 <strong>req_data;⚠️ 就是這支「順便把專案層成員一起撈出來」**——第 58-60 行走 get_control_participants_with_inherits_by_uid)。已有守門可照抄:同檔 :79 post/:101 put/:123 delete;群組層的 control_group_participant_route.py:19 post/:39 put/:59 delete 也是已有守門的樣本
  • #58 同一家公司裡任何能登入的人,對任務指派清單送一個空白查詢,一次拿到公司內全部一萬多筆任務指派紀錄(🟡 中)

    • 為什麼要緊:一次拿到一萬多筆,每筆含專案、任務、被指派人的登入帳號與暱稱、誰是審核者。也可以只帶一個人的編號,反查這個人手上有哪些任務,鎖定目標。
    • 修法:專案編號或任務編號二擇一必填、兩個都沒帶直接拒絕;帶任務編號的由任務反查專案,再檢查成員身分(🔴 不可直接相信呼叫端給的專案編號)。
    • 🔴 入口清單:jedi-task-platform/jedi_task_platform/participant/api/routes/task_assignee_route.py:44(TaskAssigneesRoute.post,掛 /task-assignees,見 routing.py:66;第 48 行 **req_data 直進 get_task_assignees)。同檔已有守門可照抄::20 TaskAssigneeBatchRoute.post、:59 post/:73 put/:95 delete
  • 這張卡的手測總清單:

    1. 專案規劃頁的成員配置:專案層、群組層、控制項層三個層級都要列得出來、指派得動、改得動、刪得掉。
    2. 任務指派清單(含「指派給某人」的反查)要列得出來。
    3. 四支查詢各送一次完全空白的請求,全部要被拒。
    4. 換一個沒參與該專案的帳號,拿別人的專案編號打四支,全部要被擋。
    5. 🔴 回歸重點:規劃頁的成員下拉選單(menu 那兩支)是前端高頻呼叫,補完必須確認下拉還是填得出人——這是最容易改壞的地方(症狀是「有些本來看得到的人突然看不到了」)。

§7

卡 2-5:專案摘要報告五支讀取與稽核輪次選單補參與者檢查

  • 範圍:BE 主專案 app/project_summary_report/、api/project_summary_report/、api/project/routes/audit_round_route.py + 套件 jedi-compliance-audit。worktree 建議 wt-fix-b2-report-round。涵蓋 #56、#57。序列組 B(與卡 2-3 不同時派)。

每件的修法

  • #56 同公司裡不是這個專案的人,直接開專案摘要報告就讀到稽核結論全文與歷史版本(歷史版本常保留後來被刪掉的稽核發現)(🟡 中)

    • 修法:五支讀取功能開頭補上同檔已有的成員檢查;🔴 清單頁不能改必填(它本來就要列出「我看得到的所有報告」)——改成只列出你有參與的專案的報告。
    • 🔵 這張卡最省力:同檔已經有現成的 helper——app/project_summary_report/service/project_summary_report_service.py:35 _require_participant(project_id)、:44 _require_participant_by_report_uid(uid)(內部走 common/authz/project.py:101 assert_project_participant)。寫入側 :111 create/:138 update/:164 delete、以及匯出用的 :95 get_project_summary_report_by_uid(:100 有呼叫)都已經在用了,只有讀取這幾支漏掉。
    • 🔴 入口清單(五支讀取):
      • app/project_summary_report/service/project_summary_report_service.py:53(get_project_summary_reports_and_pager,清單頁——這支要改成「只列我參與的專案」,不是補必填)
      • 同檔 :73(get_project_summary_reports)
      • 同檔 :78(get_project_summary_report,單筆 by uid,History 頁 getReport() 打的就是這支)
      • 同檔 :89(get_project_summary_report_by_id)
      • app/project_summary_report/service/project_summary_report_history_service.py:29(get_project_summary_report_histories)、:36(get_project_summary_report_history_by_uid)——歷史版本這兩支是「常保留後來被刪掉的稽核發現」的出口,優先補;同檔 :47 revert_...(第 57-58 行已有 assert_project_participant)是可照抄的樣本
      • route 層(不改邏輯、只列出對應關係供手測):api/project_summary_report/routes/project_summary_report_route.py:74/:94/:111/:153、api/project_summary_report/routes/project_summary_report_history_route.py:28/:44
    • 功能不能壞:清單頁改成「只列我參與的專案」後,管理者若習慣用清單頁看全局會突然少東西——手測要拿 manager 帳號確認他參與的專案都還在;若決策者要 manager 看全局,那是另一個視圖的事(比照 #61 的裁定,不在本卡)。
  • #57 同公司裡不是這個專案的人,在網址上換成別人的專案編號,就看到那個專案全部稽核輪次的名稱、狀態、起訖日與人員(🟡 中)

    • 修法:開頭先由專案編號查出專案,再呼叫現成的成員檢查;同檔的統計功能一起補。同模組的改專案、刪專案都有檢查,只有這支漏掉。
    • 🔴 入口清單:api/project/routes/audit_round_route.py:74(AuditRoundsListRoute.post,收 project_uid,第 76 行直接 list_rounds(project_uid))→ jedi-compliance-audit/jedi_compliance_audit/app/service/audit_round_app_service.py:211(list_rounds)。單筆 audit_round_route.py:85(AuditRoundRoute.get,by round_uid)一起補。✅ 已補查(2026-09-21):「同檔的統計功能」= AuditRoundFindingsRoute.get(:258,AO 全量判定矩陣,服務層 docstring 自己寫「+ stats 分母」)。但整份檔案的唯讀 GET 共 8 支、全部是同一個形狀、全部零參與者檢查,建議首腦把範圍訂成「這 8 支一起補」而不是挑其中一兩支。

全 8 支唯讀 GET 逐支核對結果(route 檔 api/project/routes/audit_round_route.py 共 32 個 route method、27 個 class;每一支的 decorator 都只有 @doc + @jwt_required() + @require_license("audit") + @inject,零參與者檢查):

# route(file:line) class.method 服務層落點 服務層有沒有守門
1 :74 AuditRoundsListRoute.post(列表,收 project_uid) jedi-compliance-audit/.../audit_round_app_service.py:211 list_rounds ❌ 只有 _resolve_project_id + 撈列表(:212-215),無 _check_role(同檔 create_round「:230」有)
2 :85 AuditRoundRoute.get(單筆,by round_uid) 同檔 :218 get_round ❌ 只有 get_by_uid + 404(:219-222)
3 :163 AuditRoundApRoute.get(AP 內容) BE 主專案 app/flow_control/service/assessment_plan_app_service.py:511 get_ap_for_round ❌ 只有 round/AP 的 404 檢查(:512-519)
4 :234 ApPartiesRoute.get(稽核人員名單) BE 主專案 同檔 :704 list_ap_parties ❌ docstring 明文寫「讀取不卡角色(AP 頁參與者皆可見)」——注意這句話的前提是「AP 頁參與者」,但程式沒有驗他是不是參與者
5 :258 AuditRoundFindingsRoute.get(判定矩陣=本件所指的「統計」) jedi-compliance-audit/.../assessment_result_app_service.py:392 get_findings ❌ _require_round_ar_result(round_uid, writable=False)(:394),該 helper(:120-129)只判 round 存在 + ar_result 存在 + 階段可寫,不判人
6 :333 AuditRoundRisksRoute.get(風險清單) 同檔 :695 list_risks ❌ 同上,writable=False(:696)
7 :419 AuditRoundPoamItemsRoute.get(POA&M 清單) jedi-compliance-audit/.../poam_app_service.py:246 list_items ❌ _require_round_poam(round_uid, writable=False)(:247),該 helper(:59-70)同樣只判 round/poam 存在 + 階段,不判人
8 :429 AuditRoundPoamItemRoute.get(POA&M 單筆詳情) 同檔 :271 get_item ❌ 同上(:277)

三個要一起寫進卡片的要點:

  1. 落點橫跨兩個 repo:#1/#2/#5/#6/#7/#8 六支在套件 jedi-compliance-audit,#3/#4 兩支在 BE 主專案 app/flow_control/service/assessment_plan_app_service.py。開卡時 worktree 要開兩個。
  2. writable=False 是刻意的、不要動——那個參數管的是「結案後仍可檢視」(_require_round_ar_result 與 _require_round_poam 的 docstring 都寫明),與「是不是參與者」是兩件事。補守門要另加一行參與者檢查,不是把 writable 改回 True(改了會讓結案輪次的 POA&M 頁整個打不開)。
  3. 守門要挑對那一支:套件側用 jedi-compliance-audit/jedi_compliance_audit/common/guard.py:48 assert_project_role(精確查詢,簽名 (participant_domain_service, project_id, user_id, allowed_roles, error_code))或 :58 assert_project_role_fallback(含 control→group→project 繼承,簽名 不同:(role_service, project_id, allowed_roles, group_id, control_id, error_code))。⚠️ guard.py:57-60 的 docstring 明文警告兩支簽名不同、照另一支的形狀改會靜默傳錯位置參數——這一句要原樣寫進卡片。讀取類要放行任一角色(含 viewer),所以 allowed_roles 不能沿用寫入路徑的 ("manager",) 預設值。
  • 功能不能壞:稽核輪次選單是專案頁的高頻元件,補完要確認參與者切輪次照樣切得動。

  • 這張卡的手測總清單:

    1. 摘要報告:清單開得出來(只有自己參與的專案)、單筆讀得到、歷史版本列得出來、單一歷史版本開得起來、還原版本做得到、匯出下載得到。
    2. 稽核輪次:選單列得出來、切換得動、單筆詳情開得起來。
    3. 換一個沒參與該專案的帳號,把上面每一支都打一遍,全部要被擋。
    4. manager 帳號看清單頁,確認他參與的專案報告都在(不要改出「本來看得到的突然看不到」)。

§8

卡 2-6:AI 儀表板 26 支查詢加「這支要什麼權限」欄位並在呼叫前檢查,拿掉視同管理員後門

  • 範圍:套件 jedi-ai-dashboard(主體)+ BE di_containers/dashboard_apis/(26 支申報)+ BE app/flow_control/service/project_service.py + 套件 jedi-common(序列化白名單)。worktree 建議 wt-fix-b2-ai-dashboard。涵蓋 #60、#61、#62、#114。
  • 為什麼四件併一張:#61、#62 的報告都明寫「與 #60 同一套修法、同一個位置,不要分開修」;#114 的修法也已在 AI 儀表板那塊裁定。分開修會變成同一個位置改四次。

每件的修法

  • #60 AI 儀表板呼叫的那 26 支查詢完全不問「你能不能看這筆資料」,基層員工因此拿得到整份組織架構、誰有什麼權限、公司有哪些客戶(🟡 中)

    • 為什麼會繞過(一定要懂才修得對):程式分兩層,「入口」負責檢查權限、「做事的那段」負責撈資料。走正常畫面是先過入口;AI 儀表板不走入口,直接叫做事的那段。所以那道檢查根本沒被碰到——不是檢查失效,是這條路不經過檢查。產品定義的「查看使用者」「查看角色」「查看客戶」「查看部門」四個權限,走儀表板這條路全部沒有作用。
    • 修法(已裁定):儀表板本身有一份「可以查什麼」的清單(那 26 支),每一支上面已經寫了它要什麼條件、回什麼資料——只要多寫一欄「這支需要什麼權限」,儀表板在呼叫之前先檢查那一欄。好處:一處補、26 支全涵蓋;做事的那段完全不動(正常畫面行為零影響);新增查詢時天然要填那一欄。
    • 🔴 不能省的一條:沒有標明權限的查詢要直接拒絕,不能預設放行。寫成「有標示就檢查、沒標示就過」的話,日後有人新增一支忘了標,它會悄悄變成不檢查、不會有任何錯誤訊息。
    • 被排除的兩個做法(不要重議):①改那 26 支查詢本身各自檢查——走正常畫面的人會被檢查兩次,且 26 支各改一次、以後新增的還是會漏;②讓 AI 改走正常畫面的入口——等於把整個儀表板重寫,代價與風險太大。
    • 🔴 入口清單:
      • jedi-ai-dashboard/jedi_ai_dashboard/app/registry.py:37(DashboardApi dataclass——目前欄位只有 api_key/module/parameters/required_params/optional_params 等,沒有任何權限欄位,新欄位加在這裡;:71 as_metadata 與 :122 metadata 一併要處理)
      • jedi-ai-dashboard/jedi_ai_dashboard/app/service/data_api_service.py:83(call_api——檢查就插在這裡:第 108-112 行是「①驗證 API 是否存在」、第 115-120 行是「②驗證必要參數」、第 123 行才「③執行」,新的權限檢查放在①之後、②之前或之後皆可,但一定要在③之前)
      • BE 26 支申報(每支都要補那一欄):di_containers/dashboard_apis/auth.py(4 支)、participant.py(7 支)、flow_engine.py(3 支)、oscal.py(2 支)、survey.py(2 支)、device.py、feedback.py、bulletin.py、system_menu.py、system_config.py、module_frame.py、project.py、user_auth_provider.py(各 1 支)
    • 功能不能壞:補完基層員工問儀表板問題時,該被擋的要擋、該回答的要照樣回答。手測要用「一般成員」與「租戶管理員」兩種帳號各問一輪,比對回答範圍的差異符合權限設定。
  • #61 AI 儀表板查專案清單時把呼叫者當成管理員處理,不是專案成員的人也能列出該客戶底下全部稽核專案(🟡 中)

    • 🔴 這是刻意設計、不是寫錯——程式旁邊的說明文字自己寫明,是為了讓舊版儀表板看到整個客戶底下全部專案而特地開的,客戶之間的隔離交給資料庫那層。這不是疏忽,是一個在當時成立的決定。
    • 修法(已裁定):拿掉那道後門。AI 儀表板的定位是「人人可用的查詢工具」,所以它只能看到你自己參與的專案。與 #60 同一套、同一個位置,只是它檢查的不是「你有沒有權限」而是「你是不是這個專案的人」。兩條一起做。
    • 🔴 入口清單:app/flow_control/service/project_service.py:212(get_projects_for_dashboard)——後門就是第 224 行的 is_admin=True,函式 docstring 第 217-219 行自己寫明「對齊 v1 dashboard『看本 tenant 全部專案』…is_admin=True 繞 grc user 參與可見性,tenant 隔離由 RLS 負責」。申報處:di_containers/dashboard_apis/project.py(project.get_projects,⚠️ 該檔 module="grc" 與 api_key 前綴 project. 不一致是既有事實,照抄不動以免動到 AI 的選擇行為)
    • 功能不能壞:🔴 拿掉之後管理者也只看得到自己參與的專案。這個後果報告已寫明並接受——若實際運作上管理者需要看全局,正確做法是另外給他一個管理者專用視圖,不是讓所有人都看得到全部。這件事現在不用決定,等有人反映再處理(不要在本卡順手做)。
  • #62 任何登入帳號叫出 AI 儀表板說一句「列出所有公告」,就拿到整個客戶底下全部公告(含別部門的、停用草稿、未到發布時間的),前三筆還原文送到外部 AI(🟡 中)

    • 修法:🔴 這條不另外修——公告就是那 26 支裡的一支,#60 的修法會一併涵蓋。本卡只要確認公告那一支的申報也補上了權限欄位。另外它呼叫時不指定「要不要按部門過濾」,所以部門過濾永遠不生效,補權限欄位時一起把過濾參數帶對。
    • ⚠️ 不要被「現在會出錯」誤導:這條路目前一叫就出錯(公告功能搬進套件後,儀表板傳過去的東西跟它現在認得的格式對不上),錯誤被上層接住、使用者看到「生成失敗」。但守門缺失一點都沒變——🔴 哪天有人把那個小錯修掉而沒同步補守門,這條路立刻恢復暢通,而修那個小錯看起來會完全像是在做正確的事。
    • 🔴 入口清單:di_containers/dashboard_apis/bulletin.py(bulletin.get_bulletins 的申報;optional_params 已有 org_unit_id 但儀表板不傳)→ jedi-bulletin/jedi_bulletin/app/service/bulletin_service.py:239(get_bulletins(user, <strong>kwargs))。⚠️ 該申報的註解已說明 parameters 第一個 user 現在收的是純 dict**({uid, login_name, org_unit_id})不是 UserContextDTO——改的時候別把它當成身分物件用
  • #114 共用的「資料轉成回覆內容」工具資料上有什麼就吐什麼,呼叫的人忘了先篩選,畫面就會拿到不該看到的內部項目(⚪ 低)

    • 修法(已在 AI 儀表板那塊裁定,分兩步):第一步(本卡做)——那支共用工具本來就有一份「這些不要送」的清單(現在裡面只有三個系統內部項目),把密碼加密鹽值與「是不是超級管理員」旗標加進去,改一行就止血;順便盤一次還有哪些是敏感的一起加。第二步(不在本卡)——改成在資料本身打記號、工具看到記號就跳過,不靠名字比對。
    • 🔴 兩步的差別一定要寫進卡:第一步是名單制——漏登記就外洩(新增敏感欄位時沒人記得補登記,它會被原樣送出且無錯誤訊息);第二步是標記制——漏標記頂多少送一項(出錯方向是「該送的沒送」,會馬上被發現)。所以第一步是止血、不是終點。
    • ⚠️ 已排除的做法:「明列只送哪幾欄」聽起來更安全,但 AI 會從 26 支裡自己挑一支、每支回來的資料長得都不一樣——等於要維護 26 份清單,漏一次就白做。
    • 🔴 入口清單:jedi-common/jedi_common/utils/serialization_util.py:12(_ORM_SKIP_KEYS = frozenset({'metadata', 'registry', 'sa_instance_state'})——就是這三個「系統內部項目」,鹽值與超管旗標加在這裡)→ 同檔 :15 to_serializable(使用處)。⚠️ 此檔在 jedi-common,是全站共用工具,改它要確認其他 consumer 不受影響(加白名單只會少送欄位,風險低,但仍要在卡上點名這是跨套件改動)
    • 功能不能壞:加白名單的方向是「少送」,症狀會是某個畫面某一欄突然空了。手測要掃一遍儀表板各類問題的回答,確認沒有正常欄位被誤殺。
  • 這張卡的手測總清單:

    1. 用「一般成員」帳號問儀表板:「列出所有使用者」「列出所有角色」「列出所有客戶」「列出所有部門」「列出所有公告」「列出所有專案」——該被擋的要擋,且訊息清楚。
    2. 用「租戶管理員」帳號問同樣六題,該回答的要照樣回答得出來。
    3. 🔴 用 manager 帳號問「列出所有專案」,確認只回他參與的專案(#61 拿掉後門的驗證)。
    4. 打開儀表板回應的原始內容,確認沒有密碼加密鹽值、沒有超級管理員旗標(#114 的驗證,要看原始回應不是只看畫面——畫面上只顯示三欄不代表送出去的只有三欄)。
    5. 故意在 di_containers/dashboard_apis/ 挑一支把權限欄位留空,確認它被拒絕而不是放行(這是 #60 那條「不能省」的驗證,測完改回來)。
    6. 儀表板的圖表與表格渲染要全部點過,確認沒有欄位被序列化白名單誤殺。

§9

卡 2-7:查任務詳細資料補「這筆任務屬不屬於網址上那個專案」

  • 範圍:BE 主專案 api/flow_control/routes/job_route.py、app/flow_control/service/job_service.py。worktree 建議 wt-fix-b2-job-detail。涵蓋 #63。

每件的修法

  • #63 任何登入的人在任務列表上看到一筆別人專案的任務編號,就點得開它的完整內容——入口雖然要填專案編號,但填真的、全零、亂打一串字三種都查得到同一筆(🟡 中,⚠️ 部分修)

    • 🔴 已修的是哪道、這張卡要補哪道:已修=改(PUT)與刪(DELETE)兩支補上了管理者守門。未修、本卡要補=①查詢(GET)那支一行未改;②補上的那道守門本身仍不核對「這筆任務到底屬不屬於那個專案」——它只問「你是不是你說的那個專案的管理者」,所以改與刪同樣可以專案填自己的、任務填別人的。本卡兩件都要做:GET 補守門,PUT/DELETE 補核對。
    • 修法:寫入/讀取前都先由任務反查它真正屬於哪個專案,跟網址上填進來的專案比對,不一致就拒絕(回「找不到」)。這就是 M10 第 6 條講的「驗了人、沒驗物」的同型修法——同一支檔案的某些方法已經是這個寫法,照抄即可。
    • 🔴 入口清單:
      • api/flow_control/routes/job_route.py:82(ProjectJobDetailResource.get——第 88 行 job_service.get_job(job_uid) 收了 project_uid 但整支沒用它,這是主要缺口)
      • api/flow_control/routes/job_route.py:104(同類別 put,第 118-127 行有傳 project_uid 進 update_job)
      • api/flow_control/routes/job_route.py:147(同類別 delete)
      • app/flow_control/service/job_service.py:140(get_job(uid)——簽名裡根本沒有 project_uid,要加參數並核對)
      • app/flow_control/service/job_service.py:266(update_job,第 287 行 self._require_manager(project_uid)——只驗人、沒驗物)
      • app/flow_control/service/job_service.py:211(delete_job(uid, project_uid=None),第 212 行同病;⚠️ project_uid 預設 None 代表不傳就完全不檢查)
      • app/flow_control/service/job_service.py:126(_require_manager 本體,第 132-137 行;⚠️ 第 132 行 if self._participant_role_service is None 時的行為要確認是不是靜默放行——若是,那是另一個缺口,回寫時點名)
      • 同檔 :217 delete_job_from_diagram(uid)(完全沒有 project_uid,但已實查:唯一呼叫端是 app/flow_control/service/workflow_xml_sync_service.py:124,該處的 to_delete 是由流程圖存檔的三方 diff 算出來的、不是使用者直接指定任務編號,守門走存檔端點那道(該檔第 122-123 行的註解已說明)——本卡不動它,但回寫時要點名「存檔端點那道守門本身是否核對專案」由 FR-112 那條線負責)
    • 功能不能壞:任務詳情頁是最高頻的畫面之一。手測要確認參與者開自己專案的任務照樣開得起來、編輯得動、刪得掉;ProjectJobListResource.post:52 與 MyJobListResource.post:231 列出來的任務,點進去每一筆都要能開。
  • 這張卡的手測總清單:

    1. 自己專案的任務:從任務列表點進詳情、編輯後儲存、刪除,三個動作都要成功。
    2. 「我的任務」清單點進去每一筆都要開得起來(跨專案的任務也要能開——只要你是那個專案的參與者)。
    3. 🔴 交叉打三種:網址專案填自己的、任務編號填別人專案的 → GET/PUT/DELETE 三支都要拒絕;專案編號填全零 → 拒絕;專案編號亂打一串字 → 拒絕(這三種現在都查得到同一筆)。
    4. 非參與者拿真實的專案+任務編號打 GET → 要拒絕。

§10

卡 2-8:合規資源庫三個入口補歸屬檢查、建立入口補權限與用量上限

  • 範圍:BE 主專案 app/oscal/service/、api/oscal/routes/、app/module_frame/service/。worktree 建議 wt-fix-b2-resource-library。涵蓋 #46、#65。序列組 C(2-8 先做,2-9 接著在同一個 worktree)。
  • 為什麼兩件併一張:都在 api/oscal/routes/resource_library_route.py 與 app/oscal/service/resource_library_app_service.py 這一組檔上,而且 #65 的「三個入口要一起補」與 #46 的「三條路打同一塊地」指的是同一組入口(手動編輯、Excel 匯入、Word 匯入)。分兩張卡會互撞。

每件的修法

  • #46 子公司的管理員在「資源庫」改範本時,可以把母公司分享下來的範本的適用控制項清單整個改掉或清空,底下實作記錄一併刪除——之後每個用這份範本開專案的人,以為在稽核範圍內的項目其實沒被評估(涵蓋掃描總表 #120 + #67 + M10-11)

    • 修法(決策者補裁):後端在「改控制項清單」之前先確認這份範本是自己客戶的——把現有那道檢查從「只在改分享身分時」搬到「只要有寫入就檢查」。明確擋兩件事:①身分是原廠公版(SYSTEM)→ 直接拒絕(公版只允許平台管理員);②範本所屬公司跟呼叫者不同 → 直接拒絕。判斷方式照抄 common/authz/sharing.py 既有的 assert_scope_writable——🔵 那支警衛本來就是專門擋這件事的、寫得對,只是站錯位置。
    • 🔴 開卡務必寫明的兩句:
      • ①同一條路徑上的 _init_ao_workflows(處理新增控制項那半邊)要補同一道檢查——只補一半等於沒補。
      • ②不要想著靠資料庫補——真正被改被刪的三張表(oscal.profile_imports/oscal.ssp_implemented_requirements/oscal.catalog_control_parts)根本沒設客戶隔離、連客戶欄位都沒有,而且本來就設計成「跟著範本走」;補隔離是第二道保險、不是這條的解法(要補歸 FR-094「要先改結構」那一級)。
    • 為什麼資料庫沒擋下來(兩層防護各自獨立失效,修的時候要懂):①資料庫那層的隔離只設在範本主表 compliance.module_frames,而這次的請求只送控制項清單、沒動到那張表任何欄位,程式根本沒對它發出更新指令——沒發指令,規則就沒機會觸發(#67 當初標「可信度中」的那個未實測疑點,O2 已經回答:不是拋錯也不是靜默 0 列,是連觸發機會都沒有)。②程式那層其實已經有一支對的警衛,但它只站在「使用者要改範本的分享身分」那個 if 裡面,只送控制項清單、不送分享身分就完全繞過。白話講就是「讀得到」被當成了「改得動」的通行證。
    • 🔴 三條路一起修(O3a/O4 補的):本項是「編範本的控制項清單」這個入口、掃描總表 #123 是「Excel 匯入覆寫範本」、#125 是「Word 匯入覆寫範本」——三條都寫同一批範本資料、三條都是「有人守了一半」(本項是警衛站錯位置、123/125 是同一個判斷式裡別條守了這條沒守)。只補其中一條,另外兩條照樣進得去。 ⚠️ #123/#125 本身歸哪批 開卡前首腦確認(若不在第 2 批,本卡至少要在回寫時點名它們必須同一輪修完)。
    • 🔴 入口清單:
      • app/oscal/service/resource_library_app_service.py:364(update_applicable_controls(module_frame_uid, include_controls, user)——第 371 行 @transaction 之後第 372 行直接 get_session()、第 373 行下 raw SQL 撈 compliance.module_frames,從頭到尾沒問一句「這份範本是不是你家的」)
      • app/oscal/service/resource_library_app_service.py:412(上面那支的呼叫入口)
      • common/authz/sharing.py 的 assert_scope_writable(現成的正確警衛,照抄它的判斷)
      • app/module_frame/service/module_frame_service.py:110-118(繞過點——警衛就站在這個 if 裡面)
      • app/oscal/service/resource_library_app_service.py:220(_init_ao_workflows,新增控制項那半邊;被 :419 呼叫——就在 update_applicable_controls 這條路徑上,另一個呼叫點 :497 是建立路徑)
      • Excel 匯入覆寫路徑(掃描總表 #123):api/oscal/routes/ssp/ssp_excel_import_route.py:103(SspExcelImportConfirmRoute.post,掛 /ssp-excel-import/<parse_uid>/confirm,見 api/oscal/__init__.py:91)→ app/oscal/service/ssp_excel_import_app_service.py:441(_confirm_update_module_frame,Model A 既有資源庫、replace 語意——第 449 行 clear_ssp_body 先清舊 body 再整批重建,這就是「覆寫範本」那條路)
      • Word 匯入覆寫路徑(掃描總表 #125):app/oscal/service/ssp_docx_import_app_service.py:304(confirm_import,第 339 行 module_frame_uid = effective_source_uid 是 Model A 既有資源庫那條、第 347-352 行是 Model B 新建那條);route 在 api/oscal/routes/ssp/ssp_docx_import_route.py:96(SspDocxImportConfirmRoute.post,掛 /ssp-docx-import/<parse_uid>/confirm,見 api/oscal/__init__.py:99)
    • 功能不能壞:🔴 「改壞」的長相是「本來編得動的範本突然編不動了」。手測要用「自己客戶建的範本」確認控制項清單照樣編得動(加、減都要試),再拿「原廠公版」與「母公司分享下來的範本」各試一次,兩者都要被拒。
  • #65 建立合規資源庫的入口完全沒有權限檢查、只驗有沒有登入,而隔壁做同樣事情的入口是有要求權限的(中等)

    • 修法:補功能權限(照 ModuleFrameRoute.post 的寫法)+用量上限。兩件事都要,因為每建一次就複製一整套框架控制項目錄、每個查核項目各產一張流程範本——這不是一筆小寫入,而系統沒裝任何流量限制,重複呼叫就是用很小的代價把資料庫灌大。
    • 這條不是跨客戶問題:建立時一定會綁呼叫者自己的公司、碰不到別人家的資料(檔案裡那段註解說的這一半是對的),資料庫隔離本來就不負責回答「你有沒有資格建」。缺的是功能權限與用量上限這兩件註解沒回答的事。另外「公司內部任何角色(含唯讀帳號、一般稽核員)都能建範本庫」這件事從沒被決定過(見 D-b2-5)。
    • 🔴 開卡務必寫明:Excel 匯入、Word 匯入那兩條路也會建資源庫,三個入口要一起補,只補這一支還有其他路進得來。
    • 🔴 入口清單:
      • api/oscal/routes/resource_library_route.py:43(ResourceLibraryCreateRoute.post,第 40-42 行只有 @doc + @jwt_required() + @inject;第 44-47 行的註解正是那段「說對了一半」的說明——它解釋了為什麼不會碰別人家資料,但沒回答「你有沒有資格建」與「能建幾次」)。⚠️ 同檔 :28(ResourceLibrariesRoute.post,列表查詢)同樣只有 @jwt_required(),開卡前確認它是不是也該守
      • api/module_frame/routes/module_frame_route.py:58(ModuleFrameRoute.post,正確範本——第 56 行 @require_capability("module-frame.create") 就是要照抄的那一行)
      • Excel 匯入建庫入口:api/oscal/routes/ssp/ssp_excel_import_route.py:103(SspExcelImportConfirmRoute.post)→ app/oscal/service/ssp_excel_import_app_service.py:544(_confirm_create_resource_library,第 556 行呼叫 create_resource_library;由 :344 分派)
      • Word 匯入建庫入口:app/oscal/service/ssp_docx_import_app_service.py:347(confirm_import 內的 Model B preselect-new 那條,直接呼叫 create_resource_library)
      • 🔵 三條路的匯流點:app/oscal/service/resource_library_app_service.py:427(create_resource_library)——⚠️ 權限要補在三個入口、不是這裡(這支被匯入流程內部呼叫,補在這裡會擋掉正常匯入;但可在這裡加用量計數)
      • 🔵 參考:發布(Publish)已限 platform-admin,公版治理不受影響——補權限時不要動到它
    • 功能不能壞:手測要確認該建得起來的人照樣建得起來(三個入口各建一次),以及沒權限的帳號三個入口都被擋。用量上限要挑一個不會擋住正常客戶的值(原則十一:安全措施不可以擋住正常客戶——上限要寬到正常使用碰不到,見 D-b2-6)。
  • 這張卡的手測總清單:

    1. 自己客戶建的範本:控制項清單加控制項、減控制項都要成功,底下的實作記錄變化符合預期。
    2. 原廠公版範本:用租戶管理員改控制項清單 → 要被拒;用平台管理員改 → 要成功。
    3. 母公司分享下來的範本:用子公司管理員改控制項清單 → 要被拒。
    4. 三個建立入口(手動新增、Excel 匯入、Word 匯入)各建一次資源庫 → 有權限的要成功、沒權限的三個都要被擋。
    5. 連續呼叫建立入口多次 → 用量上限要生效且訊息清楚(不是 500)。
    6. 🔴 Excel 匯入與 Word 匯入覆寫既有範本那條路:拿別人家的範本試,要被拒(這是「三條路打同一塊地」的驗證)。

§11

卡 2-9:刪除佐證文件與文件庫文件時,核對「要刪的那筆屬不屬於你管的那份計畫」

  • 範圍:BE 主專案 app/oscal/service/。worktree 建議沿用 wt-fix-b2-resource-library(同組 C,2-8 做完接著做)。涵蓋 #64(掃描總表 #118 + #119)。

每件的修法

  • #64 刪除控制項底下的佐證文件、刪除文件庫文件時,系統檢查的是「你管不管網址上那份計畫」,刪掉的卻是「你另外給的那份文件編號」,兩者從不核對(中等;後者還會連帶刪關聯、可能把實體檔一起永久銷毀)

    • 修法:要刪的那一筆先撈出來比對歸屬,不符就回「找不到」。🔵 同 repo 已有一支寫對的可照抄:app/module_frame/service/module_frame_reference_document_service.py:147-151——它先 get_by_uid(doc_uid) 撈出來,再比對 context_type 與 context_id,不符就 raise NotFound。照抄這個形狀。
    • 出事會怎樣:任何管理一個專案的人,可以刪掉任何其他專案、任何其他公司的佐證文件連結;被害者只會看到文件從控制項底下無聲消失,沒有錯誤訊息,紀錄上也查不到是誰做的。
    • 要先有什麼才打得到:①任何登入帳號 ②自己開一個專案(開的人自動變該專案負責人,不需任何人核准)③知道目標文件編號(當該專案的唯讀成員就看得到;#119 那條有一支查詢任何登入者都打得到、不檢查權限,編號更容易拿)。
    • 為什麼資料庫沒擋下來:存這些文件的那張表沒有客戶欄位、也沒設資料庫隔離,上層放行之後底下沒有第二道關卡。
    • 🔴 入口清單:
      • app/oscal/service/ssp_control_implementation_service.py:888-890(delete_reference_document(ssp_uid, doc_uid)——整個方法只有兩行:第 889 行 self._perm.require_manager(ssp_uid)(連回傳值都沒接)、第 890 行 self._ref_doc_ds.delete_by_uid(doc_uid) 直接刪)
      • 🔵 同檔上方的 add_reference_documents() 寫對了(有接 ctx = ... 並用 ctx.ssp.id)——同一個檔案裡兩支方法一嚴一鬆,是漏掉不是設計;另外 :895 附近的 list_ao_reference_documents 也有 ctx = self._perm.require_participant(ssp_uid) 可參考
      • app/oscal/service/ssp_document_pool_service.py:123-129(delete_from_pool——第 118 行 ctx = self._perm.require_manager(ssp_uid) 有守門但守錯對象;第 123 行 list_pool(ctx.ssp.id) 看起來像在核對歸屬,但那次查找的產出只有檔案編號(判斷要不要刪實體檔),找不到就填 None 繼續往下跑、不會擋;第 125 行 self._ref_doc_ds.get_by_uid(doc_uid) 已經把真正那筆紀錄抓在手上了,卻沒問一句「它屬於哪份計畫」;第 129 行照樣 delete_by_uid(doc_uid)。整段從頭到尾沒有一個 if 擋在刪除前面。)
      • app/module_frame/service/module_frame_reference_document_service.py:147-151(照抄的正確樣本)
    • 🔴 #119 修時務必寫明:檔案編號要從「已驗證過歸屬的那一筆」上取,不要再走另一條查詢——否則兩邊哪天又會各走各的。(現在 file_uid 從 list_pool 取、file_id 從 get_by_uid 取,兩條路。)
    • 功能不能壞:#119 會連帶刪掉該文件底下所有「掛在哪些控制項/查核項目」的關聯,而引用計數 count_by_file_id 算在受害者那個檔案上,算完判定沒人在用實體檔案就被永久刪除——所以手測要確認正常刪除的連帶行為完全沒變:自己的文件刪掉後,關聯確實清掉、還有別版在用的實體檔沒有被誤刪。
    • 零引用查證:不適用(本件不刪程式碼)。
  • 這張卡的手測總清單:

    1. 自己管理的 SSP:控制項底下的佐證文件刪得掉;文件庫的文件刪得掉,關聯一併清掉。
    2. 「還有別版在用的實體檔」:刪掉本版紀錄後,實體檔要還在(引用計數的既有行為不能被改壞)。
    3. 「沒有別版在用的實體檔」:刪掉後實體檔確實被清掉。
    4. 🔴 交叉打:自己開一個新專案(自動當負責人),拿別人專案/別家公司的文件編號打這兩支刪除 → 都要回「找不到」且文件還在。
    5. 唯讀成員看得到文件編號的情況下,拿那個編號打刪除 → 要被拒。

§12

卡 2-10:弱點檢測「測試連線」補權限,八支讀取功能補權限並修掉誤導的說明文字

  • 範圍:套件 jedi-detection。worktree 建議 wt-fix-b2-detection。涵蓋 #4、#47、#113。

每件的修法

  • #4 弱點檢測設定頁的「測試連線」按鈕沒檢查權限,任何登入者按一下,系統就把整家公司的維運帳密解密、送到按的人自己指定的那台主機(🟠 高)

    • 為什麼要緊:一次拿走整家公司的維運帳密——送出去的是連主機用的 SSH/Windows 遠端管理密碼,以及三套掃描工具的存取權杖。⚠️ 既然第 6 條裁定不限制連到哪裡,這道權限檢查就是唯一的防線。
    • 這是漏掉不是刻意:同一支檔案裡的新增、修改、重置每一支都有檢查權限,真正的缺口只有「測試連線」這一支(同樣沒掛的「查引用次數」是純查詢、不解密、不對外連線,沒掛是合理的——不要順手去改它)。
    • 修法:補一行權限宣告,同一支檔案裡有三處現成寫法可以照抄。
    • 🔴 入口清單:jedi-detection/jedi_detection/api/routes/detection_tool_route.py:126(TenantDetectionToolConfigTestConnectionRoute.post——第 125 行只有 @require_license("plugin"),缺 @capability_required;第 133 行 test_connection(...) 就把帳密解密送出去了)。同檔三處現成寫法::67(TenantDetectionToolConfigsRoute.post,@capability_required(lambda rt: rt.config.plugin_update_capability))、:88(TenantDetectionToolConfigDetailRoute.put)、:105(TenantDetectionToolConfigResetRoute.post)。⚠️ 不要動 :143(TenantDetectionToolConfigRefCountRoute.get,查引用次數,沒掛是合理的)
    • 功能不能壞:手測要確認有權限的管理員按「測試連線」照樣連得成(含帶 host/credential_group/agent_uid 三種參數形式)。
  • #47 同一家客戶裡任何成員只要知道任務編號,就讀得到別人專案的掃描執行紀錄(🟡 中)

    • 修法:補上同一個服務裡其他八處已經在用的那行檢查;🔴 任務不存在時回報找不到,不要回空清單。
    • 同一個服務裡另外八個吃任務編號的功能每一個都先做了這道檢查,只有這一支漏掉。
    • 🔴 入口清單:jedi-detection/jedi_detection/app/service/detection_orchestration_service.py:1649(list_executions(job_uid, tenant_id)——第 1668 行直接 list_by_job_execution_ordered(job_uid, ...),沒有 assert_project_participant)。八處現成寫法可照抄:同檔 :196、:871、:925、:1007、:1032、:1097、:1127、:1470(每處都是 self._wf_svc.assert_project_participant(job.workflow_execution_id);第 1085 行的註解還寫明了這道守門的設計意圖)
    • 功能不能壞:掃描歷程是任務詳情頁的區塊,補完要確認參與者看自己任務的掃描紀錄照樣看得到(含 legacy 那種 group_uid 為 NULL、以「單筆自成一組」形狀回傳的紀錄)。
  • #113 租戶管理員在權限矩陣把某個權限取消勾選,前端選單確實藏起來了,但後端八支讀取功能照樣回全部資料——那一格勾選其實不生效,管理員被騙了(⚪ 低)

    • ⚠️ 這條原本評中風險,重新定性後調降為低。原本的寫法會讓人以為「任何登入者都拿得到」,但這個功能本來就只開給租戶管理員使用,拿得到的人本來就拿得到。真正的問題是管理員被騙了——他在權限矩陣取消勾選、前端選單確實藏起來,但該帳號直接對系統送請求,後端八支讀取功能一支都沒檢查、照樣回全部。也就是那一格勾選其實不生效。
    • 修法:八支讀取功能補掛權限檢查,並保留那顆權限點以維持未來細分的彈性(例如日後讓稽核人員「看得到但改不了」)。
    • 🔴 補的時候要一併把程式裡那句「讀取端刻意不掛」的說明文字改掉,否則下一個人看到又會當成設計、把它拿掉。(具體位置:jedi-detection/jedi_detection/api/routes/detection_profile_route.py:290「GET 不設 capability,與其他讀取端一致」、同檔 :540「讀狀態的 GET 不設 capability,與其他讀取端一致」;另 api/routes/detection_profile_taxonomy_route.py:7 有一句「限制讀取等於讓分類功能對租戶失效」——⚠️ 那一句講的是分類下拉、屬另一回事,判斷後再決定要不要動。此條與 CLAUDE.md「檔案內註解只寫為什麼與陷阱」那條鐵則方向一致:過期脈絡會帶偏下一個人。)
    • 🔴 這個功能有公版機制,不要改壞——看得到哪些資料是三種用「或」連起來(公版人人看得到 + 自己建的 + 上游分享下來的),補權限是加在「能不能進這個功能」那一層,不動「看得到哪些」那一層。
    • 🔴 入口清單(八支讀取,全在 jedi-detection/jedi_detection/api/routes/detection_profile_route.py):
      • :135(DetectionProfileListRoute.post,基準清單)
      • :163(DetectionProfileMenuRoute.get,下拉選單)
      • :211(DetectionProfileDetailRoute.get,單筆詳情)
      • :274(DetectionProfileVersionsRoute.get,版本歷史)
      • :303(DetectionProfileUsageRoute.get,主檔使用狀況)
      • :328(DetectionProfileVersionUsageRoute.get,版本使用狀況)
      • :507(DetectionProfileVersionControlsRoute.get,規則清單)
      • :527(DetectionProfileVersionExtractionRoute.get,抽取狀態)
      • 現成寫法可照抄:同檔 :54-56 的三個 decorator(_profile_create/_profile_delete/_profile_update,🔴 是 lambda 延遲求值——route class 在 import 期定義、那時還讀不到 config,新增讀取用的 decorator 要照同樣形狀寫),掛在 :184 post/:230 put/:258 delete 等處
    • 🔴 補完要驗四件事(報告明列):①管理員看得到公版 ②看得到自己建的 ③下游看得到上游分享的 ④沒勾權限的被擋掉。「改壞」的長相是「有些本來看得到的人突然看不到了」。
    • ⚠️ 與設備清冊那塊同形,要一起裁——見 D-b2-7。
  • 這張卡的手測總清單:

    1. 有權限的管理員:「測試連線」按得成(三種參數形式各試一次);八支讀取功能全部正常(基準清單、下拉、單筆、版本歷史、主檔使用狀況、版本使用狀況、規則清單、抽取狀態)。
    2. 🔴 公版機制四驗:管理員看得到公版、看得到自己建的、下游看得到上游分享的、沒勾權限的被擋掉。
    3. 沒有 plugin 權限的帳號:按「測試連線」要被擋;八支讀取要被擋。
    4. 任務的掃描執行紀錄:參與者看得到;非參與者拿任務編號打 → 要回「找不到」;任務不存在的編號 → 要回找不到,不是空清單。
    5. 「查引用次數」那支要維持原狀能用(沒被順手改掉)。
    6. 分類下拉(detection-profile-taxonomies)要照樣列得出來(那支刻意不守是對的)。

§13

決策者要裁的事(彙整本批的 D)

  • D-b2-1:孤兒檔要不要一併擋掉?(卡 2-1) 卡 2-1 的修法規定「三張下游表都認不出來的檔一律拒絕」,但全庫 2,821 筆裡一定有一批認不出來的(歷史遺留、系統產生的轉檔 PDF)。我的建議:runner 先在 DEV 用唯讀 SQL 數出「認不出來的有幾筆、長什麼樣」再回報,數量少就照原則直接擋(fail-closed 才安全),數量可觀就先擋、同時給一個「平台管理員可下載」的例外口,避免客服接到「舊檔下載不了」。

  • D-b2-2:刪除改成可復原,範圍到哪裡?(卡 2-1) 報告要求刪除改成「可復原的刪除」,但沒說復原期多長、誰能復原、要不要做畫面。我的建議:本卡只做到「資料層軟刪、實體檔保留」+平台管理員可用 SQL 撈回,復原畫面另開需求——否則這張卡會從補守門變成做新功能。

  • D-b2-3:流程範本到底要不要權限?(卡 2-3 的 #115) 報告給了兩條路:補權限(與前端選單一致),或產品決定「人人可看」就把權限與選單綁定一起拿掉。我的建議:補權限。流程範本含階段設計、角色指派、判斷條件,是稽核流程的骨架,不該人人可讀;而且「前端藏、後端開」本身就是要消除的不一致。

  • D-b2-4:IProjectRoleGuard 要加「參與者」那一支嗎?(卡 2-4) jedi-task-platform 的 port 目前只宣告 assert_manager,但本批讀取門檻是「任一參與者」。我的建議:加一支 assert_participant 到 port,宿主接 common/authz/project.py:101 的 assert_project_participant。這是套件契約異動,照 CLAUDE.md 外部套件規範要決策者點頭。

  • D-b2-5:資源庫該開放給哪些角色建?(卡 2-8 的 #65) 報告點明「公司內部任何角色(含唯讀帳號、一般稽核員)都能建範本庫,這件事該不該開放從沒被決定過」。我的建議:比照隔壁 ModuleFrameRoute.post 的權限點(範本編輯權),亦即租戶管理員與有範本編輯權的角色可建、唯讀角色不可建。

  • D-b2-6:資源庫建立的用量上限訂多少?(卡 2-8 的 #65) 每建一次就複製一整套控制項目錄與流程範本,需要上限;但原則十一說安全措施不可以擋住正常客戶。我的建議:上限訂在「一小時 10 次/每租戶」這種寬到正常使用碰不到的量級,超過回明確訊息(不是 500),並記一筆稽核事件。具體數字請決策者定。

  • D-b2-7:弱點檢測的權限矩陣與設備清冊要一起裁嗎?(卡 2-10 的 #113) 報告標「與設備清冊那塊同形,要一起裁」。我的建議:兩處一起裁、方向一致(都是「當初刻意放寬、現在重新確認」,比照 #61 的處理),但分兩張卡做——設備清冊那條不在第 2 批,本卡只做弱點檢測側,回寫時點名兩處方向已對齊。

  • D-b2-8:掃描總表 #123/#125(Excel/Word 匯入覆寫範本)歸哪批?(卡 2-8) O3a/O4 已判定它們與 #46 是「同一塊地被三條路打穿,必須三條一起修」,但 #123/#125 本身在 SUMMARY 的批次歸屬要確認。我的建議:若不在第 2 批,把它們併進卡 2-8 一起做——不然補完 #46 另外兩條照樣進得去,等於白做一輪。