For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
Goal: 將 SSP 程序書從「直接掛在 control/AO」改為「SSP 級別程序書池 + 多對多關聯」,同一份程序書可關聯多個控制項/AO,並整合 Excel 批次匯入。
Architecture: 現有 ssp_reference_documents 表改為程序書池(context_type='ssp'),新增 ssp_reference_document_mappings 表做多對多關聯。程序書池 CRUD 複用現有 domain service,關聯操作新增 infra 層。Excel 匯出新增「參考程序書」欄位。
Tech Stack: SQLAlchemy、Flask-RESTful、Marshmallow、openpyxl
之前: 程序書直接上傳到控制項或 AO(一對一,重複上傳) 現在: 程序書上傳到 SSP 級別的池子(一次),再關聯到多個控制項/AO(多對多)
Step 1: 管理者進入 SSP 程序書管理
→ 批次上傳程序書到池(拖拽多檔上傳)
→ 每份程序書可填寫描述/編號
Step 2: 在控制項/AO 頁面關聯程序書
→ 點擊「關聯程序書」→ Dialog 顯示池中所有程序書
→ 多選 → 確認 → 建立關聯
Step 3: 或用 Excel 批次匯入
→ 匯出 Excel 多一欄「參考程序書」
→ 填入程序書名稱(逗號分隔)
→ 匯入時自動從池中比對並建立關聯
GET /api/1.0/ssp/<ssp_uid>/document-pool
→ 列出池中所有程序書
POST /api/1.0/ssp/<ssp_uid>/document-pool
→ 上傳程序書到池
Body: { "documents": [{"file_uid": "xxx", "description": "資訊安全政策 v3.0"}] }
DELETE /api/1.0/ssp/<ssp_uid>/document-pool/<doc_uid>
→ 從池刪除(cascade 刪除所有關聯)
池清單 Response:
{
"status": true,
"data": [
{
"uid": "doc-uuid",
"file_uid": "file-uuid",
"file_name": "資訊安全政策v3.0.pdf",
"file_size": 1024000,
"description": "資訊安全政策 v3.0",
"mapping_count": 5,
"created_at": "2026-03-29T10:00:00"
}
]
}GET /api/1.0/ssp/<ssp_uid>/control-implementation/<ctrl_id>/document-mappings
→ 列出該控制項關聯的程序書
POST /api/1.0/ssp/<ssp_uid>/control-implementation/<ctrl_id>/document-mappings
→ 批次建立關聯
Body: { "document_uids": ["doc-uid-1", "doc-uid-2"] }
DELETE /api/1.0/ssp/<ssp_uid>/control-implementation/<ctrl_id>/document-mapping/<doc_uid>
→ 取消關聯
GET /api/1.0/ssp/<ssp_uid>/control-implementation/<ctrl_id>/objective/<stmt_id>/document-mappings
POST /api/1.0/ssp/<ssp_uid>/control-implementation/<ctrl_id>/objective/<stmt_id>/document-mappings
DELETE /api/1.0/ssp/<ssp_uid>/control-implementation/<ctrl_id>/objective/<stmt_id>/document-mapping/<doc_uid>
(格式同控制項關聯)
ssp_reference_document_mappings 表-- Date: 2026-03-29
-- 1. 新增程序書多對多關聯表 (2026-03-29)
CREATE TABLE IF NOT EXISTS oscal.ssp_reference_document_mappings (
id SERIAL PRIMARY KEY,
reference_document_id INTEGER NOT NULL
REFERENCES oscal.ssp_reference_documents(id) ON DELETE CASCADE,
context_type VARCHAR(30) NOT NULL,
context_id INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_user VARCHAR(50),
CONSTRAINT uq_ssp_ref_doc_mapping UNIQUE (reference_document_id, context_type, context_id)
);
CREATE INDEX ix_ssp_ref_doc_mapping_context ON oscal.ssp_reference_document_mappings (context_type, context_id);
CREATE INDEX ix_ssp_ref_doc_mapping_doc_id ON oscal.ssp_reference_document_mappings (reference_document_id);
-- 2. 權限授予 (2026-03-29)
GRANT SELECT, INSERT, UPDATE, DELETE ON oscal.ssp_reference_document_mappings TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE oscal.ssp_reference_document_mappings_id_seq TO cm_app;
-- 3. 遷移現有資料:將 control_implementation/objective 的程序書轉為 ssp 池 + mapping (2026-03-29)
-- 步驟 a: 將現有記錄的 context_type 改為 'ssp',context_id 改為對應的 ssp.id
-- 步驟 b: 建立 mapping 記錄(保留原本的 control/AO 關聯)
-- 注意:此遷移需根據實際資料量決定是否分批執行context_type 值定義| context_type | 用途 | context_id |
|---|---|---|
ssp |
程序書池(SSP 級別) | system_security_plans.id |
control_implementation |
mapping 目標:控制項 | system_security_plan_control_implementations.id |
objective |
mapping 目標:AO | ssp_control_implementation_objectives.id |
| 檔案 | 職責 | 操作 |
|---|---|---|
scripts/sql/ssp_document_pool_migration.sql |
DB migration | Create |
infra/grc/model/ssp_reference_document_mapping.py |
ORM model | Create |
infra/grc/repository/ssp_document_pool_query.py |
infra 查詢(池清單含 mapping_count、mapping CRUD) | Create |
app/oscal/service/ssp_document_pool_service.py |
程序書池 + 關聯 app service | Create |
api/oscal/routes/ssp/ssp_document_pool_route.py |
池 CRUD + 關聯 CRUD routes | Create |
api/oscal/serializers/ssp/ssp_document_pool.py |
Marshmallow schemas | Create |
api/oscal/__init__.py |
註冊 routes | Modify |
di_containers/oscal/oscal_containers.py |
DI wiring | Modify |
app/oscal/service/ssp_control_impl_import_service.py |
Excel 匯出加「參考程序書」欄、匯入加關聯 | Modify |
Files:
scripts/sql/ssp_document_pool_migration.sqlFiles:
infra/grc/model/ssp_reference_document_mapping.py"""SSP 程序書關聯 — ORM Model"""
from datetime import datetime
from typing import Optional
from sqlalchemy import String, Integer, DateTime, ForeignKey, Index, func, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from jedi_common.session.database.declarative_base import Base
class SspReferenceDocumentMapping(Base):
"""程序書與控制項/AO 的多對多關聯"""
__tablename__ = "ssp_reference_document_mappings"
__table_args__ = (
UniqueConstraint("reference_document_id", "context_type", "context_id",
name="uq_ssp_ref_doc_mapping"),
Index("ix_ssp_ref_doc_mapping_context", "context_type", "context_id"),
Index("ix_ssp_ref_doc_mapping_doc_id", "reference_document_id"),
{"schema": "oscal", "comment": "SSP 程序書與控制項/AO 的多對多關聯"},
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
reference_document_id: Mapped[int] = mapped_column(
ForeignKey("oscal.ssp_reference_documents.id", ondelete="CASCADE"),
nullable=False,
)
context_type: Mapped[str] = mapped_column(String(30), nullable=False)
context_id: Mapped[int] = mapped_column(Integer, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), nullable=False)
created_user: Mapped[Optional[str]] = mapped_column(String(50))
# Relationship
reference_document = relationship("SspReferenceDocument", lazy="selectin")Files:
infra/grc/repository/ssp_document_pool_query.py提供:
list_pool(ssp_id) — 列出池中程序書,含 mapping_countlist_mappings(context_type, context_id) — 列出某控制項/AO 的關聯程序書add_mappings(doc_id, context_type, context_id) — 建立關聯remove_mapping(doc_id, context_type, context_id) — 刪除關聯get_pool_name_to_id_map(ssp_id) — 名稱 → doc_id mapping(Excel 匯入用)Files:
api/oscal/serializers/ssp/ssp_document_pool.py"""SSP 程序書池 Serializers"""
from marshmallow import Schema, fields
class DocumentPoolItemSchema(Schema):
"""程序書池項目"""
uid = fields.Str()
file_uid = fields.Str(allow_none=True)
file_name = fields.Str(allow_none=True)
file_size = fields.Int(allow_none=True)
description = fields.Str(allow_none=True)
mapping_count = fields.Int(dump_default=0)
created_at = fields.DateTime(allow_none=True)
class DocumentPoolUploadRequestSchema(Schema):
"""上傳程序書到池"""
documents = fields.List(fields.Nested({
"file_uid": fields.Str(required=True),
"description": fields.Str(load_default=None, allow_none=True),
}), required=True)
class DocumentMappingRequestSchema(Schema):
"""建立程序書關聯"""
document_uids = fields.List(fields.Str(), required=True)
class DocumentMappingItemSchema(Schema):
"""關聯的程序書"""
uid = fields.Str()
file_uid = fields.Str(allow_none=True)
file_name = fields.Str(allow_none=True)
file_size = fields.Int(allow_none=True)
description = fields.Str(allow_none=True)Files:
app/oscal/service/ssp_document_pool_service.py方法:
list_pool(ssp_uid) — 列出池add_to_pool(ssp_uid, documents, curr_user) — 上傳到池(context_type='ssp')delete_from_pool(doc_uid) — 從池刪除(cascade)list_control_mappings(ssp_uid, ctrl_id) — 列出控制項關聯add_control_mappings(ssp_uid, ctrl_id, doc_uids, curr_user) — 建立控制項關聯remove_control_mapping(doc_uid, ctrl_id) — 刪除控制項關聯list_ao_mappings(ssp_uid, ctrl_id, stmt_id) — 列出 AO 關聯add_ao_mappings(ssp_uid, ctrl_id, stmt_id, doc_uids, curr_user) — 建立 AO 關聯remove_ao_mapping(doc_uid, stmt_id) — 刪除 AO 關聯Files:
api/oscal/routes/ssp/ssp_document_pool_route.pyRoutes:
SspDocumentPoolRoute — GET(列出)+ POST(上傳)SspDocumentPoolDetailRoute — DELETE(刪除)SspControlDocumentMappingRoute — GET + POSTSspControlDocumentMappingDetailRoute — DELETESspAoDocumentMappingRoute — GET + POSTSspAoDocumentMappingDetailRoute — DELETEFiles:
api/oscal/__init__.pydi_containers/oscal/oscal_containers.pyFiles:
app/oscal/service/ssp_control_impl_import_service.py匯出:
匯入:
Files:
docs/changelog/2026-03-29-ssp-document-pool.mddocs/api_spec/2026-03-29-ssp-document-pool-frontend-spec.md