每輪稽核 (AP) 1:1 對應一個 SSP 版本,使每輪稽核時的 SSP 狀態能完整保留為 audit trail, 並避免「編輯本輪 SSP 影響歷史輪次」的資料污染問題。
狀態:Approved(待 implementation plan) Date:2026-04-26 Branch:feature/google-drive-sync 相關 commits:a96b621(前次 Round 2 freeze 部分實作,本設計會吸收併入)
目前 schema 設計:
system_security_plans (SSP)assessment_plans (AP,代表多輪稽核)control_implementations, ssp_control_implementation_objectives, ssp_reference_documents 池, ssp_reference_document_mappings, system_characteristic, system_implementations) 全部掛在 SSP 主表底下導致的 bug:
implementation_description) → AP1 的紀錄也被改寫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 | 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 上輪填寫的「實施現況」與程序書,符合多輪稽核「追蹤改善」的業務語意 |
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);assessment_plans.ssp_id 含義變成「鎖到的 SSP 版本」(schema 不動)| 概念 | 對應欄位 | 含義 |
|---|---|---|
| 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 版本」。
start_oscal_project)不變。建立第一份 SSP 時:
group_id = gen_random_uuid()version_no = 1AP1.ssp_id = v1.idlaunch_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 重抓。
理由:
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 唯一」之類的隱含約束。
不變。FE 從 currentAp.ssp.uid 拿 ssp_uid,打 /ssp/<uid>/... 系列 API, backend 直接寫到該版本。因為每個版本是獨立 row,編 v2 不會影響 v1。
不變。FE「過往 AP 紀錄」頁面已用 AP context 拿 ssp_uid,自然指到該 AP 鎖的版本。 歷史 AP 因為已歸檔,FE 本來就只給看不給編 → 自然唯讀。
所有 /ssp/<ssp_uid>/... URL contract — ssp_uid 改成版本 uid 即可,handler 邏輯不動:
GET/POST/DELETE /ssp/<uid>/document-poolGET/POST/DELETE /ssp/<uid>/control-implementation/<ctrl_id>/document-mappingsGET/POST/DELETE /ssp/<uid>/control-implementation/<ctrl_id>/objective/<stmt_id>/document-mappingsGET/POST/PUT /ssp/<uid>/control-implementations/...GET/POST /ssp/<uid>/control-implementations/export|import| Endpoint | 改動 |
|---|---|
POST /grc/projects/<uid>/launch-new-round |
response 加新 SSP 版本資訊(new_ssp_uid、new_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。如果是後者,要改成前者。 |
| 層 | 改動 |
|---|---|
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_id、version_no 欄位 |
新增 SspVersioningService(建議) |
封裝「clone SSP 整份到新版本」的邏輯,給 launch_new_round 呼叫 |
| 頁面 | 改動 |
|---|---|
任何從 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。
-- 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。
assessment_plans.ssp_idUser 確認目前還在開發階段未上線,可放寬 migration 嚴格度。 不需要為「修復歷史污染資料」做特殊處理,按 6.2 邏輯處理即可。
| 項目 | 決策 |
|---|---|
| 過往 AP 鎖的 SSP 版本,FE 是否禁止編輯? | FE 控管(過往 AP 頁面本來就唯讀,自然鎖死) |
| Backend 是否加 AP-state 檢查? | 不加(YAGNI,相信 FE caller) |
未來若有需求(例如多人共用、需要防誤操作),可在 SSP write APIs 加上: 「resolve 該 SSP 版本對應的 AP,若 AP status ∈ {archived, completed} 則拒絕」。 本次不做。
test_oscal_project_service_launch_new_round.py:
test_oscal_project_service_template_copy_integration.py 中 Round 2 freeze 相關測試 → 改寫為新流程OscalSystemSecurityPlan(路徑:~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/infra/model/ssp/ssp.py):加 group_id、version_no — 權威路徑為套件內,主專案不另存 local copy;改完套件需走進版流程(user 明確指示需批准)app/project/service/oscal_project_service.py:launch_new_round 重寫 clone 邏輯app/oscal/service/ssp_versioning_service.py:封裝整份 cloneinfra/grc/repository/ssp_document_pool_query.py:保留,邏輯不動scripts/sql/2026-04-XX-ssp-versioning.sql:migration_clone_control_doc_mappings、_clone_ssp_objectives 內聯邏輯project.ssp_uid 用法 → 改 currentAp.ssp_uidlaunch_new_round 後跳轉用新 ssp_uiddocs/changelog/2026-04-XX-ssp-versioning.md:新增變更紀錄docs/api/module-frame/design.md:§6.7 從 Option X 更新為「整份 SSP 版本化」docs/api/oscal/(如有):補 SSP versioning 章節test/test_oscal_project_service_launch_new_round.py:新增 SSP 版本化測試test/test_oscal_project_service_template_copy_integration.py:改寫 Round 2 freeze 測試| 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 在套件內,加欄位需發新版 | 確認改動範圍,必要時走套件進版流程 |
下列三項在 implementation plan 第一階段就要驗證 / 解決,不是 ambient open questions:
OscalSystemSecurityPlan 在套件內,加 group_id/version_no 需動套件。Plan 第一步:跟 user 確認套件進版可批准,再規劃實作順序(套件 → 主專案)oscal_metadata / oscal_documents 不 clone 的最終確認 — 讀套件內 model 跟使用該欄位的所有地方,確認這兩張表是「文件 origin metadata」性質 (非系統識別),跨 version 共享不會破壞語意ProjectSystemCharacteristicMapping cascade 行為 — 確認其 FK 跟 cascade 設定, clone 時要不要同步 ON DELETE CASCADE、unique constraint 跟 (project_id, characteristic_id) 的關係,避免 v2 clone 時撞重複鍵