SSP Doc Parser — v2 Design

取代 v1 design(dialog-based + chain confirm hack) 設計討論完成日:2026-05-02 v1 已上線 commit 565e8b5 (BE) / 1f66640 (FE),本 v2 是針對下一輪重構

1. 目標

讓使用者把 SSP docx 範本一鍵建立 / 一鍵匯入到既有資源庫, 不再受 dialog 嵌套限制;解析範圍擴大到 metadata / parties / leveraged services。

2. 主要決策一覽

# 主題 決定 理由
1 文件預覽 iframe /upload-file/<uid>/preview-as-pdf 既有 endpoint 自動 docx→PDF,零新依賴
2 右側 panel scrollable Fieldset + 每區獨立 SectionComponent 跟合規框架預覽 UI 一致
3 左右同步滾動 ❌ 不做(v1) 第一版實作成本高,先觀察使用者需求
4 編輯持久化 BE parse_job.parsed_result JSONB(7 天 TTL)+ FE localStorage(session 防丟) BE 7 天足夠;localStorage 防 refresh
5 重新上傳 新 parse_uid,舊的自然過期 簡單;user 確認多次上傳是罕見場景
6 UI 流程 5-A revised:雙獨立入口(手動 / docx) 取消 dialog 嵌套,docx 一條龍建立
7 帳號鉤稽 email 完全相符(同 tenant);soft reference user_id integer 對齊既有 cross-schema FK 規範
8 未連結 party 純文字保留(A 案);編輯頁加「參與單位 / 人員」section 補連結 不污染 user 表,事後可補
9 Org-unit 鉤稽 name 完全相符;soft reference org_unit_id integer 同 person 模式
10 多 framework Parser core + framework adapter 兩層;CMMC 特例走 oscal_props v1 只實作 CMMC,但不綁死
11 UI 擴充性 hard-coded Fieldset + SectionComponent 拆乾淨;不做 config-driven v1 區塊有限,過度抽象不划算
12 Storage 擴充性 核心走 column;偶爾擴充走 oscal_props;1:N 走新表 跟既有 OSCAL 設計對齊

3. UX 流程(5-A revised)

3.1 入口分布

[合規資源庫管理 首頁]
   ├─ 「手動新增」Button
   │   → Dialog (3 step: framework + baseline 勾選 + 確認)
   │   → 建立空殼 module_frame
   │   → redirect to 編輯現況與程序書頁
   │
   ├─ 「從 docx 建立」Button  ← 新增
   │   → /compliance-resource/import-docx (獨立頁,create mode)
   │
   └─ [既有資源庫] → 編輯現況與程序書頁
         └─ 批次維護 → 「從 docx 匯入」
             → /compliance-resource/<uid>/import-docx (獨立頁,update mode)

[專案規劃頁]
   └─ 批次維護 → 「從 docx 匯入」
       → /project/<id>/ssp-import-docx (獨立頁,SSP update mode)

3.2 獨立頁雙模式

Mode route 可編輯範圍
create /compliance-resource/import-docx framework / version / baseline / 全部內容 / parties
update (MF) /compliance-resource/<uid>/import-docx controls / AOs / parties;framework + baseline 唯讀
update (SSP) /project/<id>/ssp-import-docx 同 update MF,目標寫入 SSP 而非 module_frame

三條路共用同一個 Vue page component,差別只是 props(mode + 目標 uid)。

3.3 獨立頁佈局

┌─ Header ────────────────────────────────────────────┐
│ [從 docx 建立 / 匯入] [重新上傳] [取消] [確認]      │
└─────────────────────────────────────────────────────┘
┌─ Splitter ──────────────────────────────────────────┐
│ ┌─ Left (50%) ──────┐ ┌─ Right (50%) ─────────────┐ │
│ │                   │ │                            │ │
│ │ <iframe>          │ │ Fieldset 「基本資料」     │ │
│ │  原始 docx        │ │   <MetadataSection>       │ │
│ │  (PDF)            │ │ Fieldset 「參與單位/人員」│ │
│ │                   │ │   <PartiesSection>        │ │
│ │  scrollable       │ │ Fieldset 「外部利用服務」 │ │
│ │                   │ │   <LeveragedSection>      │ │
│ │                   │ │ Fieldset 「控制項實作」   │ │
│ │                   │ │   <ControlImplSection>    │ │
│ │                   │ │ Fieldset 「未匹配段落」   │ │
│ │                   │ │   <UnmatchedSection>      │ │
│ │                   │ │   scrollable              │ │
│ └───────────────────┘ └────────────────────────────┘ │
└─────────────────────────────────────────────────────┘

3.4 操作流程

1. 點「從 docx 建立」/「從 docx 匯入」
   → 跳到獨立頁
2. user 上傳 docx
   → BE upload + parse → 拿 parse_uid + parsed_result
   → 左 iframe 顯示 PDF
   → 右各 Section 自動填入 parsed 資料
3. user 編輯任一欄位
   → 立即更新 localStorage[ssp_docx_draft:<parse_uid>]
4. user F5 / 切 tab 回來
   → mount 時從 localStorage 還原編輯狀態
5. user 重新上傳
   → 棄置舊 parse_uid(BE 7 天後過期)+ 清掉 localStorage key
   → 新 parse_uid → 從 step 2 重來
6. user 點「確認」
   → POST confirm with merged payload (BE parsed + localStorage edits)
   → BE 寫入 SSP/MF (含 parties / leveraged / controls / AOs)
   → 清 BE parse_job + FE localStorage
   → redirect to 編輯現況與程序書頁
7. 在編輯頁的「參與單位 / 人員」section 可補連結未連結的 party

4. 架構

4.1 Parser 分層(多 framework 預備)

┌─ DocxParserCore (既有,純結構抽取,framework-agnostic) ──┐
│   _extract_structure(doc) -> StructureParagraph[]/Table[] │
│   _is_list_item(p), _promote_pseudo_h3, _heading_level    │
└──────────────────────────────────────────────────────────┘
                          ↓
              StructureBundle (intermediate)
                          ↓
┌─ ISspDocxAdapter (新增 interface) ────────────────────────┐
│   adapt(structure, candidates) -> ParsedSsp               │
└──────────────────────────────────────────────────────────┘
                          ↓
              ┌───────────┴───────────┐
              ↓                       ↓
   CmmcSspAdapter (v1)        Iso27001SspAdapter (未來)
   - 抓 5 個固定角色            - ...
   - FCI 類型 → props
   - FedRAMP → leveraged
                          ↓
              ParsedSsp (OSCAL-aligned schema)
                          ↓
┌─ WriteStrategy (既有,已涵蓋 controls + AOs) ─────────────┐
│   ModuleFrameWriteStrategy / SspWriteStrategy             │
│   v2 擴充:寫 parties / leveraged-auth / metadata         │
└──────────────────────────────────────────────────────────┘

4.2 ParsedSsp Intermediate Schema

@dataclass
class ParsedSsp:
    metadata: ParsedMetadata
    system_characteristics: ParsedSystemCharacteristics
    parties: list[ParsedParty]
    leveraged_services: list[ParsedLeveragedService]
    matched_controls: list[MatchedControl]      # 既有
    information_types: list[ParsedInformationType]
    unmatched_paragraphs: list[UnmatchedParagraph]  # 既有
    summary: dict
    framework_specific_props: dict[str, Any]    # CMMC FCI 等特例

@dataclass
class ParsedParty:
    name: str
    party_type: Literal['person', 'organization']
    title: str | None         # person 的職稱
    email_address: str | None
    telephone_number: str | None
    address: str | None
    role: str                  # 'system-owner' / 'security-officer' / ...
    matched_user_id: int | None        # email exact match → user_id
    matched_org_unit_id: int | None    # name exact match → org_unit_id

4.3 Storage

4.3.1 oscal_parties schema 擴充

ALTER TABLE oscal.oscal_parties
  ADD COLUMN title varchar(255),
  ADD COLUMN email_address text,
  ADD COLUMN telephone_number varchar(50),
  ADD COLUMN address text,
  ADD COLUMN user_id integer,        -- soft ref to public.members.id
  ADD COLUMN org_unit_id integer;    -- soft ref to public.org_units.id

CREATE INDEX ix_oscal_parties_email ON oscal.oscal_parties(email_address);
CREATE INDEX ix_oscal_parties_user_id ON oscal.oscal_parties(user_id);
CREATE INDEX ix_oscal_parties_org_unit_id ON oscal.oscal_parties(org_unit_id);

GRANT SELECT, INSERT, UPDATE, DELETE ON oscal.oscal_parties TO cm_app;

user_id / org_unit_id 二擇一 nullable:

  • party_type='person' → 用 user_idorg_unit_id 必為 NULL
  • party_type='organization' → 用 org_unit_iduser_id 必為 NULL

4.3.2 parse_job 行為

ssp_docx_parse_jobs (既有表)
  ├─ uid
  ├─ source_type / source_uid    (update mode 用)
  ├─ framework / mode            (mode='full' 為 create / 'statement_only' 為 update)
  ├─ parsed_result JSONB         (immutable parser 輸出)
  ├─ status                      (awaiting_review / completed / discarded)
  ├─ created_at / created_user
  └─ ...

TTL: 7 天(cron 清 status='awaiting_review' 且 created_at < NOW() - 7d)

4.3.3 CMMC 特例走 oscal_props

oscal_props (既有表,key-value)
  ├─ name='cmmc:fci-type'
  ├─ value='合約文件,包括與政府代表進行溝通...'
  ├─ prop_class='information_type_extension'
  └─ ns='http://cmmc.example.com'

oscal_metadata_props (M2M 既有)
  → 連結到對應 OSCAL 主節點

不新增 fci_* 欄位污染 OSCAL 通用 schema。

4.4 FE 結構

src/
├─ views/compliance-framework/
│   └─ ImportDocxPage.vue          ← 新增!create + update 共用
├─ views/project/
│   └─ ProjectSspImportDocxPage.vue ← create-style export 用 SSP 路徑
├─ components/grc/ssp-docx-import/
│   ├─ sections/                    ← v2 新增子目錄
│   │   ├─ MetadataSection.vue
│   │   ├─ PartiesSection.vue
│   │   ├─ LeveragedSection.vue
│   │   ├─ ControlImplSection.vue
│   │   ├─ UnmatchedSection.vue
│   │   └─ PartyLinkDialog.vue      ← 補連結 dialog
│   └─ ImportPageHeader.vue         ← header bar (按鈕區)
├─ stores/sspDocxImportV2.js        ← 重寫 store(捨棄 v1 wizard 邏輯)
└─ composables/useSspDocxDraft.js   ← localStorage 同步 helper

4.5 補連結 UI(編輯現況與程序書頁)

新增 tab/section「參與單位 / 人員」:

┌─ Tab 「參與單位 / 人員」──────────────────────────┐
│                                                    │
│ 角色:System Owner                                 │
│   王大明 (wang@inner.com)  ✓ 已連結 [User] [改連結] │
│                                                    │
│ 角色:Security Officer                             │
│   李小美 (lee@external.com)  ⚠ 未連結 [補連結]    │
│                                                    │
│ 角色:Responsible Organization                     │
│   AIR ASIA Co., Ltd.  ⚠ 未連結 [補連結]          │
│                                                    │
└────────────────────────────────────────────────────┘

點「補連結」→ PartyLinkDialog

  • 對 person:搜尋 system users by email/name
  • 對 organization:搜尋 org_units by name/code

5. 各 Section 內容(右側 fieldset 詳細)

5.1 MetadataSection

欄位 來源 docx OSCAL 欄位 編輯方式
文件編號 封面 T0 oscal_props (cmmc:document-serial-no) InputText
Version 封面 T0 oscal_metadatas.version InputText
Issue Date 封面 T0 oscal_metadatas.published DatePicker
修訂紀錄 Appendix 2 T24 oscal_props 暫存 list(v1) 唯讀表

5.2 PartiesSection

每個角色顯示:

  • Name / Title / Email / Phone / Address
  • 連結狀態:✓ 已連結 / ⚠ 未連結
  • 「補連結」按鈕(內嵌 PartyLinkDialog)

5 個固定角色(CMMC adapter 抽出):

  1. Responsible Organization (organization)
  2. Information Provider (organization)
  3. Information Receiver (person)
  4. System Owner (person)
  5. System Security Officer (person)

未來 framework 可不同(adapter 決定)。

5.3 LeveragedSection

T6 + T7 的內容合併顯示:

  • 服務名稱
  • 提供商
  • 用途
  • 協定 / Port
  • FedRAMP Package ID(CMMC 特例,存 oscal_props
  • Agreement Type

每行可編輯。

5.4 ControlImplSection

跟現有 PreselectSummary 邏輯類似:

  • 列出 matched_controls(含 score、impl preview、AO 列表)
  • create mode:可勾選 / 取消(控制 baseline)
  • update mode:唯讀控制項清單,只編輯內容
  • 控制項點開可編輯 parsed_implementation_description markdown
  • AO 子列每筆可編輯 parsed_description

5.5 UnmatchedSection

跟現有 PreselectSummary 一樣的「疑似漏掉的控制項 + 雜訊計數」分段呈現。

5.6 SystemCharacteristicsSection(如果獨立)

欄位 OSCAL 欄位
System Name system_security_plans_system_characteristics.name
Description description
Sensitivity Level security_sensitivity_level
C / I / A Impact compliance.information_systems.security_objective_*
Authorization Boundary compliance.information_systems.authorization_boundary
Deployment Model compliance.information_systems.deployment_model

6. 鉤稽演算法

6.1 Person → user_id

def match_person(party: ParsedParty, tenant_id: int) -> Optional[int]:
    if not party.email_address:
        return None
    user = users.get_one_by(
        email=party.email_address.lower().strip(),
        tenant_id=tenant_id,  # 同 tenant 才連
        is_active=True,
    )
    return user.id if user else None

6.2 Organization → org_unit_id

def match_organization(party: ParsedParty, tenant_id: int) -> Optional[int]:
    if not party.name:
        return None
    org = org_units.get_one_by(
        name=party.name.strip(),  # exact, case-insensitive 由 DB 端 collation 處理
        tenant_id=tenant_id,
    )
    return org.id if org else None

兩者無 fuzzy / partial 比對;找不到就回 None,store 為純文字 party。

7. Out of Scope(v1 不做)

  • 左右同步滾動 / 高亮聯動
  • Multi-day cross-device 編輯(user 確認不需要)
  • 全域「未連結 party 管理」頁
  • 新建 user 時 auto-suggest 連結 parties
  • 多 user 協作鎖定
  • 修訂紀錄獨立表(metadata_revisions
  • system_information_types 獨立 1:N 表
  • leveraged_authorizations 獨立表(先用 system_implementationimplementation_type + props)
  • system_implementation_components 結構化清單表
  • ISO 27001 / ISMS adapter(架構預備好,實作未來再做)
  • Plugin / dynamic adapter loading
  • Config-driven UI section
  • Network architecture 圖示自動抽取(圖檔留在原 docx 內,不擷取)

8. 廢棄項

從 v1 廢棄:

項目 廢棄原因
SspDocxImportDialog.vue dialog 模式不再使用
PreselectSummary.vue 整合到新 SectionComponent 內
StepUpload.vue / StepPreview.vue / StepResult.vue wizard 模式不再使用
ModuleFrame.vue「從 docx 預選」按鈕 入口改為合規資源庫管理首頁
pendingParseUid / pendingPredictedControls 暫存 state 改放 import page 內
bindingFrameworkVersionTreeData 防洗 guard flow 重新設計後不需要
chain confirm 邏輯 confirm 直接帶 create payload,BE 一筆 transaction 完成

9. 風險與對策

風險 對策
docx PDF 轉檔失敗(複雜版面) iframe fallback:onerror 顯示「PDF 預覽不可用,請檢查右側結構化資料」
localStorage 配額用滿 一個 parse_uid 一個 key,confirm 後即清;異常 capture 警告 user
Adapter 介面設計不夠抽象(未來加 ISO 才發現) 先寫 CmmcSspAdapter 時刻意挑「最 generic 的 method signature」;ISO 加入時容許小幅 adjust interface
Email 鉤稽到別 tenant 的 user match 強制帶 tenant_id filter
既有 v1 用戶的 module_frame 怎辦 v2 不影響既有資料,只改新建 / 更新流程;既有 module_frame 仍可正常編輯

10. 上線策略

v2 規劃為 fresh ship

  • v1 已上線運作;v2 完成後直接覆蓋舊路徑
  • v1 的 dialog 入口在 v2 release 時拿掉
  • parse_jobs 表 schema 不變,向後相容;既有資料保留
  • 新 schema migration(oscal_parties 4 欄)安全(純加欄位)

詳細 deployment 流程見 release-notes-v2.md(v2 ship 前產生)。