這批修的是「誰有資格動這筆東西」這個判斷本身——不是補一行呼叫,而是把判斷規則定下來、把判斷的入口做出來。第 2 批那 29 件裡有一大票是「補一道守門呼叫」,而它們要呼叫的那道守門就在這批裡;所以這批必須先做完、合回主線、套件發版,第 2 批才有東西可以接。現在就能開,沒有等任何前置。13 件分成兩種形狀:①「任務狀態操作」這一組(#37)是一條守門被十支端點共吃,跨 BE 與 jedi-detection 兩個 repo,是整批的關鍵路徑;②其餘十二件是各套件自己的「擁有者/角色」判斷(問卷、公告、設備清冊、意見回饋、專案成員),彼此互不相干,可以同時派。
| 卡 | 卡名(做什麼) | 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 之前) |
序列組說明:
~/Projects/Billows/Audit-Manager/compliance-manager-be),worktree 建議 wt-fix-b1-flow-engine-guard,涵蓋 #37 的 BE 半#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)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——它走的是系統發起的階段回退,手測要包含這一條,不能只測畫面上的退回鍵。known_project_id 旁路沒被弄壞這張卡的手測總清單:上面七項全部親手點過;第 6、7 項最容易漏,它們是「沒有弄壞既有東西」的證據。
需決策者先裁的:D-b1-1(見本頁末)
交接給 1-2 的東西:新守門的方法名與簽名要寫進 Notion 卡回寫裡(1-2 的 runner 看不到這邊的 context,只靠 commit message 與卡上的回寫)。
~/Projects/Jedicogy/module/jedi-python-package,子目錄 jedi-detection,worktree 建議 wt-fix-b1-detection,涵蓋 #37 的套件半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 — 整組刪:1470di_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 項)——這一項是「沒把系統代勞的路擋掉」的唯一證據,不可省。TASK_STATUS_PROCESSING 這個字面值是套件與宿主的凍結契約(domain/ports.py:238-245),不要碰pyproject.toml 把 jedi-detection 的 pin 改 path 形式,改動不 commit);不發版、不推 Nexus。發版是第 1 批全部合完之後的獨立動作。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 某個專案的管理者在寫入「群組層」/「控制項層」成員時,填進來的群組或控制項可能根本不屬於那個專案,系統不比對就寫進去
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_idproject_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,專案規劃頁):
enforce_role=False 旁路沒壞)紀律補充:poetry path dependency 開發、path 改動不 commit、不發版。
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 拿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)— 門檻不是零:要有問卷修改/刪除能力點才按得動,但那道門只問「你有沒有改問卷的權力」,不問「這則留言是不是你寫的」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,問卷填答頁):
需決策者先裁的:無裁決項,但補查結果改變了開卡形狀——survey_discussions 確認無軟刪除欄位,故本卡依原定規則拆成兩張:卡 1-4a(套件側:#38 歸屬比對 + #39 的「改」那一半 + 讀取端呈現)、卡 1-4b(BE scripts/sql/ migration:加軟刪除欄位,只套 DEV,出貨基線待重產)。1-4b 要先套上 DEV,1-4a 的軟刪除那一半才驗得起來。
紀律補充:poetry path dependency、不發版。
jedi-bulletin,worktree 建議 wt-fix-b1-bulletin,涵蓋 #42、#111#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_capabilityjedi-bulletin/jedi_bulletin/api/routes/bulletin_route.py:77-82 — DELETE,守 bulletin_delete_capabilitydelete_bulletin() 的先清橋表再刪主表的順序不可反(bulletin_org_units.bulletin_id 的外鍵沒有 CASCADE,順序反了會撞 ForeignKeyViolation 回 409)。新加的比對要放在這兩步之前。#111 用網址直接開一則公告時系統什麼都不檢查(不看部門、不看是不是草稿、不看有沒有到發布時間),所以調部門之後舊部門的公告用舊網址照樣打得開;公告列表碰到「沒有被分配部門」的帳號就整個不過濾、全部給看(含草稿、含過期),而「要不要過濾」這個開關還是呼叫端自己在請求裡傳的
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_requiredauth 這個開關是呼叫端傳的:FE 只有 compliance-manager-fe/src/views/bulletins/BulletinManage.vue:82 一處在傳 auth: true(首腦已 grep 全 FE 確認只有這一處)。改成伺服器判定之後,這一行要跟著拿掉。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 部分可先做):
需決策者先裁的:D-b1-2(見本頁末)
紀律補充:poetry path dependency、不發版。若 D-b1-2 裁定要加欄位,migration 與 FE 各另開一張小卡,不要讓這張卡跨三個 repo。
jedi-asset,worktree 建議 wt-fix-b1-asset,涵蓋 #43 的守門半、#112device.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_* 常數。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 → 覆蓋回去變成啟用):136,守 information_system_delete_capability)行為不變;正常修改(不碰啟用狀態)行為不變。這張卡的手測總清單:
SELECT 一下稽核人員角色有沒有那兩顆能力點)is_active=false → 要被拒is_active 的修改請求給一個已停用的系統 → 那個系統不可以被重新啟用驗收打折要當場講明:如果 DEV 上湊不出「自訂過角色的既有客戶」這個情境(要自己手動建角色、拿掉能力點來模擬),回報裡要寫明這是模擬的,不可報成「已驗證既有客戶不受影響」。
紀律補充:poetry path dependency、不發版。
jedi-issue,worktree 建議 wt-fix-b1-issue,涵蓋 #45capability_required),不要另外寫一套。這一層不卡任何決策——十顆能力點在資料庫裡全部已定義好、也已綁在前端選單上,缺的純粹是後端那一段接線。@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(標籤下拉,內容是系統預設分類選項、不是客戶資料,不必守)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 一個欄位,要照同一形狀補其餘五個。compliance-manager-fe/src/views/feedback/FeedbackManageTabView.vue:65 用 :view="permissions.length === 1" 這個前端算出來的旗標切換唯讀模式,而 SystemFeedbackManage.vue:73 兩種模式打的都是同一個 API.FEEDBACKS。後端只看到「有人要清單」,就把整個客戶的回饋全部回傳。feedback.update → 該角色連自己的都改不動(403,不是 500)feedback-view.read 的命名多了 -view(對應唯讀檢視那張頁 system-feedback-view),字串凍結不要順手統一。wt-fix-b1-flow-template-validate,涵蓋 #40require_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),沒有節點數/連線數上限docs/security-report/M06-flow-engine.md 的 {#limits} 段)。XML 字串長度上限報告沒給數字 → 卡上寫「runner 依 100 個節點的實際 XML 大小推一個寬鬆值並在 commit message 寫理由」,不要憑空定。{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 裡也不會擋到任何既有資料。 查到的實際數字:
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)的十倍推出來的。scripts/sql/,worktree 建議 wt-fix-b1-migrations,涵蓋 #44、#43 的權限資料半#43(權限資料半)補上設備/資訊系統讀取權限給唯一沒有它的那個角色
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 兩條選單路由(不用改)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 出那一列確認。:49)過濾,不吃 insert policy,所以放寬 insert 不會讓別人看到不該看的。-- Date:,每個語句加日期註解GRANT ... TO cm_app;但要確認現有 GRANT 不受影響INSERT public.schema_migrationspsql --single-transaction -v ON_ERROR_STOP=1 -f <檔> 用 cmmgr 套(cm_app 受 RLS 擋系統層寫入會 silent fail)localhost:5432,連線資訊查 .env)。STG/POC 一律不碰,那是上版動作不是開發動作phase=active/envs=* 的 migration → 回寫時要明講「出貨基線待重產」。重產要寫 188 基線庫,屬決策者裁示,runner 只回報不自己動。.env」)SELECT 確認那個「稽核人員」角色已經有 device.read 與 information-system.readSELECT 出那一列 → tenant_id 有值、org_unit_id 可以是空問題:現在有一道守門叫「是不是這個專案的參與者」,任何角色都算通過。這道守門同時被三組功能吃:①完成/退回任務(本批要收嚴)②弱點掃描八支改狀態端點(本批要收嚴)③佐證文件的上傳/更新/刪除(角色規則表也說要收嚴,但不在本批)。直接把那支收嚴,第三組會跟著改;新開一支則要在十處換呼叫。
我的建議:新開一支嚴守門(例如「是不是這個任務的負責人或這個專案的管理者」),既有那支不動。理由是佐證那三支的手測與驗收沒有排在這一批,順手改掉等於做了一件沒人驗的變更;而角色規則表本來就要求佐證也收嚴,留給後續一棒照同一支新守門接上去,是加一行呼叫的事。
問題:裁定是「發送對象改成必選——全公司或指定部門,兩者擇一,都沒選就不能發布」。但資料庫裡已經有一批公告的發送對象是空的(那正是這條的成因)。這批舊資料要怎麼處理?留空會讓它們讀取時全被擋掉(等於廢掉那些公告),一律當成「全公司」會讓原本只想發給某部門的變成全公司可見。而且要做到「分得出刻意選全公司」與「忘了選」,資料上可能需要多一個欄位(不能靠「橋表空集合」,那正是現在分不出來的原因)。
我的建議:多一個「發送範圍」欄位(全公司/指定部門),舊資料一律標成「全公司」並在上線公告裡說明。理由是那些公告現在實際上就是全公司都看得到(這條的症狀正是如此),標成全公司是把現狀寫實、不是擴大可見範圍;而留空會讓客戶的舊公告憑空消失,那比較像壞掉。若採此案,卡 1-5 要拆成三張(套件 + BE migration + FE 加選項)。
問題:裁定是「自己那頁只回自己建立的、管理頁回整個客戶的(但要真的檢查管理權限)」。但前端兩頁用同一個畫面元件、打同一支入口,後端只看到「有人要清單」。要分辨就得改契約:①同一支入口多一個明確參數(例如「範圍=我的/全部」),送「全部」時後端驗管理能力點;②開第二支入口給管理頁。
我的建議:①同一支入口多一個參數。理由是既有原則二講「權限檢查走一條路由+登記,不為每個情境各開路由」;而且第二支入口要重做分頁、排序、匯出的整套契約,前端也要改兩處。多一個參數的代價是 FE 要在管理頁那邊明確送值(一行)。