SSP Versioning Design

每輪稽核 (AP) 1:1 對應一個 SSP 版本,使每輪稽核時的 SSP 狀態能完整保留為 audit trail, 並避免「編輯本輪 SSP 影響歷史輪次」的資料污染問題。

狀態:Approved(待 implementation plan) Date:2026-04-26 Branchfeature/google-drive-sync 相關 commits:a96b621(前次 Round 2 freeze 部分實作,本設計會吸收併入)


1. Background

1.1 問題現況

目前 schema 設計:

  • 一個專案 → 一筆 system_security_plans (SSP)
  • 一個 SSP → 多個 assessment_plans (AP,代表多輪稽核)
  • 所有 SSP 子層資料 (control_implementations, ssp_control_implementation_objectives, ssp_reference_documents 池, ssp_reference_document_mappings, system_characteristic, system_implementations) 全部掛在 SSP 主表底下

導致的 bug:

  • 在 AP2(第二輪)的程序書管理刪除一份程序書 → AP1 也看不到(因為池是 per-SSP)
  • 編輯 AP2 的「實施現況」(implementation_description) → AP1 的紀錄也被改寫
  • 歷史輪次無法保留當時的 SSP 真實狀態 → audit trail 失真

1.2 OSCAL 觀點

OSCAL 標準的 SSP 包含四大區塊:

OSCAL 區塊 我們的 schema 性質
system-characteristics system_characteristic + 旗下 系統識別(名稱、規模、components)
system-implementation system_implementations + 旗下 系統建置(users、components、inventory)
control-implementation control_implementations (CI) + ssp_control_implementation_objectives (CIO) 控制項實施現況
back-matter (resources) ssp_reference_documents + mappings 程序書 / 佐證資料

OSCAL 規範裡 SSP 主表有 metadata.version 字串欄位,但沒有規定歷史快照怎麼存 — 完全由 implementer 決定。本設計用 group_id + version_no 補上這個機制, 不違背 OSCAL 標準(屬於 OSCAL 允許但未明定的範圍)。

1.3 前置決策(已確認)

# 決策 結論
1 Identity vs Version 範圍 整個 SSP(含 system_characteristic、system_implementations)全 versioning
2 URL contract 每版本自己的 uid(/ssp/<uid> 直接打到指定版本)
3 Version 建立時機 1 round = 1 version,僅在 launch_new_round 時 clone
4 歷史版本 UI 不另做 version selector,從現有「過往 AP 紀錄」頁面自然進入(讀 AP.ssp.uid 即拿到對應版本)
5 system_characteristic mapping 每版本各自 clone 一份
6 既有資料 migration 全部當作 v1,現有 AP 全部繼續指向 v1
7 v_{n+1} 內容來源 從 v_n deep clone(不從 module_frame template 重抓),保留 user 上輪填寫的「實施現況」與程序書,符合多輪稽核「追蹤改善」的業務語意

2. Architecture

2.1 Schema 變更

oscal.system_security_plans 新增欄位

ALTER TABLE oscal.system_security_plans
    ADD COLUMN group_id UUID,
    ADD COLUMN version_no INT NOT NULL DEFAULT 1;

-- Backfill: 每個現有 SSP 自己一個 group
UPDATE oscal.system_security_plans SET group_id = gen_random_uuid() WHERE group_id IS NULL;

ALTER TABLE oscal.system_security_plans
    ALTER COLUMN group_id SET NOT NULL;

CREATE UNIQUE INDEX uq_ssp_group_version
    ON oscal.system_security_plans (group_id, version_no);

CREATE INDEX ix_ssp_group_id
    ON oscal.system_security_plans (group_id);

不變的 FK 行為

  • assessment_plans.ssp_id 含義變成「鎖到的 SSP 版本」(schema 不動)
  • 所有 SSP 子表 FK(CI / CIO / Pool / Mapping / system_characteristic 等)不動 — 自然會跟著主表 cascade,每版本各自一份

2.2 Identity 概念

概念 對應欄位 含義
Identity(真身份) group_id 跨版本不變,串起一個系統的所有 SSP 版本
Version(版本) version_no 該 identity 下的版本號,1, 2, 3...
Per-version uid uid 每個版本自己的 UUID,FE 對接時用這個

group_id 對應 OSCAL system-id 的概念(系統的 stable identifier), version_no 對應「每輪稽核時的 snapshot 版本」。


3. Lifecycle / Flow

3.1 新建專案 (start_oscal_project)

不變。建立第一份 SSP 時:

  • group_id = gen_random_uuid()
  • version_no = 1
  • ModuleFrameTemplateCopyService 跑一次填入 default 內容
  • 建立 AP1,AP1.ssp_id = v1.id

3.2 啟動新輪次 (launch_new_round)

新流程(取代/吸收現有 _clone_control_doc_mappings_clone_ssp_objectives):

1. 讀取 old_ap、old_ssp(v_n)

2. clone SSP 主表 → 新 row(同 group_id, version_no = n+1, 新 id, 新 uid)→ v_{n+1}

3. clone v_n 的 SSP 內部結構(不依賴 AP-level id 的部分):
   3.1 system_characteristic + 旗下 components / users / inventory
   3.2 system_implementations + 旗下
   3.3 project_system_characteristic_mapping → 指向新 characteristic
   3.4 control_implementations (CI) → 新 ssp_id
       → 同時建 ci_id_map: {old_ci.id → new_ci.id}
   3.5 ssp_control_implementation_objectives (CIO) → 用 ci_id_map 重對映 control_implementation_id
   3.6 ssp_reference_documents (池) → 新 ssp_id, file_id 共享
       → 同時建 pool_doc_id_map: {old_pool_doc.id → new_pool_doc.id}(用 step 3.6 的 INSERT...RETURNING 配對 old_id)

4. 建立 new_ap,new_ap.ssp_id = v_{n+1}.id

5. clone AP-level (apc / apt / workflow_template / workflow_executions) — 維持現有邏輯
   → 同時建 apc_id_map: {old_apc.id → new_apc.id}
   → 同時建 apt_id_map: {old_apt.id → new_apt.id}

6. clone ssp_reference_document_mappings(必須在 step 5 之後,因為要新 apc/apt id):
   - context_type='control_implementation': context_id 用 apc_id_map 重對映 old_apc.id → new_apc.id
   - context_type='objective': context_id 用 apt_id_map 重對映 old_apt.id → new_apt.id
   - reference_document_id 用 pool_doc_id_map 重對映 old_pool_doc.id → new_pool_doc.id

7. ModuleFrameTemplateCopyService 不再呼叫(v_{n+1} 從 v_n clone 已含所有 default 內容)

Clone 來源決策:v_{n+1} 的內容從 v_n deep clone,不從 module_frame template 重抓。

理由:

  • 多輪稽核的核心價值是追蹤改善(「上輪 partial → 這輪 implemented」), 從 template 重抓會讓 user 上輪填寫的「實施現況」全部消失
  • 程序書同理:上輪掛的程序書沿用,user 自己決定要不要改版/刪舊掛新
  • Template 中途更新的情況(例如 module_frame 加了新控制項)不在這次處理: v_{n+1} 不會自動帶進新的 template default。未來若有需求,另做一個明確的 「Sync template updates」user action(不混在 launch_new_round 裡)

重要:mapping clone(step 6)必須在 AP-level clone(step 5)之後執行, 因為 mapping 的 context_id 指向新 apc/apt id,這些 id 在 step 5 才產生。

id_map 建構模式:所有 clone 都用 INSERT ... RETURNING id + 跟原始 row 配對, 建立 {old_id: new_id} dict 供後續步驟查詢。不要假設「同 file_id 唯一」之類的隱含約束。

3.3 編輯 SSP(任何輪次中)

不變。FE 從 currentAp.ssp.uid 拿 ssp_uid,打 /ssp/<uid>/... 系列 API, backend 直接寫到該版本。因為每個版本是獨立 row,編 v2 不會影響 v1。

3.4 查看歷史輪次

不變。FE「過往 AP 紀錄」頁面已用 AP context 拿 ssp_uid,自然指到該 AP 鎖的版本。 歷史 AP 因為已歸檔,FE 本來就只給看不給編 → 自然唯讀。


4. API Impact

4.1 不變的 API

所有 /ssp/<ssp_uid>/... URL contract — ssp_uid 改成版本 uid 即可,handler 邏輯不動:

  • GET/POST/DELETE /ssp/<uid>/document-pool
  • GET/POST/DELETE /ssp/<uid>/control-implementation/<ctrl_id>/document-mappings
  • GET/POST/DELETE /ssp/<uid>/control-implementation/<ctrl_id>/objective/<stmt_id>/document-mappings
  • GET/POST/PUT /ssp/<uid>/control-implementations/...
  • GET/POST /ssp/<uid>/control-implementations/export|import

4.2 要改的 API

Endpoint 改動
POST /grc/projects/<uid>/launch-new-round response 加新 SSP 版本資訊(new_ssp_uidnew_ssp_version),FE 跳轉用
GET /grc/projects/<uid> (get_project_full_by_uid) 確認返回的 SSP 是「latest AP 鎖的 SSP 版本」。Day-1 plan task:read infra/grc/repository/grc_project_repo_impl.py:622-638 實作,確認是 join assessment_plans ORDER BY created_at DESC 取 ssp_id,不是 直接取 project.ssp_id。如果是後者,要改成前者。

4.3 Service / Repo 層改動

改動
OscalProjectService.launch_new_round 新增「clone 整份 SSP」邏輯,吸收 _clone_control_doc_mappings / _clone_ssp_objectives
OscalProjectService._clone_control_doc_mappings(commit a96b621 加的) 刪除,併入新的整份 clone 流程
SspDocumentPoolQuery.get_ap_control_id_by_identifier ORDER BY created_at DESC 邏輯仍保留(取最新 AP 的 apc.id),語意不變
OscalSystemSecurityPlan ORM group_idversion_no 欄位
新增 SspVersioningService(建議) 封裝「clone SSP 整份到新版本」的邏輯,給 launch_new_round 呼叫

5. Frontend Impact

5.1 不變

  • 所有 SSP CRUD 頁面(document-pool、control-implementation、planning view 等)
  • 「過往 AP 紀錄」頁面(自然會拿到對應版本)

5.2 要改

頁面 改動
任何從 project.ssp_uid 取 ssp_uid 的頁面 改成從 currentAp.ssp_uid
ProjectAuditorOverview 驗證 ssp_uid 來源
ProjectPlanningView 驗證 ssp_uid 來源
DashboardView 驗證 ssp_uid 來源
launch_new_round 後跳轉 用 response 的 new_ssp_uid 跳新 AP 的 SSP context

實作時要全 grep project.ssp_uid / ssp_uid 用法,逐一確認來源是 AP context。


6. Migration

6.1 Schema migration

-- scripts/sql/2026-04-XX-ssp-versioning.sql
-- Date: 2026-04-XX

-- 1. 加欄位 (2026-04-XX)
ALTER TABLE oscal.system_security_plans
    ADD COLUMN group_id UUID,
    ADD COLUMN version_no INT NOT NULL DEFAULT 1;

-- 2. Backfill: 每個現有 SSP 一個獨立 group_id (2026-04-XX)
UPDATE oscal.system_security_plans
    SET group_id = gen_random_uuid()
    WHERE group_id IS NULL;

-- 3. 加 NOT NULL + unique constraint (2026-04-XX)
ALTER TABLE oscal.system_security_plans
    ALTER COLUMN group_id SET NOT NULL;

CREATE UNIQUE INDEX uq_ssp_group_version
    ON oscal.system_security_plans (group_id, version_no);

CREATE INDEX ix_ssp_group_id
    ON oscal.system_security_plans (group_id);

安全性備註:backfill 為每個 SSP 給獨立 group_id,(group_id, version_no=1) 全表唯一, unique index 不會破。如果未來重跑此 migration(例如資料還原後再跑),需先確認沒有 殘留的 group_id IS NULL row。

6.2 既有資料行為

  • 所有現有 SSP 自動成為 v1(version_no=1)
  • 所有現有 AP 繼續指向現有 SSP(= v1)→ 無需動 assessment_plans.ssp_id
  • 既有 Round 2 資料的 bug 不回頭修:之前已 launch 過 Round 2 的專案, AP1/AP2 仍共用 v1,他們的歷史輪次資料污染問題無從還原(無從得知 AP1 / AP2 各自當時看到什麼)
  • 從本變更上線後的下一次 launch_new_round 開始:才會 clone v2,從那時起 freezing 生效

6.3 Pre-production 條件

User 確認目前還在開發階段未上線,可放寬 migration 嚴格度。 不需要為「修復歷史污染資料」做特殊處理,按 6.2 邏輯處理即可。


7. Edit Lockdown

項目 決策
過往 AP 鎖的 SSP 版本,FE 是否禁止編輯? FE 控管(過往 AP 頁面本來就唯讀,自然鎖死)
Backend 是否加 AP-state 檢查? 不加(YAGNI,相信 FE caller)

未來若有需求(例如多人共用、需要防誤操作),可在 SSP write APIs 加上: 「resolve 該 SSP 版本對應的 AP,若 AP status ∈ {archived, completed} 則拒絕」。 本次不做。


8. Out of Scope

  • 版本選擇器 UI(歷史已可從過往 AP 查)
  • Backend AP-state write lockdown(YAGNI)
  • File body GC(之前討論過,留未來)
  • Profile 變更導致的 SSP 重建(換 baseline 是另一個 use case)
  • Cross-version diff / 比對 UI
  • Sync template updates:module_frame template 中途新增/修改的 default 不會自動進新版本 SSP。未來若有需求另做明確 user action

9. Testing Strategy

9.1 Unit / Integration tests

  • test_oscal_project_service_launch_new_round.py
    • launch_new_round → 新 SSP 版本 row 建立,version_no = old + 1
    • clone 完整:CI / CIO / Pool / Mapping / system_characteristic / mapping 都有對應新版本 row
    • 編輯新版本內容 → 舊版本不受影響
    • mapping context_id 正確重對映(apc/apt/pool_doc id 對齊)
  • 既有 test_oscal_project_service_template_copy_integration.py 中 Round 2 freeze 相關測試 → 改寫為新流程
  • 新增測試:v1 內容 → launch v2 → 確認 v2 的內容是 v1 當下狀態的 deep copy(非 reference)

9.2 Migration tests

  • 針對 backfill SQL 寫驗證:執行後每個 SSP 都有 group_id、version_no=1
  • Unique constraint 測試:嘗試插入相同 group_id + version_no 應失敗

9.3 E2E(compliance-manager-test 專案)

  • 啟動專案 → AP1 編輯程序書 → launch Round 2 → AP2 編輯程序書 → 切回 AP1 看,原資料不變
  • AP1 刪除程序書 → AP2 程序書不受影響(測試池版本獨立)

10. Affected Files (預估)

Backend

  • jedi-oscal 套件內 OscalSystemSecurityPlan(路徑:~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/infra/model/ssp/ssp.py):加 group_idversion_no權威路徑為套件內,主專案不另存 local copy;改完套件需走進版流程(user 明確指示需批准)
  • app/project/service/oscal_project_service.pylaunch_new_round 重寫 clone 邏輯
  • 新增 app/oscal/service/ssp_versioning_service.py:封裝整份 clone
  • infra/grc/repository/ssp_document_pool_query.py:保留,邏輯不動
  • scripts/sql/2026-04-XX-ssp-versioning.sql:migration
  • 移除 _clone_control_doc_mappings_clone_ssp_objectives 內聯邏輯

Frontend

  • 全 grep project.ssp_uid 用法 → 改 currentAp.ssp_uid
  • launch_new_round 後跳轉用新 ssp_uid

Docs

  • docs/changelog/2026-04-XX-ssp-versioning.md:新增變更紀錄
  • docs/api/module-frame/design.md:§6.7 從 Option X 更新為「整份 SSP 版本化」
  • docs/api/oscal/(如有):補 SSP versioning 章節

Tests

  • test/test_oscal_project_service_launch_new_round.py:新增 SSP 版本化測試
  • test/test_oscal_project_service_template_copy_integration.py:改寫 Round 2 freeze 測試

11. Risk & Open Questions

11.1 Risk

Risk Mitigation
Clone 流程漏 clone 某個子表 在實作前先列完整子表清單,code review 對表
Cross-table id 重對映出錯(apc/apt/pool_doc) 用 dict lookup pattern + unit test 覆蓋
FE 多處用 project.ssp_uid 沒改全 → bug 「永遠看 v1」 全 grep + 人工 review
jedi-oscal SSP model 在套件內,加欄位需發新版 確認改動範圍,必要時走套件進版流程

11.2 Day-1 Plan Tasks(plan 寫之前要先解的)

下列三項在 implementation plan 第一階段就要驗證 / 解決,不是 ambient open questions:

  1. jedi-oscal 套件進版流程OscalSystemSecurityPlan 在套件內,加 group_id/version_no 需動套件。Plan 第一步:跟 user 確認套件進版可批准,再規劃實作順序(套件 → 主專案)
  2. oscal_metadata / oscal_documents 不 clone 的最終確認 — 讀套件內 model 跟使用該欄位的所有地方,確認這兩張表是「文件 origin metadata」性質 (非系統識別),跨 version 共享不會破壞語意
  3. ProjectSystemCharacteristicMapping cascade 行為 — 確認其 FK 跟 cascade 設定, clone 時要不要同步 ON DELETE CASCADE、unique constraint 跟 (project_id, characteristic_id) 的關係,避免 v2 clone 時撞重複鍵