狀態: Design — 待實作 建立日期: 2026-04-25 作者: raymond + Claude Code (brainstorming session)
目前合規資源庫(module_frames)只存「基本資訊 + OSCAL Profile 包裝 + BPMN 工作流」。每次啟動專案後,使用者必須在 ProjectPlanning 介面為每個控制項與評估目標(AO)逐一填寫現況說明(implementation_description)與程序書(reference documents),每個專案都重複一次。
把「現況說明 + 程序書」抽成 module_frame 層的模板預設值:
詳見「§9 Out of scope」。最重要的是:
| 決策 | 結論 | 理由 |
|---|---|---|
| 預設值粒度 | 控制項層 + AO 層(兩層都做) | 對齊 SSP 端 ssp_control_implementations + ssp_control_implementation_objectives 兩層結構 |
| OSCAL 對應 | Component Definition Lite(掛在 module_frame 下) | OSCAL 標準的「跨 SSP 重用」機制是 Component Definition;不違反 OSCAL「SSP per system」核心語意 |
| 程序書儲存 | 真實檔案上傳(沿用 SSP 結構) | 模板真的「自帶程序書」,啟動時 SSP 直接共享 file_id 不複製檔案本體 |
| 程序書池 scope | Per-Module-Frame(不跨模板共享) | 對齊 SSP ssp_reference_documents per-SSP pattern;模板自包含 |
| 啟動專案複製策略 | Snapshot at start(不 live reference) | 安全:稽核中不會被 template 改動意外影響;OSCAL Component Definition 模式即如此 |
| 編輯 UI 結構 | 保留 2-step Dialog 建立 + 新詳情頁編輯 | 建立流程輕、編輯流程重,分開最自然 |
| AO statement_identifier 來源 | oscal.catalog_control_assessments.uid |
spike 已驗證;對拷時 join 到 ap_task.uid |
| Multi-AP 共用 SSP 模型 | 不影響本次設計 | 啟動時 deep copy 一次到 SSP,後續跨 round 共用,與 template 無關 |
compliance.module_frame_control_defaults控制項層預設值,對應 OSCAL Component Definition 的 implemented-requirements,啟動時對拷至 SSP oscal.system_security_plan_control_implementations。
CREATE TABLE compliance.module_frame_control_defaults (
id SERIAL PRIMARY KEY,
uid UUID DEFAULT gen_random_uuid() NOT NULL UNIQUE,
module_frame_id INTEGER NOT NULL
REFERENCES public.module_frames(id) ON DELETE CASCADE,
control_identifier VARCHAR(100) NOT NULL, -- e.g. AC.L1-3.1.1
implementation_status VARCHAR(30) DEFAULT 'unknown', -- 預設值
implementation_description TEXT, -- 現況說明預設文字
responsible_role VARCHAR(100), -- 負責角色預設
control_origination VARCHAR(50) DEFAULT 'organization',
remarks TEXT,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
created_user VARCHAR(50),
updated_user VARCHAR(50),
CONSTRAINT uq_mfcd_module_frame_control UNIQUE (module_frame_id, control_identifier)
);
CREATE INDEX ix_mfcd_module_frame_id ON compliance.module_frame_control_defaults (module_frame_id);行為:
module_frames 的 is_delete flag + JOIN 過濾;hard delete 走 ON DELETE CASCADE。compliance.module_frame_control_objective_defaultsAO(Assessment Objective)層預設值,對應 SSP oscal.ssp_control_implementation_objectives。
CREATE TABLE compliance.module_frame_control_objective_defaults (
id SERIAL PRIMARY KEY,
uid UUID DEFAULT gen_random_uuid() NOT NULL UNIQUE,
module_frame_id INTEGER NOT NULL
REFERENCES public.module_frames(id) ON DELETE CASCADE,
control_default_id INTEGER NOT NULL
REFERENCES compliance.module_frame_control_defaults(id) ON DELETE CASCADE,
control_identifier VARCHAR(100) NOT NULL,
statement_identifier VARCHAR(100) NOT NULL, -- = catalog_control_assessment.uid
implementation_status VARCHAR(50),
implementation_description TEXT,
remarks TEXT,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
created_user VARCHAR(50),
updated_user VARCHAR(50),
CONSTRAINT uq_mfcod_ctrl_default_stmt UNIQUE (control_default_id, statement_identifier)
);
CREATE INDEX ix_mfcod_module_frame_id ON compliance.module_frame_control_objective_defaults (module_frame_id);
CREATE INDEX ix_mfcod_control_default_id ON compliance.module_frame_control_objective_defaults (control_default_id);statement_identifier 設計:
catalog_control_assessments.uid(OSCAL catalog 層級的 stable key)ssp_control_implementation_objectives.statement_identifier 存 assessment_plan_tasks.uid(執行期生成)module_frame_control_objective_defaults → catalog_control_assessments → assessment_plan_tasks → ssp_control_implementation_objectivesSoft FK 規範(按 CLAUDE.md 跨 schema 慣例):
statement_identifier 是 catalog_control_assessments.uid 的字串 soft reference(無 SQLAlchemy FK constraint,跨 schema)MODULE_FRAME_CONTROL_NOT_IN_PROFILEdenormalized module_frame_id:
module_frame_id (FK) 和 control_default_id (FK),後者可推導前者compliance.module_frame_reference_documents程序書池(per-module_frame),對應 SSP oscal.ssp_reference_documents。
CREATE TABLE compliance.module_frame_reference_documents (
id SERIAL PRIMARY KEY,
uid VARCHAR(36) DEFAULT gen_random_uuid() NOT NULL UNIQUE,
module_frame_id INTEGER NOT NULL
REFERENCES public.module_frames(id) ON DELETE CASCADE,
file_id INTEGER NOT NULL, -- jedi-file-upload tenant 內 file
title VARCHAR(255),
description TEXT,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
created_user VARCHAR(50),
updated_user VARCHAR(50)
);
CREATE INDEX ix_mfrd_module_frame_id ON compliance.module_frame_reference_documents (module_frame_id);
CREATE INDEX ix_mfrd_file_id ON compliance.module_frame_reference_documents (file_id);Scope 說明:
file_id 是 tenant-scoped(jedi-file-upload 既有設計),故同 tenant 內不同模板可挑同一份檔案重用(FE 從既有檔案池選即可)compliance.module_frame_reference_document_mappings多對多,讓同一份程序書可掛多個 control / objective default。
CREATE TABLE compliance.module_frame_reference_document_mappings (
id SERIAL PRIMARY KEY,
reference_document_id INTEGER NOT NULL
REFERENCES compliance.module_frame_reference_documents(id) ON DELETE CASCADE,
context_type VARCHAR(30) NOT NULL, -- 'control_default' | 'objective_default'
context_id INTEGER NOT NULL, -- → control_defaults.id 或 objective_defaults.id
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
created_user VARCHAR(50),
CONSTRAINT uq_mfrdm_doc_context UNIQUE (reference_document_id, context_type, context_id)
);
CREATE INDEX ix_mfrdm_context ON compliance.module_frame_reference_document_mappings (context_type, context_id);
CREATE INDEX ix_mfrdm_doc_id ON compliance.module_frame_reference_document_mappings (reference_document_id);對齊 SSP 端 oscal.ssp_reference_document_mappings:
context_type = 'control_implementation' / 'objective'context_type = 'control_default' / 'objective_default'最小改動 — 只加一個欄位:
ALTER TABLE oscal.system_security_plans
ADD COLUMN template_module_frame_id INTEGER NULL;用途:紀錄此 SSP 的源頭 module_frame(lineage tracking)。為將來 re-sync feature 預留。
✂️ 明確不加(避免 YAGNI):
previous_ssp_id(給未來 SSP-per-round 用,等那個 feature 自己加)is_user_modifiedon control_impl / objectives(給未來 re-sync 用,等那個 feature 自己加)
按 CLAUDE.md 規範:
scripts/sql/2026-XX-XX-module-frame-template-defaults.sql-- Date: 2026-XX-XX(2026-XX-XX) 註解GRANT SELECT, INSERT, UPDATE, DELETE ... TO cm_app; + sequence 授權範例(4 張新表 + 1 ALTER):
-- 1. 4 張新表 CREATE 略(見 §3.1)
-- 2. ALTER SSP table (2026-XX-XX)
ALTER TABLE oscal.system_security_plans
ADD COLUMN template_module_frame_id INTEGER NULL;
-- 3. CHECK constraint 限制 mappings 的 context_type 合法值 (2026-XX-XX)
ALTER TABLE compliance.module_frame_reference_document_mappings
ADD CONSTRAINT chk_mfrdm_context_type
CHECK (context_type IN ('control_default', 'objective_default'));
-- 4. GRANT 權限授予 cm_app (2026-XX-XX)
GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.module_frame_control_defaults TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.module_frame_control_defaults_id_seq TO cm_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.module_frame_control_objective_defaults TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.module_frame_control_objective_defaults_id_seq TO cm_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.module_frame_reference_documents TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.module_frame_reference_documents_id_seq TO cm_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.module_frame_reference_document_mappings TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.module_frame_reference_document_mappings_id_seq TO cm_app;按 §3.1.1 Lazy 預填策略:本次 migration 只建表 + 加欄位,不對既有資料做 backfill。
template_module_frame_id = NULL(unknown source),不影響任何邏輯新表的 uid 欄位有兩種型別:
module_frame_control_defaults.uid / module_frame_control_objective_defaults.uid 用 PostgreSQL UUIDmodule_frame_reference_documents.uid 用 VARCHAR(36)(沿用 SSP ssp_reference_documents 既有 convention,避免 jedi-file-upload 整合處要做型別轉換)JOIN 時注意型別轉換(如 ccai.uid::text = mfod.statement_identifier)。
POST /module-frame、GET /module-frame/{uid}、PUT /module-frame/{uid}、DELETE /module-frame/{uid}、Excel import endpoints 保持不變。
新欄位都不在建立 Dialog 階段填,建立完成後在新詳情頁編輯。
| Method | Path | 用途 |
|---|---|---|
GET |
/module-frame/{uid}/control-defaults |
列出該模板已設定的所有控制項預設值 |
GET |
/module-frame/{uid}/control-defaults/{control_identifier} |
取得單一控制項預設值(含其下 AO defaults + 程序書 mappings) |
PUT |
/module-frame/{uid}/control-defaults/{control_identifier} |
Upsert(lazy 第一次 PUT 即 INSERT) |
DELETE |
/module-frame/{uid}/control-defaults/{control_identifier} |
刪除(清空回未設定狀態,CASCADE 帶走 AO defaults) |
PUT 驗證流程(重要):
control_identifier 屬於該模板 profile 的 include_controls,否則回 MODULE_FRAME_CONTROL_NOT_IN_PROFILE (MODULE_FRAME_400001)Request body 範例 (PUT):
{
"implementation_status": "implemented",
"implementation_description": "本公司透過 ISMS-001 程序書管理...",
"responsible_role": "資安長",
"control_origination": "organization",
"remarks": "..."
}| Method | Path | 用途 |
|---|---|---|
GET |
/module-frame/{uid}/objective-defaults |
列出全部 AO 預設值 |
GET |
/module-frame/{uid}/objective-defaults/{objective_default_uid} |
取得單一 |
PUT |
/module-frame/{uid}/objective-defaults |
Upsert(body 含 control_identifier + statement_identifier=ccai_uid) |
DELETE |
/module-frame/{uid}/objective-defaults/{objective_default_uid} |
刪除 |
| Method | Path | 用途 |
|---|---|---|
GET |
/module-frame/{uid}/reference-documents |
列出程序書池(含每筆 mappings 概要) |
POST |
/module-frame/{uid}/reference-documents |
上傳新檔案(multipart/form-data) |
PUT |
/module-frame/{uid}/reference-documents/{doc_uid} |
更新 title / description |
DELETE |
/module-frame/{uid}/reference-documents/{doc_uid} |
刪除 doc + CASCADE 清 mappings(檔案本體保留) |
| Method | Path | 用途 |
|---|---|---|
POST |
/module-frame/{uid}/control-defaults/{control_identifier}/reference-documents |
把現有 doc_uid 掛到此控制項 |
DELETE |
/module-frame/{uid}/control-defaults/{control_identifier}/reference-documents/{doc_uid} |
解除此 mapping |
POST |
/module-frame/{uid}/objective-defaults/{objective_default_uid}/reference-documents |
掛到 AO |
DELETE |
/module-frame/{uid}/objective-defaults/{objective_default_uid}/reference-documents/{doc_uid} |
解除 |
| Method | Path | 用途 |
|---|---|---|
GET |
/oscal/catalog-controls/{control_uid}/assessments |
列出此 catalog control 的 catalog_control_assessment 清單,供 UI 列出可填的 AO |
→ jedi-oscal 已有 CatalogControlAssessmentRepoImpl,本層只做 API 包裝。回傳:
{ "code": 1, "data": [{ "uid": "...", "name": "Official", "description": "..." }, ...] }| Method | Path | 用途 |
|---|---|---|
GET |
/module-frame/{uid}/control-defaults/export |
匯出當前 control + AO defaults 為 Excel |
GET |
/module-frame/{uid}/control-defaults/template/download |
下載空白 Excel 範本(含模板的 control + AO 結構) |
POST |
/module-frame/{uid}/control-defaults/import/template |
上傳 Excel + 驗證 headers/格式 |
POST |
/module-frame/{uid}/control-defaults/import/verify |
驗證資料行(顯示 diff) |
POST |
/module-frame/{uid}/control-defaults/import |
確認匯入(bulk upsert) |
→ 完全 mirror SspImportRoute 的 endpoint 結構與 service 邏輯(fork ssp_control_impl_import_service.py)。
POST /oscal/projects/start:
_copy_template_to_ssp(),在建立 SSP 與 AP 之後呼叫按 CLAUDE.md 規範:
manager 角色(沿用 module_frame service 既有權限規範)@transaction 裝飾器module_frame_domain_service.get_one(uid) 驗證 + RLS 自動過濾 tenantcommon/code/module_frame_error_code.py(新檔)Error Code 命名(按 CLAUDE.md 規範):
class ModuleFrameErrorCode(BaseCode):
MODULE_FRAME_CONTROL_DEFAULT_NOT_FOUND = ("模板控制項預設值不存在", "MODULE_FRAME_404001")
MODULE_FRAME_OBJECTIVE_DEFAULT_NOT_FOUND = ("模板 AO 預設值不存在", "MODULE_FRAME_404002")
MODULE_FRAME_REFERENCE_DOC_NOT_FOUND = ("模板程序書不存在", "MODULE_FRAME_404003")
MODULE_FRAME_CONTROL_NOT_IN_PROFILE = ("控制項不屬於此模板的 profile", "MODULE_FRAME_400001")OscalProjectService.start_oscal_project 既有 Step 7.5 (oscal_project_service.py:272-318) 已經為每個 AP control 建立 SSP control_impl 空殼 row + 為每個 AP task (AO) 建立 SSP objective 空殼 row:
implementation_status="unknown", 其餘欄位 NULLstatement_identifier = task.task_code OR task.title OR str(task.id) (line 307),其餘欄位 NULL→ 我們的 _copy_template_to_ssp 在 Step 7.5 之後執行,必須 UPDATE 既有 row 不 INSERT 新 row。
uq_ssp_obj_ctrl_impl_stmt unique constraint新增方法 OscalProjectService._copy_template_to_ssp(module_frame_id, ssp_id, ap_id, curr_user),於 start_oscal_project 的 Step 7.5 之後執行。
-- Step 1: 控制項層 UPDATE in place
-- NULL 語意:模板若未填某欄位,保留 Step 7.5 的 default 值(用 COALESCE 防止 NULL 覆蓋有意義的 default)
UPDATE oscal.system_security_plan_control_implementations ci
SET
implementation_status = COALESCE(mfcd.implementation_status, ci.implementation_status),
implementation_description = COALESCE(mfcd.implementation_description, ci.implementation_description),
responsible_role = COALESCE(mfcd.responsible_role, ci.responsible_role),
control_origination = COALESCE(mfcd.control_origination, ci.control_origination),
remarks = COALESCE(mfcd.remarks, ci.remarks),
updated_user = :curr_user,
updated_at = now()
FROM compliance.module_frame_control_defaults mfcd
WHERE mfcd.module_frame_id = :module_frame_id
AND ci.system_security_plan_id = :ssp_id
AND ci.control_identifier = mfcd.control_identifier;
-- Step 2: AO 層 UPDATE in place(透過 ccai + ap_task 找到對應 ssp objective)
-- 關鍵:obj.statement_identifier 用 Step 7.5 的 convention(COALESCE task_code/title/id),不用 ap_task.uid
-- NULL 語意:同 Step 1,模板若未填則保留 Step 7.5 預設
UPDATE oscal.ssp_control_implementation_objectives obj
SET
implementation_status = COALESCE(mfod.implementation_status, obj.implementation_status),
implementation_description = COALESCE(mfod.implementation_description, obj.implementation_description),
remarks = COALESCE(mfod.remarks, obj.remarks),
updated_user = :curr_user,
updated_at = now()
FROM compliance.module_frame_control_objective_defaults mfod
JOIN oscal.catalog_control_assessments ccai
ON ccai.uid::text = mfod.statement_identifier
JOIN oscal.assessment_plan_tasks ap_task
ON ap_task.catalog_control_assessment_id = ccai.id
AND ap_task.assessment_plan_id = :ap_id
WHERE mfod.module_frame_id = :module_frame_id
AND obj.system_security_plan_id = :ssp_id
AND obj.control_identifier = mfod.control_identifier
AND obj.statement_identifier = COALESCE(ap_task.task_code, ap_task.title, ap_task.id::text);
-- Step 3a: 控制項層程序書(→ SSP mappings 表,新增 row)
-- 詳見 §5.4
-- Step 3b: AO 層程序書(→ SSP objective.reference_documents JSONB,UPDATE in place)
-- 詳見 §5.4
-- Step 4: 標記 SSP 來源
UPDATE oscal.system_security_plans
SET template_module_frame_id = :module_frame_id
WHERE id = :ssp_id;→ 整個對拷邏輯不 INSERT 任何 SSP control_impl / objective row,只 UPDATE Step 7.5 已建好的空殼。
→ 沒有 unique constraint 衝突風險。
→ 沒設模板 defaults 的控制項 / AO row 維持 Step 7.5 的空白狀態(行為與既有 100% 一致)。
session.bulk_insert_mappings 或 INSERT ... SELECT 形式批次處理(避免 100+ 單行 INSERT)OscalProjectService._copy_template_to_ssp() method(單一入口)module_frame_reference_documents.file_id 與 SSP 的 ssp_reference_documents.file_id 指向同一個 file_idmodule_frame_reference_documents row → 只刪 mapping,檔案本體保留;既有專案 SSP 仍可下載實際翻 ssp_control_implementation_service.py 後確認 SSP 端兩層程序書儲存方式不一致:
| 層級 | SSP 端儲存 | 對應 entity |
|---|---|---|
| Control 層 | oscal.ssp_reference_documents 池 + ssp_reference_document_mappings mapping 表(context_type='control_implementation') |
obj_entity 透過 mapping 表關聯 |
| Objective (AO) 層 | oscal.ssp_control_implementation_objectives.reference_documents inline JSONB array |
obj_entity.reference_documents 直接賦值 |
→ 對拷邏輯需要分兩支處理:
Step 3a: 控制項層程序書(→ SSP mappings 表)
-- 模板的 module_frame_reference_documents → ssp_reference_documents (context_type='control_implementation')
-- 模板的 module_frame_reference_document_mappings (context_type='control_default')
-- → ssp_reference_document_mappings (context_type='control_implementation', context_id=new_ssp_ci.id)
Step 3b: AO 層程序書(→ SSP objective JSONB)
-- 模板的 module_frame_reference_document_mappings (context_type='objective_default')
-- → 組成 [{file_id, name, ...}] JSONB array
-- → UPDATE ssp_control_implementation_objectives SET reference_documents = :jsonb_array
理由: 既有 SSP service 端 add_objective_document 直接操作 JSONB(ssp_control_implementation_service.py:158),改成 mappings 表會破壞既有 API。本 feature 不重構 SSP 端,只在對拷時做格式轉換。
既有 bug: _clone_ssp_objectives (oscal_project_service.py:563-630) 跨 round clone SSP objective 時沒有複製 reference_documents JSONB 欄位(line 616-626 只 copy implementation_status / implementation_description / remarks)。
→ 任何使用者在 round 1 加的 AO 層程序書(包括既有功能填的、不只本 feature 的)都會在 round 2 launch 時消失。
修法: 在 _clone_ssp_objectives line 623(remarks=...)之後、line 624(created_user=...)之前補一行:
reference_documents=getattr(source_obj, "reference_documents", None) or [],性質: 純 additive bug fix,零向後相容風險。修了之後所有 AO 程序書(不論模板帶來的或使用者自填的)跨 round 都不會掉。
列入本 feature scope — 不修的話本 feature 的「程序書帶入」承諾在 round 2 會破功,等同沒做。
ModuleFrame.vue 的「+ 新增」按鈕仍開現有 Dialog:
按「建立」→ 模板成形(預設值全空)→ Dialog 關閉 → 自動跳轉至新詳情頁。
ModuleFrameTemplateEditView.vueURL: /module-frame/:uid/template-edit
設計策略:高度 reuse ProjectPlanningView.vue 既有結構與元件
┌─ Header ───────────────────────────────────────────────────────────┐
│ [← 返回] 模板編輯:ISO27001 v1.0 │
│ [stats: 已設定 35/100 控制項 / 12/250 AO] │
│ [批次維護 ▾] [程序書池管理] │
└────────────────────────────────────────────────────────────────────┘
┌─ 左 Tree (panel-sidebar) ────┐ ┌─ 右 Content ──────────────────────┐
│ - search │ │ - control 編輯區:description / │
│ - group/control/AO 三層 │ │ status / responsible_role / │
│ - 狀態 icon (complete/partial/ │ │ control_origination / 備註 │
│ empty) │ │ - 程序書區塊(reuse Reference │
│ - badge: 已設定/總數 │ │ DocumentList) │
│ │ │ - AO 編輯區:相同樣式(Accordion) │
└───────────────────────────────┘ └────────────────────────────────────┘
從 ProjectPlanningView 直接 import:
SspStatusTag.vue — 狀態 tagReferenceDocumentList.vue — 程序書清單顯示DocumentPoolPanel.vue — 程序書池管理 DialogDocumentLinkDialog.vue — 從池選掛<Panel v-for="task in taskMap.get(...)">)canEditSsp / isPreparing / isApClosed 狀態判斷(模板永遠可編輯)control_origination 欄位(Planning 沒這欄位)— dropdown: organization | system-specific | customer-configured | inherited | sharedModuleFrameTemplateImportDialog.vue(fork SspImportDialog.vue)docs/changelog/2026-04-25-audit-scope-merge.md)並發/競爭寫入處理: v1 採 last-write-wins,無 etag/version 控制。理由:模板維運通常是少量 manager 序列編輯,並發寫入機率低;YAGNI。後續若實際遇到衝突,可加 If-Match: <updated_at> header 機制。
新增 src/service/ModuleFrameTemplateService.js,mirror SspService.js 的 API surface 但底層打 module-frame endpoint:
export default {
listControlDefaults(moduleFrameUid) { ... },
updateControlDefault(moduleFrameUid, controlIdentifier, payload) { ... },
listObjectiveDefaults(moduleFrameUid) { ... },
upsertObjectiveDefault(moduleFrameUid, payload) { ... },
// 程序書池
listReferenceDocuments(moduleFrameUid) { ... },
uploadReferenceDocument(moduleFrameUid, file, meta) { ... },
deleteReferenceDocument(moduleFrameUid, docUid) { ... },
// mappings
attachDocToControl(moduleFrameUid, controlIdentifier, docUid) { ... },
detachDocFromControl(moduleFrameUid, controlIdentifier, docUid) { ... },
attachDocToObjective(moduleFrameUid, objectiveDefaultUid, docUid) { ... },
detachDocFromObjective(moduleFrameUid, objectiveDefaultUid, docUid) { ... },
// catalog AO 列舉
listCatalogControlAssessments(controlUid) { ... },
// 批次
exportControlDefaults(moduleFrameUid) { ... },
downloadImportTemplate(moduleFrameUid) { ... },
importControlDefaults(moduleFrameUid, file) { ... },
}src/config/locales/i18n/zh-tw/module-frame.json + en/module-frame.json 新增:
template_edit_title / template_edit_breadcrumb
stat_controls_filled / stat_aos_filled
label_implementation_description / label_implementation_status
label_responsible_role / label_control_origination / label_remarks
placeholder_unfilled / state_filled / state_unfilled
btn_back_to_list / btn_batch_maintain / btn_document_pool
toast_autosaved / toast_document_uploaded / toast_document_attached
confirm_delete_document_with_mappings
... (大量可參考 lang.project_planning.* 並複製改名)
新功能對 ProjectCreate / ProjectSettings 完全透明:
ModuleFrame.vue 列表每筆模板加「編輯預設值」按鈕,導向 /module-frame/:uid/template-edit。
Project (compliance.projects)
↓ 1:1
SSP (oscal.system_security_plans) ← 系統基線,跨 round 共用、mutable
↑ N:1
AP_1 (Round 1) → ssp_id 軟參照同一份 SSP
AP_2 (Round 2) → ssp_id 軟參照同一份 SSP
...
launch_new_round 邏輯:必須舊 AP closed → 新 AP profile_id + ssp_id 沿用舊 AP → AP controls/tasks 重新從 Profile snapshot(每 round 新生 ap_task.uid)。
_clone_ssp_objectives 處理重要: SSP objectives 的 statement_identifier 存的是 ap_task.uid,每 round 新生。但 launch_new_round 已經有 _clone_ssp_objectives() 機制處理跨 round mapping(oscal_project_service.py:563):
task.title 當穩定 key 建立舊→新 ap_task.uid 對照→ Round 2 啟動後,SSP 中會多出新的 objective row 對應新 task uid,舊 round 的 objective row 仍保留(為 future audit history 預留)。
Template → SSP 啟動時 UPDATE in place 在 round 1 啟動時執行一次(不是 INSERT):
task.task_code OR task.title OR str(task.id))_clone_ssp_objectives 用 task.title 對應,自動把 description / status / remarks / reference_documents (見 §5.5 修補) 複製到新 task uid 上→ 本設計在 multi-round 下完全成立,搭配 §5.5 的一行 fix 即可確保程序書跨 round 不消失。
註:
_clone_ssp_objectives用task.title當穩定 key 是既有設計選擇。理論上用catalog_control_assessment_id作為 stable key 更穩固(title 可能因為翻譯/編輯而變動),但這是既有 launch_new_round 的設計議題,不在本次 scope。
| Spike 項目 | 解開時機 | 決議 / 假設 |
|---|---|---|
init_ap_controls_from_profile 建立的「Evidence upload」fallback control(沒 catalog_control_assessment)— 對應的模板 AO 該怎麼處理 |
spike DONE(design 階段已決) | 不支援:fallback control 沒有 catalog_control_assessment,因此模板層不允許為其新增 AO defaults。對拷邏輯用 LEFT JOIN(不是 INNER JOIN),對沒 CCA 的 control 自然無 AO row 可拷;對拷完成後 log 一行 INFO(「N controls had no CCA, AO defaults skipped」),不算錯誤。模板 UI 對沒 CCA 的 control 不顯示 AO 編輯區塊。 |
模板 clone (clone_module_frame) 時要不要連 control_defaults / objective_defaults / reference_documents 一起 clone |
實作 clone 擴充前 | 假設:clone 時連 defaults + 程序書 mapping 一起複製(檔案 file_id 共享);獨立 task 不在 v1 必做。 |
驗證 assessment_plan_tasks.catalog_control_assessment_id 與 oscal.catalog_control_assessments.uid 的索引存在 |
實作對拷邏輯前 | 預期既有索引足夠;若不足,migration 需補。 |
| 項目 | 原因 | 列入 |
|---|---|---|
| SSP-per-round | 影響範圍跨 dashboard / SSP detail / POA&M / evidence,需獨立 feature | Phase 2 backlog |
| SSP 版本控制 | 跟 SSP-per-round 一起做 | Phase 2 backlog |
| AP per-round scope 變化 | OSCAL reviewed-controls 標準功能,獨立 feature |
Phase 2 backlog |
| Re-sync from template(Pattern C) | YAGNI;等真實「template 改了想推到 project」需求出現再做 | Phase 2 backlog |
| AR snapshot SSP 內容 | 由 SSP-per-round 一併解決 | Phase 2 backlog |
| i18n 翻譯表 | v1 純 TEXT 欄位,未來加 trans 表向前相容 | Phase 2 backlog |
| Tenant 共用程序書池 | YAGNI;目前 per-module_frame 設計與 SSP 端對齊 | 視需求評估 |
SspImportDialog.vue 抽象成 generic 元件 |
過度設計,本次 fork 一份即可 | Phase 2 backlog |
| Promote project SSP to template | 不在心智模型內 | 已棄 |
| # | 風險 | 決策 |
|---|---|---|
| 1 | previous_ssp_id 預留欄位 YAGNI |
✅ 砍 |
| 2 | is_user_modified 預留欄位 YAGNI |
✅ 砍 |
| 3 | AO statement_identifier 來源 | ✅ A: 模板層用 catalog_control_assessment.uid,SSP 端對拷 join |
| 4 | control_origination 欄位 |
✅ 加到 module_frame_control_defaults |
| 5 | Lazy vs Eager 預填 | ✅ Lazy + UI fallback「未設定」 |
| 6 | 軟刪除一致性 | ✅ 新表不加 is_delete,靠 CASCADE + JOIN 過濾 |
| 7 | i18n | ✅ v1 不做,欄位 TEXT 留向前相容 |
| 8 | 對拷邏輯封裝 | ✅ _copy_template_to_ssp() 共用 service + unit test |
| 9 | 啟動時對拷效能 | ✅ Bulk insert |
| 10 | jedi-oscal 進版 | ✅ 確認可進版 |
| 11 | 程序書池 scope | ✅ Per-Module-Frame |
| 12 | 批次上傳元件抽象 | ✅ A: fork SspImportDialog |
本 feature 的核心承諾:對既有功能、既有資料、既有 API 完全不影響。逐項保證如下:
| 既有 API | 影響 | 保證 |
|---|---|---|
POST /module-frame (建立模板 Dialog) |
無 | request/response 完全不變;新欄位都不在建立階段填 |
GET /module-frame/{uid} (取得模板) |
無 | 既有 response shape 不變;新增欄位走獨立新 API |
PUT /module-frame/{uid} (更新基本資訊) |
無 | 不更新新表 |
DELETE /module-frame/{uid} (軟刪除) |
無 | 既有軟刪除邏輯不變;新表 row 跟著 module_frame 軟刪除(query 時 JOIN filter) |
POST /module-frame/clone |
spike 後決定(v1 預設一起 clone) | 不破壞既有 clone 行為 |
Excel import(/module-frame/import/*) |
無 | 沿用既有流程 |
POST /oscal/projects/start |
request/response 完全不變 | 內部多一步 _copy_template_to_ssp();對 caller 透明 |
POST /grc/projects/{uid}/launch-new-round |
無 | 完全不改;既有 _clone_ssp_objectives 接手 round 維護 |
所有 SSP 端 API(PUT /ssp/...、程序書 CRUD、AO update) |
無 | 不重構 SSP 端任何邏輯 |
| 所有 ProjectPlanning / ProjectCreate / ProjectSettings FE 流程 | 無 | 不改任何既有 view |
module_frames 表資料 完全不動assessment_plans / assessment_plan_tasks 資料 完全不動oscal.system_security_plans.template_module_frame_id 為 NULL(對既有 SSP 是 unknown source,不影響任何既有邏輯)對於沒有設定任何 defaults 的 module_frame(即所有既有模板):
_copy_template_to_ssp() 內 UPDATE 的 WHERE clause 對應 0 筆 (因為 module_frame_control_defaults 表為空) → 沒任何 row 被改→ 既有專案啟動完全不會看到差異,只有「模板已設過 defaults」的新專案才會看到自動帶入內容。
關鍵設計:UPDATE in place 而非 INSERT(見 §5.0) — 因為 Step 7.5 已經建好空殼 row,我們只是把 NULL 欄位填上模板內容,不額外 INSERT 新 row,故沒有 unique constraint 衝突風險。
launch_new_round 主要邏輯完全不改_clone_ssp_objectives 補一行 copy reference_documents JSONB(見 §5.5)
OscalSystemSecurityPlan model 加 template_module_frame_id 欄位(nullable)實作完成後必須驗證以下既有行為完全不變:
_copy_template_to_ssp 共存:模板有 defaults 啟動 → SSP control_impl / objective row 數量 = Step 7.5 該建的數量(無 duplicate row、無 unique constraint 衝突)_copy_template_to_ssp 在 start_oscal_project 的 @transaction scope 內新增:
compliance.module_frame_control_defaults (table)compliance.module_frame_control_objective_defaults (table)compliance.module_frame_reference_documents (table)compliance.module_frame_reference_document_mappings (table)domain/module_frame/entity/... + infra/module_frame/repository/... + mapper/... 一整套 DDD 層app/module_frame/service/module_frame_control_default_service.pyapp/module_frame/service/module_frame_objective_default_service.pyapp/module_frame/service/module_frame_reference_document_service.pyapp/module_frame/service/module_frame_template_import_service.pyapi/module_frame/routes/control_default_route.pyapi/module_frame/routes/objective_default_route.pyapi/module_frame/routes/reference_document_route.pyapi/module_frame/routes/template_import_route.pyapi/oscal/routes/catalog_control_assessment_route.py(如尚未有)common/code/module_frame_error_code.py (新檔)scripts/sql/2026-XX-XX-module-frame-template-defaults.sql修改:
oscal.system_security_plans 加 template_module_frame_id 欄位(jedi-oscal 進版)app/project/service/oscal_project_service.py 加 _copy_template_to_ssp() 並在 start_oscal_project 流程中呼叫app/oscal/service/... 暴露 catalog_control_assessments 列舉 service(如尚未有)不動:
新增:
src/views/module_frame/ModuleFrameTemplateEditView.vuesrc/components/grc/ModuleFrameTemplateImportDialog.vue (fork SspImportDialog)src/service/ModuleFrameTemplateService.jssrc/config/router/index.js 加新 route /module-frame/:uid/template-editmodule-frame.json zh-tw + en)修改:
src/views/module_frame/ModuleFrame.vue 列表加「編輯預設值」按鈕src/config/api/api.js 加新 endpoint 常數不動:
ProjectCreateView / ProjectSettingsView / ProjectPlanningViewOscalSystemSecurityPlan model 加 template_module_frame_id 欄位按 CLAUDE.md 規範,本次需產出:
docs/features/FR-018-2604-module-frame-template-defaults/design.md(本文件)docs/features/FR-018-2604-module-frame-template-defaults/implementation-plan.md(writing-plans 階段)docs/changelog/2026-XX-XX-module-frame-template-defaults.md(實作完成後)| 階段 | 工作量 |
|---|---|
| BE: schema migration + DDD layer (4 entities × entity/mapper/repo if/repo impl/service) | 3 天 |
| BE: 5 services + routes + import/export | 2 天 |
| BE: 對拷邏輯(含 control + objective 雙路徑 + JSONB 轉換) | 1 天 |
| BE: jedi-oscal 改欄位 + 進版發佈 | 0.5 天 |
| FE: TemplateEditView + Service + Dialog fork + i18n + route | 5.5 天 |
| 整合測試(BE unit + FE 整合 + 對拷端到端驗證) | 2 天 |
| 文件交付(changelog) | 0.5 天 |
| 總計:~14-15 天 |
| # | 問題 | 解法方向 |
|---|---|---|
| Q1 | 模板 clone 時 defaults / 程序書 mapping 是否一起 clone? | 預設「是」,需在 plan 確認 |
| Q2 | Catalog 後續更新導致 catalog_control_assessment 變動 → 模板層 statement_identifier 變孤兒,UX 該如何提示? | UI 顯示「目標已被 catalog 移除」,提供「移除預設值」按鈕 |
| Q3 | assessment_plan_tasks.catalog_control_assessment_id 索引是否存在? |
實作前 \d 確認,缺則 migration 補 |
| Q4 | jedi-oscal 進版號 (minor / patch)? | minor bump(schema 變更,已決) |
scripts/sql/ssp_control_implementation_migration.sqlscripts/sql/stage5_migration.sql + scripts/sql/ssp_document_pool_migration.sqlprompt/oscal_project_start_flow.mdcompliance-manager-fe/src/views/project/ProjectPlanningView.vue~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/