FR-038 import-ssp — 資源庫範本 SSP 匯入(docx + excel,落 v2 schema)設計

狀態:設計定稿(2026-06-15,與 user 互動拍板)。本檔為 brainstorm 收斂結果。 上游:requirement-analysis / design §4.2、§4.4 / api-contract §2 / 2A handoff §10(import-ssp defer)。 FR-038 Wave 2 收尾項:把唯一還沒接 v2 的 BE 功能(SSP 匯入)補上。


1. 問題(白話)

受評公司 / 顧問常常已經有一份寫好的 SSP 文件(Word 或 Excel)。目前要把內容搬進系統,只能 人工一條條打。import-ssp = 上傳該文件 → 系統自動解析 → 填進「答案卷(SSP)」。

落點 = 合規資源庫的「範本 SSP」(公版範本):顧問/管理員建一份標準範本 SSP 放資源庫,之後 每個新專案成立時 clone 這份範本當起點(B2 已有 clone 機制)。不是填某個專案自己的 SSP。

UI 掛在合規資源庫編輯頁(FE ModuleFrameTemplateEditView.vue,已是 v2:module-frame-import-docx-update / module-frame-import-excel-update 路由 + ssp-docx-import-v2 頁)。

兩種匯入模型(user 2026-06-15 拍板:兩種都做)

v1 confirm 以 source_type 分派三條(_VALID_SOURCE_TYPES={framework_version, module_frame, ssp}):

source_type v1 flow 模型 本期
framework_version _confirm_superset_flow Model B:從文件建新資源庫(選框架版本 → 建 module_frame + profile + 填範本 SSP) ✅ 做
module_frame _confirm_update_flow Model A:更新既有資源庫的範本 SSP(FE ...-update 路由) ✅ 做
ssp _confirm_ssp_update_flow 專案 SSP(Project Planning) ❌ 範圍外
  • 兩條都靠 P1 OscalIoService.import_ssp 填範本 SSP(共同核心)。差別:Model B 多一步「先建殼」 —— 從 framework_version clone catalog→profile→空範本 SSP(= B1 既有 clone_resource_library 能力), 再 import_ssp 填;Model A 直接解析既有 module_frame 的 template_ssp_id 來填/更新。
  • 本質是 update 流程:FE 路由名為 ...-update,程式碼註解「ensure_shell 後填入」。首次匯入空殼 = create(P1~P3 涵蓋);再次匯入既有內容 = update-diff(P4,核心非附加)。Model B 每次建新資源庫 =永遠 create;Model A 才會碰 update-diff。

為什麼現在卡住

  • v1 匯入管線完整但 dark:route(docx / excel / ssp-scoped excel)、app service、parser (DocxParserCore / ExcelParser,OSCAL-agnostic)、parse-job 表、reconciliation、 Phase 2 update-diff、8+ 測試 —— 都還在,但被 config/di_modules.py:EXCLUDE_MODULES 擋掉。
  • 擋掉原因:confirm 的寫入核心domain/oscal/strategy/ssp_write_strategy.pymodule_frame_write_strategy.py)還 import 舊 jedi_oscal v1 entity(ControlImplementationEntity / ControlImplementationObjectiveEntity / PartyEntity / ResponsiblePartyEntity),與 v2 撞同一 SQLAlchemy MetaData,wire 進去 BE 就炸。
  • 套件 jedi_oscal_v2OscalIoService 只有 export_oscal(已 ship),import 整個不存在

已驗證可重用 vs 須重建

區塊 狀態 處置
DocxParserCore / ExcelParser(解析) OSCAL-agnostic,不依賴 v1/v2 重用
parse-job 表 + domain service(暫存解析結果 + 狀態機) 主專案、與 v1 OSCAL 無關 重用
route + app service 的 preview / confirm / discard 編排 主專案;confirm 內呼 v1 strategy 重用編排,換寫入
party reconciliation(解析出的人 ↔︎ DB user 配對) 主專案 重用
Phase 2 update-diff 標注(parsed vs current) 主專案;current 端讀 v1 entity 重用編排,current 端改讀 v2
寫入核心(control-impl / SoA / statements / parties / SC) v1 jedi_oscal strategy 重建於 v2(套件)
SspImportConfirmService(v2-bundle confirm) 只寫 LA / component / inventory(FR-032 era) 併入新寫入路徑,補齊核心

2. 已驗證地基(2026-06-15 讀 live code)

  • 套件 jedi_oscal_v2 寫入機具齊全:14 個 SSP repo/entity(SspControlImplementation / SspImplementedRequirement / SspStatement / SspByComponent / SspSystemCharacteristics / SspComponent / SspLeveragedAuthorization / SspInventoryItem 等)+ SspCloneService.deep_clone_ssp 已示範 12 子表完整寫入 pattern(含 by-component 的 component_id 軟指標 remap)。
  • 套件 OscalIoService.export_oscal_export_ssp / _build_metadata / _build_system_characteristics / _build_system_implementation / _build_control_implementation:import 是其精確反向,照同一結構鏡像。
  • 資源庫範本 SSP 由 B1 建殼:module_frames.template_ssp_id 指該 SSP;import 解析 resource-library {uid}template_ssp_id → 寫入該 ssp_id。

⚠️ 實作前 pre-flight(per memory feedback_preflight_before_touching_pipeline):每 phase 開工 必先讀關鍵檔本身(parser 輸出 dataclass 欄位、export _build_* 實際結構、SspCloneService 寫入 順序、B1 建殼後 template SSP 是否已有 implemented_requirements),不憑本設計摘要刻。欄位級 對照在 implementation-plan 各 phase pre-flight 段釘死。


3. 架構與邊界(locked)

┌─ 主專案(compliance-manager-be)────────────────────────────────┐
│  route(re-enable)→ app service(preview / confirm / discard 編排)│
│    ├─ DocxParserCore / ExcelParser     → 解析成「正規化 SSP 中介」   │
│    ├─ party reconciliation             → 解析人 ↔ DB user            │
│    ├─ update-diff 標注                  → 中介 vs 套件 snapshot       │
│    └─ confirm 寫入  ── delegate ──▶                                  │
└──────────────────────────────────────────┼──────────────────────────┘
                                            ▼
┌─ 套件(jedi-oscal-v2 OscalIoService)──────────────────────────────┐
│  import_ssp(intermediate, *, target_ssp_id, catalog_id, mode, user) │
│    → 把正規化中介寫成 v2 SSP entity 樹(鏡像 export / clone pattern)│
│  build_ssp_snapshot(ssp_id) → 可比對 dict(給主專案 diff 用)        │
└─────────────────────────────────────────────────────────────────────┘

三條邊界決策

# 決策
D-IS-1 落點 合規資源庫範本 SSP(module_frames.template_ssp_id)。兩種模型都做:Model A(module_frame source,填/更新既有)+ Model B(framework_version source,先建殼=clone_resource_library 再填)。ssp source(專案)範圍外
D-IS-2 寫入 mapper 位置 套件 OscalIoService.import_ssp(與 export 對稱,套件擁有 OSCAL↔︎entity 知識)
D-IS-3 正規化中介契約 主專案 parse 端定義、套件 import 端 consume;穩定 dataclass(OSCAL-shaped、不綁 docx/excel)

正規化中介契約(altitude;欄位級在 plan 釘)

一個 OSCAL-shaped、與來源格式無關的中介物,承載:

  • control implementations:以 control-id 為鍵,每條含實作描述 + SoA(applicability / inclusion-justification / implementation-status,落 props)+ statements + by-components。
  • parties / roles:解析出的人/單位(已經 reconciliation 配對 DB user / org)。
  • system-characteristics:系統說明、邊界、資料流、資訊類型、圖。
  • system-implementation 子表:components / leveraged-authorizations / inventory-items / system-users。

docx 與 excel parser 各自把自己的解析結果正規化成此中介(docx adapter / excel adapter 各一), 之後 confirm + diff + 套件 import 全部只認中介 → 兩格式共用下游,避免兩套寫入。


4. 階段拆解(逐段 ship,每段 smoke + 顯式 git add commit;照 Q1 cadence)

Phase 範圍 驗收
P1 套件寫入核心 ✅ DONE OscalIoService.import_ssp(oscal_ssp, *, target_ssp_id, curr_user, mode='create'):OSCAL ssp dict(中介=export 同形狀)→ v2 SSP 樹。涵蓋 export round-trip 面:metadata roles/parties + system-characteristics + system-implementation/components + control-implementation/implemented-requirements(SoA props)。statements / by-components / LA / inventory 不在 P1(export 也不 round-trip;LA/component/inventory 另有 FR-032 v2-bundle 路徑)。create-only(空殼;非空丟錯)。pkg test tests/io_export/test_oscal_import.py io_export 10 passed(含 round-trip + 非空 guard);BE BOOT OK;BE pytest 持平
**P2 Excel(兩 source) excel parsed → OSCAL ssp dict adapter;SspExcelImportAppService confirm 改 delegate import_ssp(丟 v1 strategy);re-enable excel route。Model A**(module_frame 解析 template_ssp_id 填)+ Model Bframework_version → 先 clone_resource_library 建殼[=B1 能力] → 再 import_ssp 填)。create 模式(首次/空殼)。 e2e:(A) 既有資源庫上傳 excel → 範本 SSP 填好;(B) 選框架+上傳 → 建新資源庫且範本 SSP 填好;BOOT OK + pytest 不多紅
P3 Docx(兩 source) docx parsed → OSCAL ssp dict adapter;SspDocxImportAppService 同 P2(A+B);re-enable docx route。 e2e:docx → 兩 source 範本 SSP 填好
**P4 update-diff(Model A 再匯入)✅ 實作(接回 v2,W2;smoke 待驗收) import_ssp 加 mode='update' + 套件 build_ssp_snapshot;current 端改讀 v2;移植 Phase 2 diff 標注(parsed vs current);preview 逐項 diff_status;confirm 依 decision 在 OSCAL-dict 層 merge(W2,單一 writer)後寫入。主要服務 Model A 再匯入**(Model B 永遠建新=不需 diff),並擴及專案 SSP(Model C)。 既有資源庫重複匯入 → side-by-side 差異 + 逐項決定生效。單元/整合測試 + BOOT + baseline 零回歸已過;real-DB HTTP smoke 待 user 驗收(2026-06-16)

P1 已完成(套件 commit a930bfd,OSCAL-dict 即中介、無獨立 dataclass、create-only)。中介契約 = OSCAL ssp dict(與 export 同形狀),故 §3「正規化中介契約=dataclass」改為「= OSCAL ssp dict」; docx/excel adapter 產出此 dict。 每 phase 開工先 pre-flight 驗 live 路徑(§2 ⚠️)。改套件走 dev path-dep,不 commit pyproject 改動; 整弧完 + user 明示才發 Nexus + pin。FE 已是 v2(多數情況不需動 FE,BE 契約對齊既有頁面)。


5. 資料流(P2/P3 confirm,create 模式)

  1. 上傳 docx/excel → 存檔(MinIO)→ 建 parse-job(status=pending)。
  2. parser 解析 → adapter 正規化成中介 → party reconciliation → 存 parse-job.parsed_result(JSONB,status=awaiting_review)。
  3. FE preview(GET parse-job)。
  4. confirm:解析 resource-library {uid}template_ssp_id → 主專案在 @transaction 內呼 OscalIoService.import_ssp(中介, target_ssp_id=template_ssp_id, catalog_id, mode='create', user) → 套件寫 v2 SSP 樹 → 回 count 摘要 → parse-job status=completed。
  5. (P4)confirm 前先 diff:中介 vs build_ssp_snapshot(template_ssp_id) → 標 diff_status → FE 逐項決定 → confirm 依 decision 寫。

6. 錯誤處理

  • 沿用 jedi_common.handler.exception + GrcErrorCode(新碼 grep common/code/grc_error_code.py 最大值續編): resource-library 不存在 / template_ssp_id 為空 / 解析失敗(落 parse-job.error_code+message,不 500)/ control-id 對不到專案 catalog(warning 不擋,記 import_warnings)。
  • 寫入在主專案 @transaction 內(套件 import_ssp 不自開 scope,caller 控制;mirror confirm_service 慣例)。
  • reconciler / 非核心子項失敗 → logger.warning 不擋主流程(沿用 confirm_service Step 3 慣例,不 silent pass)。

7. 測試

  • 套件(P1):單元測 import_ssp 各子樹寫入 + by-component remap + round-trip(export(import(x)) ≈ x)。
  • 主專案(P2/P3):app service 測(logger patch fixture,per memory feedback_test_logger_patch_db_handler); 重用既有 8+ 匯入測試檔當回歸基準,逐一改成 v2 斷言。
  • e2e(測試專案 compliance-manager-test):上傳真 docx/excel → 資源庫範本 SSP 內容正確。
  • 每 phase:BOOT OK + python -m pytest test/ 不多於 baseline 紅(零新回歸)。

8. Scope / 非目標

  • 本弧只做資源庫落點;專案 SSP 落點機制可推廣但不在本弧 re-enable(D-IS-1)。
  • ISO / NIST parser adapter 維持現狀(CMMC 焦點);component-definition 不碰。
  • 不重寫 parser(OSCAL-agnostic,直接重用);不重做 FE preview/confirm UX(欄位形狀盡量不變)。
  • 套件發 Nexus + pin 還原 → 整弧完 + user 明示(dev 走 path-dep)。
  • v1 ssp_write_strategy / module_frame_write_strategy 退役(不刪檔,停用;確認無 live consumer 後另開 cleanup)。

9. 待 pre-flight 釘死的假設(plan 各 phase 開工驗)

  • 最高槓桿(P1 寫 mapper 前必先答):B1 建殼後 template SSP 是否已有 ssp_control_implementations / implemented_requirements —— 決定 create 是純 insert 還是 upsert + remap,直接改變 P1 mapper 形狀。必須在 P1 寫 code 前驗,不可邊做邊發現。
  • SoA prop key 名(P1 pre-flight 釘):從套件 _build_control_implementation 抓 applicability / inclusion-justification / implementation-status 的精確 prop name,讓 docx 與 excel adapter 對齊同一組鍵(否則兩格式寫出不同 props,下游 SoA 讀不到)。
  • 正規化中介的精確欄位(讀 ParsedExcel / ParsedDocx / ParsedSsp dataclass 實體)。
  • 套件 export _build_* 的精確 OSCAL 結構(import 鏡像)。
  • resource-library {uid}template_ssp_id 的解析路徑與權限(manager 守門)。
  • adapter registry 現況(docx/excel adapter 怎麼註冊、CMMC adapter 是否可直接產中介)。