FR-036 實作計畫 — M5(資源庫編輯讀寫改源)+ Phase 2

M1–M4 已完成並 commit(見 design.md / changelog)。本檔聚焦尚未做的 M5Phase 2。 盤點結論:採 (b) 重用 jedi-oscal 既有 SSP 子表 domain service(不重造輪子、合 DDD)。所有需要的 provider 都已在 di_containers/oscal/oscal_containers.py 註冊。


§1

已驗證的可重用建材(盤點 2026-06-10)

樣板 SSP 子表 jedi-oscal domain service 關鍵方法 BE provider(oscal_containers.py)
ssp_control_implementations ControlImplementationDomainService get_by_ssp_and_identifier(ssp_id, control_identifier)get_all_by_ssp_id(ssp_id)、add/update/delete_by_id control_implementation_domain_service:367
ssp_control_implementation_objectives ControlImplementationObjectiveDomainService get_one(filter)、get_all、add/update/delete_by_id control_implementation_objective_domain_service:372
ssp_components ComponentDomainService get_all/get_one/add/update/delete component_domain_service:600
ssp_inventory_items InventoryItemDomainService get_all/get_one/add/update/delete inventory_item_domain_service:605
ssp_leveraged_authorizations LeveragedAuthorizationDomainService get_all/get_one/add/update/delete leveraged_authorization_domain_service:595
ssp_system_characteristics SystemCharacteristicDomainService get_by_ssp_id(ssp_id)、add/update system_characteristic_domain_service:352
程序書池 + mapping ssp_document_pool_querySspDocumentPoolQuery list_mappings(context_type, context_id)list_pool(ssp_id)、add/remove_mapping ssp_document_pool_query:503 (Singleton)

共通 helper:module_frame_domain_service.verify_module_frame_exists_by_uid(uid) 回傳的 ModuleFrameEntity 已帶 template_ssp_id(M1 mapper 已補)。各 MF app service 解析 template_ssp_id 後用上表 domain service 讀寫。


§2

關鍵設計約束(實查 code 得出,務必遵守)

  1. 樣板 SSP 子表 row 的 key 是 AP-independent:control 用 control_identifier;objective 用 statement_identifier(ccai uid / letter)。不可用 AP task id(樣板沒有 AP)。
  2. 程序書 mapping 的 context_id 差異(最易踩雷):
    • 專案 SSP:SspDocumentPoolServiceAP control id / AP task id 當 context_id(line 104 註解)。
    • 樣板 SSP:M3 存的是 樣板自己的 ssp_control_implementations.id / ssp_control_implementation_objectives.id
    • 所以資源庫的程序書讀寫不能直接套 SspDocumentPoolService;要用 ssp_document_pool_query.list_mappings('control_implementation', 樣板 control_impl.id) 這種「樣板 row id」當 context_id。
  3. DTO 形狀不變(B 路線,FE 無感):各 ModuleFrame*Dto.from_entity() 是 duck-typed,jedi-oscal 的 SSP 子表 entity 欄位名相容(control_identifier / implementation_status / … 都在),可直接餵。
  4. is_template=False 預設排除(M2):讀樣板要用 get_by_id / get_by_uid 或顯式條件,別走會被排除的 list; 或直接用 ssp_id(=template_ssp_id)查子表(子表查詢不經 ssp 主表的 is_template filter)。

§3

進度(2026-06-10)

  • M5a 完成:6 個 default 編輯 service 全部改寫樣板 SSP(control/objective/components/inventory/leveraged/system_characteristic),DI 重接、import smoke 通過。commit 9aca4a16(control)/8e0913d3(其餘 4)/objective。
  • M5c 完成:新建 module_frame 時建樣板 SSP(add_empty_ssp + is_template/version_no)。jedi-oscal c62ff62、BE b797fc57
  • M5b 待做:程序書(reference document)寫入路徑。已知細節
    • oscal.ssp_reference_documents 無 title 欄位(只有 description)→ MF 程序書 title 對應 file_name(與專案 SSP 一致,M3 已只搬 description)。
    • grc ssp_reference_document_domain_service 只有 list_by_context/add/delete_by_uid,缺 update + get_by_uid → 需補這兩個 method(+ repo)。
    • mapping 用 ssp_document_pool_query.add_mappings/remove_mapping/list_mappings,context_id = 樣板自己的 control_impl.id(用 control_implementation_domain_service.get_by_ssp_and_identifier 解)/ objective.id。
    • 改寫 module_frame_reference_document_service.py(pool CRUD + control/objective attach/detach)。
  • M5d 大部分完成
    • export/ssp_mf_content_loader.py(SSP 匯出主路徑):control/AO/refdoc 走 M5a/M5b service(已 re-point)、inventory 改用 M5a inventory service。
    • module_frame_ssp_resources_service._resolve_ssp_id:改用 mf.template_ssp_id(不再 get_one(profile_id))。
    • ⬜ 移交 Phase 2ssp_import_template_app_service(樣板下載填值 _populate_filled_data)—— 此 service 的 controls/ref_docs/inventory/SC/components 讀取 helper 與 Phase 2 的 import-confirm 路徑共用、且自行注入 *_default domain service。只 re-point fill 半邊會切碎共用 helper、風險波及 import path(Phase 1 *_defaults 仍在)。故與 Phase 2 import 改源一起做。Phase 1 期間下載填值仍讀 *_defaults(M3 snapshot;M5a 編輯後在此 service 顯示為遷移初始值,屬已知 Phase-1 限制)。

§4

M5a — 6 個 default 編輯 service 改源(內容欄位)✅ 完成

每個 service 同一套改法(以 module_frame_control_default_service.py 為樣板,先做它驗證後複製):

  1. __init__:把注入的 module_frame_*_default_domain_service 換成對應 jedi-oscal SSP 子表 domain service。
  2. 每個 public method 開頭:mf = verify_module_frame_exists_by_uid(uid)template_ssp_id = mf.template_ssp_id; 若 None 拋 BadRequestError(理論上不會:M3 已設、MF-create 會設,見 M5c)。
  3. list_allget_all_by_ssp_id(template_ssp_id)get_oneget_by_ssp_and_identifier(...)upsert → get → 有則 update、無則 add(entity 帶 system_security_plan_id=template_ssp_id); delete → get → delete_by_id
  4. DTO 輸出不變;_enrich_nicknames 不變。
  5. DI:在 service 的 provider 改注入 jedi-oscal domain service provider(module_frame_containers 需能 reference oscal_container.*;control/objective 在 module_frame_containers.py、其餘 4 個在 oscal_containers.py, 後者天生可直接 reference)。

逐 service:control_default → control_objective_default → components → inventory → leveraged → system_characteristic。 每個改完跑 BE + 在資源庫 UI 實測該頁籤編輯/顯示,再做下一個。

§5

M5b — 程序書(reference document)改源

  1. 資源庫程序書池:module_frame_reference_document_service 寫入從 module_frame_reference_documents 改成 ssp_reference_documents(context_type='ssp', context_id=template_ssp_id),用 grc ssp_reference_document_domain_service(已有 list_by_context/add/delete_by_uid)。
  2. 控制項/AO 掛勾 mapping:用 ssp_document_pool_query,但 context_id 用樣板 row id (control → 樣板 ssp_control_implementations.id;AO → 樣板 ssp_control_implementation_objectives.id), 不是 AP id。需要一支「resolve 樣板 control_impl/objective id by (template_ssp_id, identifier)」helper (control_impl_domain_service.get_by_ssp_and_identifier 已有;objective 用 get_one(filter))。
  3. M5a 各 service 的 _enrich_reference_documents 一併改用此路徑(context_id=樣板 row id)。
§6

M5c — MF-create 流程建立樣板 SSP

module_frame_service.py 建立 module_frame 後,呼叫 ssp_service.add_empty_ssp(..., is_template=True) (需先在 jedi-oscal add_empty_sspis_template: bool=False 參數並傳入 entity)建出空樣板 SSP, 設 module_frames.template_ssp_id。否則新建 MF 無樣板 SSP,M5a 編輯會無處可寫。 (匯入建 MF 的 module_frame_import_service / docx 預選流程同樣要補。)

§7

M5d — 顯示 / 匯出 / 填值讀取改源

  • app/oscal/service/export/ssp_mf_content_loader.pyssp_export_model.py:讀 *_defaults 改讀樣板 SSP 子表。
  • app/module_frame/service/ssp_import_template_app_service.py_populate_filled_data(MF-scoped 填值)改讀樣板 SSP。
  • module_frame_ssp_resources_service.py:97ssp_import_template_app_service.py:1002/1075get_one(SystemSecurityPlanQueryEntity(profile_id=...)) 想拿樣板的,改傳 is_template=True(M2 預設排除)。

§8

Phase 2(2026-06-10 啟動)

P2a — 匯入 + 樣板下載填值改源(盤點結果,2026-06-10)

三個 service 各自獨立、無共用 confirm orchestrator。讀(填值)與寫(匯入確認)在 ssp_import_template_app_service共用部分 helper(如 _load_existing_ctrl_map), 改的時候要整批一起、避免切半。共通 mapping 比照 M5a / M3(*_default 欄位 → ssp_* 欄位, key = mf.template_ssp_id;components/inventory/leveraged 帶 tenant_id + is_active)。

A. ssp_import_template_app_service.py(樣板下載填值,只讀不寫)— 6 個讀 helper 改讀樣板 SSP:

  • _fetch_ref_docs_mf_lookup() L740 + _build_ref_docs() L1415 → 樣板 SSP ssp_reference_documents(context_type='ssp', context_id=template_ssp_id)
  • _build_sc_from_mf_default() L1011 → system_characteristic_domain_service.get_by_ssp_id(template_ssp_id)(已有 _build_system_characteristic_from_ssp
  • _fetch_mf_default_items() L959 → 樣板 SSP components + leveraged(已有 _fetch_items_by_ssp_id
  • _fetch_mf_inventory_entities() L933 → 樣板 SSP inventory(已有 _fetch_inventory_items_by_ssp_id / _fetch_system_assets_by_ssp_id
  • _load_existing_ctrl_map() L1389 + _load_existing_obj_map() L1410 → 樣板 SSP control_implementations / objectives(keyed by control_identifier /(control_identifier, statement_identifier))
  • 最簡做法:_populate_filled_data 解析 tssp=mf.template_ssp_id,SC/components/leveraged/inventory 直接走既有 _*_by_ssp_id helper;control/AO/refdoc helper 改吃 template_ssp_id。

B. ssp_docx_import_app_service._run_mf_default_confirm() L1078-1268(匯入確認,DELETE-all + upsert)

  • LA/Component/Inventory/SC 四段,從 mf_*_default_domain_service 改成 jedi-oscal domain service(keyed by template_ssp_id),entity 轉 ComponentEntity/InventoryItemEntity/LeveragedAuthorizationEntity/SystemCharacteristicEntity(帶 tenant_id + is_active)
  • 觸發條件 L870 的 if source_type=='module_frame' and (mf_*_default ...) 一併調整
  • 控制項/AO 寫入(module_frame_control_default_repo / _objective_default_repo L53-54)改寫樣板 SSP control_implementations / objectives

C. ssp_excel_import_app_service._apply_write_mf_defaults() L1707-1866(同 B 模式)

  • LA/Component/Inventory/SC 四段改寫樣板 SSP;ModuleFrameWriteStrategy.write_excel_control_defaults(控制項/AO,L1619)改寫樣板 SSP

D. DI(oscal_containers 993-996 / 1047-1051 / excel container):三個 import service 停止注入 mf_*_default_domain_service,改注入 jedi-oscal domain service(component/inventory/leveraged/system_characteristic/control_implementation/objective)。

P2b — gate + copy service 清理(code,可逆)

  1. _setup_ssp_system_implementation_mf_has_defaults gate(oscal_project_service ~L665)改查 module_frames.template_ssp_id IS NOT NULL;移除舊式「profile 第一個 SSP 當 template」clone。
  2. ModuleFrameTemplateCopyService.__init__ 移除已不用的 4 個 *_default domain service 注入。

P2c — DROP *_defaults 子系統(破壞性、不可逆,安全底線)

  • 前置(不可跳):stg / poc 套用 M1(欄位)+ M3(資料遷移)→ 三環境驗證零丟失 → user 明確點頭
  • DROP module_frame_*_defaults(7 張)+ module_frame_reference_documents/_mappings;移除整個子系統 model/repo/domain service/mapper/entity/query-entity/DTO/route。

§9

驗證(每階段)

  • M5a/b 每個 service:BE 重啟 → 資源庫該頁籤 新增/修改/刪除/顯示 → 再啟動專案確認內容有從樣板帶入。
  • M5c:新建一個 MF → 確認 template_ssp_id 有值 → 編輯 → 啟動專案帶入。
  • 跨環境:dev 全綠後再 stg(先備份)、poc。