C3 — 受評範圍歸屬遷移到 SSP(廢除 project_device_mapping / project_information_systems)

級別:重型(Phase C 核心 refactor) 依賴:C2(SSP items endpoints 可讀寫,C3 改 derived 路徑依賴 SSP items 是 source of truth) 被依賴:C4(request schema 變動)、C5(UI 拿掉專案範圍編輯)、C6(FE 同步) 不可獨立 ship — 必須跟 C4 同 PR / 同 release


1. 目標

把「受評範圍」(哪些設備 / 資訊系統屬於這次稽核)從 project 層遷移到 SSP 層(per-AP)。實務上稽核範圍會隨 AP 週期變動(年度增減設備),語意上應該 per-SSP。

完成後

  • dto.audit_systems / dto.devices 由 current AP 對應 SSP 的 system_implementation_items derive 出來
  • compliance.project_device_mapping + compliance.project_information_systems 兩張表廢除
  • domain/associations/ + infra/associations/ + app/associations/ 整套 stack 拆除
  • jedi_information_system/...project_information_system_* 系列同樣拆除

2. 已拍板的決策

議題 決策
C3-Q1 Migration 策略 補洞 + idempotent:把 project_device_mapping / project_information_systems 既有資料 INSERT 到對應 current SSP 的 system_implementation_items,重複資料 skip
C3-Q2 廢除順序 兩階段:PR1 = service 改 derived + write endpoint 退 410 → 觀察 1-2 週 → PR2 = DROP TABLE + 拆 stack
C3-Q3 API breaking 回 410 Gone(寫操作);GET 改 derived 仍可用
C3-Q4 DROP scope 完整清:table + RLS policy + FK + index + sequence
C3-Q5 空資料 回空 list []

3. 兩階段執行詳述

Phase C3.PR1 — Service derived + write endpoints 退 410(第一個 PR

目的:FE 仍可拿到 device / audit_systems response,但不再寫 project mapping 表

動作清單

  1. 資料 backfill — 一次性 SQL:
    • project_device_mapping 既有資料 INSERT 到對應 current SSP 的 ssp_system_implementation_items(type='hardware')
    • project_information_systems 既有資料 INSERT(type='system',system_characteristic_id soft ref)
    • ON CONFLICT DO NOTHINGWHERE NOT EXISTS idempotent
  2. 改 Response derived
    • GrcProjectService.get_project()dto.devices / dto.audit_systems 改從 current SSP 的 items 抓
    • 沒 current SSP 或 SSP 沒 items → 回 []
  3. 改寫操作 endpoint 退 410
    • POST/PUT/DELETE /project-device → 回 410 Gone + msg「請至 SSP 編輯頁維護」
    • POST/PUT/DELETE /project/job-execution-device 同上
    • update_project Section 3/5 寫操作改 no-op(接受 request 但不寫,回 410 也可)
  4. 保留 mapping 表 — 觀察期,避免 rollback 風險
  5. 此 PR 同送 C4(拿掉 request schema 內 audit_systems / devices)

Phase C3.PR2 — DROP TABLE + 拆 stack(第二個 PR,1-2 週後

前提:觀察 PR1 後 user / FE 都正常運作,無 regression

動作清單

  1. DROP migration SQL
    • DROP TABLE compliance.project_device_mapping CASCADE(含 RLS / FK / index / sequence)
    • DROP TABLE compliance.project_information_systems CASCADE
  2. 拆 Python stack
    • git rm -r domain/associations/
    • git rm -r infra/associations/
    • git rm -r app/associations/
    • git rm jedi_information_system/...project_information_system_*
    • 對應 DI containers wiring 拿掉
  3. 清理 410 endpoint — 直接 404(移除 route 註冊)

4. Migration SQL 設計(PR1 含的)

4.1 補洞 SQL(idempotent)

檔名:scripts/sql/2026-MM-DD-c3-backfill-ssp-items-from-project-mappings.sql

-- Date: 2026-MM-DD
-- Purpose: C3.PR1 — 補洞:把 project_device_mapping / project_information_systems
--          既有資料 backfill 到對應 current SSP 的 ssp_system_implementation_items
-- 帳號:cmmgr(避 RLS)
-- 策略:idempotent — 重複資料 skip

BEGIN;

-- ─────────────────────────────────────────────────────────────────────────────
-- Step 1 — 找每個 project 對應的 current SSP(最新 active AP 的 SSP)(2026-MM-DD)
-- ─────────────────────────────────────────────────────────────────────────────
CREATE TEMP TABLE _project_current_ssp AS
SELECT
    p.id AS project_id,
    p.uid AS project_uid,
    ssp.id AS ssp_id,
    ssp.uid AS ssp_uid,
    si.id AS system_implementation_id  -- main row
FROM compliance.projects p
JOIN compliance.project_assessment_plan_mapping papm ON papm.project_id = p.id
JOIN oscal.assessment_plans ap ON ap.id = papm.assessment_plan_id
JOIN oscal.system_security_plans ssp ON ssp.id = ap.ssp_id
JOIN oscal.ssp_system_implementations si
  ON si.scope_type = 'ssp' AND si.scope_id = ssp.id
WHERE ap.status = 'active'   -- 取 active AP 對應 SSP
  -- 若一個 project 有多個 active AP,取最新一個
  AND ap.id = (
      SELECT ap2.id FROM oscal.assessment_plans ap2
      JOIN compliance.project_assessment_plan_mapping papm2 ON papm2.assessment_plan_id = ap2.id
      WHERE papm2.project_id = p.id AND ap2.status = 'active'
      ORDER BY ap2.created_at DESC
      LIMIT 1
  );

-- 統計:應該每個有 active AP 的專案都有對應 row
-- SELECT COUNT(*) FROM _project_current_ssp;

-- ─────────────────────────────────────────────────────────────────────────────
-- Step 2 — Backfill devices (project_device_mapping → ssp_system_implementation_items) (2026-MM-DD)
-- ─────────────────────────────────────────────────────────────────────────────
INSERT INTO oscal.ssp_system_implementation_items (
    uid, system_security_plan_id, system_implementation_id,
    scope_type, scope_id, implementation_type,
    name, device_id, created_user, updated_user
)
SELECT
    gen_random_uuid(),
    pcs.ssp_id,
    pcs.system_implementation_id,
    'ssp',
    pcs.ssp_id,
    'hardware',
    d.name,
    d.id,
    'c3-backfill-2026-MM-DD',
    'c3-backfill-2026-MM-DD'
FROM compliance.project_device_mapping pdm
JOIN _project_current_ssp pcs ON pcs.project_id = pdm.project_id
JOIN public.devices d ON d.id = pdm.device_id
WHERE NOT EXISTS (
    -- idempotent:current SSP 內已有同 device_id 就 skip
    SELECT 1 FROM oscal.ssp_system_implementation_items existing
     WHERE existing.scope_type = 'ssp'
       AND existing.scope_id = pcs.ssp_id
       AND existing.implementation_type = 'hardware'
       AND existing.device_id = d.id
);

-- ─────────────────────────────────────────────────────────────────────────────
-- Step 3 — Backfill information_systems (project_information_systems → items) (2026-MM-DD)
-- ─────────────────────────────────────────────────────────────────────────────
-- 找對應 SSP characteristic id(每個 SSP 有 1:1 characteristic)
CREATE TEMP TABLE _ssp_characteristic AS
SELECT id, system_security_plan_id
FROM oscal.system_security_plans_system_characteristics;

INSERT INTO oscal.ssp_system_implementation_items (
    uid, system_security_plan_id, system_implementation_id,
    scope_type, scope_id, implementation_type,
    name, system_characteristic_id, created_user, updated_user
)
SELECT
    gen_random_uuid(),
    pcs.ssp_id,
    pcs.system_implementation_id,
    'ssp',
    pcs.ssp_id,
    'system',
    isys.name,
    sc.id,
    'c3-backfill-2026-MM-DD',
    'c3-backfill-2026-MM-DD'
FROM compliance.project_information_systems pis
JOIN _project_current_ssp pcs ON pcs.project_id = pis.project_id
JOIN compliance.information_systems isys ON isys.id = pis.information_system_id
LEFT JOIN _ssp_characteristic sc ON sc.system_security_plan_id = pcs.ssp_id
WHERE NOT EXISTS (
    SELECT 1 FROM oscal.ssp_system_implementation_items existing
     WHERE existing.scope_type = 'ssp'
       AND existing.scope_id = pcs.ssp_id
       AND existing.implementation_type = 'system'
       AND existing.name = isys.name  -- 用 name 為 idempotent key(characteristic_id 可能 NULL)
);

-- ─────────────────────────────────────────────────────────────────────────────
-- Step 4 — 驗證 (2026-MM-DD)
-- ─────────────────────────────────────────────────────────────────────────────
-- backfill 後計數
DO $$
DECLARE
    dev_backfilled int;
    sys_backfilled int;
BEGIN
    SELECT COUNT(*) INTO dev_backfilled
      FROM oscal.ssp_system_implementation_items
     WHERE updated_user = 'c3-backfill-2026-MM-DD' AND implementation_type='hardware';

    SELECT COUNT(*) INTO sys_backfilled
      FROM oscal.ssp_system_implementation_items
     WHERE updated_user = 'c3-backfill-2026-MM-DD' AND implementation_type='system';

    RAISE NOTICE 'C3 backfill done: % hardware items, % system items', dev_backfilled, sys_backfilled;
END $$;

-- 對照 — 應該大致對等(除了沒 active AP 的專案):
-- SELECT 'project_device_mapping', COUNT(*) FROM compliance.project_device_mapping
-- UNION ALL
-- SELECT 'project_information_systems', COUNT(*) FROM compliance.project_information_systems;

DROP TABLE _project_current_ssp;
DROP TABLE _ssp_characteristic;

COMMIT;

前置條件

  • C3.PR1 ship 後就跑這個 SQL(一次性)
  • 沒 active AP 的專案 → backfill 跳過(無 ssp 可寫)
  • 跑完保留 project_device_mapping / project_information_systems 表(等 PR2 才 DROP)

4.2 Rollback 草稿

-- C3.PR1 rollback:刪除 backfill 出來的 SSP items
BEGIN;

DELETE FROM oscal.ssp_system_implementation_items
 WHERE updated_user = 'c3-backfill-2026-MM-DD';

COMMIT;

5. Service 層改造(PR1)

5.1 GrcProjectService.get_project() — derived response

改動前

def get_project(self, uid, ...):
    ...
    # 從 project_device_mapping 抓 devices
    dev_mappings = self._device_mapping_service.get_all_by_project_uid(uid)
    dto.devices = [DeviceSummaryDto(...) for dm in dev_mappings if dm.device]

    # 從 project_information_systems 抓 audit_systems
    pis_entities = self._pis_domain_service.get_all(
        ProjectInformationSystemQueryEntity(project_id=entity.id)
    )
    ...

改動後

def get_project(self, uid, ...):
    ...
    # 從 current AP 對應 SSP 的 implementation_items derive
    current_ssp_id = self._resolve_current_ssp_id(project_id=entity.id)
    if current_ssp_id is None:
        dto.devices = []
        dto.audit_systems = []
    else:
        items = self._ssp_item_domain_service.get_all(
            SspSystemImplementationItemQueryEntity(
                system_security_plan_id=current_ssp_id, scope_type='ssp',
            )
        )
        # devices ← items where implementation_type='hardware'
        # audit_systems ← items where implementation_type IN ('component','system','subsystem','service','software')
        dto.devices = [self._item_to_device_summary(i) for i in items if i.implementation_type == 'hardware']
        dto.audit_systems = [self._item_to_system_summary(i) for i in items if i.implementation_type in ('component','system','subsystem','service','software')]

def _resolve_current_ssp_id(self, project_id: int) -> Optional[int]:
    # 找 project → latest active AP → SSP
    ...

注意_resolve_current_ssp_id 邏輯與 C2 ProjectCurrentSspRoute 一致,可抽共用 helper。

5.2 GrcProjectService.update_project() — 寫操作改 410

改動前:Section 3 / 5 處理 audit_systems / devices 替換

改動後(PR1)

  • 兩 section 暫時保留邏輯但加 deprecation warn log
  • 或直接拿掉 + request schema 拿掉欄位(搭配 C4 同 PR)

建議:搭配 C4 同 PR 一起拿掉 request schema fields,service 內對應段落同步拿掉。/project-device / /project/job-execution-device POST/PUT/DELETE endpoint 改 410。

5.3 /project-device 系列改 410(PR1)— 用 GrcErrorCode 走專案規範

檔案api/project/routes/project_device_route.py

新增 error code(加入 common/code/grc_error_code.py):

# 410 Gone — endpoint 已下架
GRC_PROJECT_DEVICE_DEPRECATED = (
    "專案設備寫操作已下架,請至 SSP 編輯頁維護受評範圍",
    "GRC_410001",
)
GRC_PROJECT_INFO_SYSTEM_DEPRECATED = (
    "專案資訊系統寫操作已下架,請至 SSP 編輯頁維護受評範圍",
    "GRC_410002",
)

Route 改動

from common.code.grc_error_code import GrcErrorCode

class ProjectDeviceRoute(MethodResource):
    def post(self):
        return self._gone_response(GrcErrorCode.GRC_PROJECT_DEVICE_DEPRECATED)

    def put(self, ...):
        return self._gone_response(GrcErrorCode.GRC_PROJECT_DEVICE_DEPRECATED)

    def delete(self, ...):
        return self._gone_response(GrcErrorCode.GRC_PROJECT_DEVICE_DEPRECATED)

    @staticmethod
    def _gone_response(error_code):
        """回傳 410 Gone,FE 用 error code 查 i18n。"""
        msg, code = error_code.value  # GrcErrorCode tuple 解構
        return {
            "code": 0,
            "msg": msg,           # zh-tw fallback(FE 優先用 code 查 i18n)
            "data": {
                "error_code": code,  # FE 用此 code 查 i18n
                "ssp_endpoints": [   # 引導 FE 改用 SSP-scoped endpoints
                    "GET /projects/<project_uid>/current-ssp-uid",
                    "POST /ssp/<ssp_uid>/ssp-resources/items"
                ]
            }
        }, 410  # HTTP 410 Gone

FE i18n 對應(FE repo src/config/locales/i18n/zh-tw/error.json):

{
  "error": {
    "GRC_410001": "專案設備寫操作已下架,請至 SSP 編輯頁維護受評範圍",
    "GRC_410002": "專案資訊系統寫操作已下架,請至 SSP 編輯頁維護受評範圍"
  }
}

FE 拿到 response 後優先用 data.error_code 查 i18n 顯示,msg 為 fallback。

GET 操作保留正常(改 derived from SSP)。

6. PR1 變動檔案清單

新檔

  • scripts/sql/2026-MM-DD-c3-backfill-ssp-items-from-project-mappings.sql
  • domain/oscal/service/ssp_current_resolver.py(_resolve_current_ssp_id 抽 helper)

改檔

  • app/grc/service/project_service.py — get_project derived;update_project 拿掉 Section 3/5
  • app/project/service/oscal_project_service.py — start_oscal_project 拿掉 Step C
  • api/project/routes/project_device_route.py — 寫操作回 410
  • api/project/routes/project_*.py 其他 device write endpoints — 同上
  • api/project/__init__.py — route 仍註冊(410 由 route 內部回,URL 仍存在)
  • api/project/serializers/project.py — OscalProjectStartRequest 拿掉 audit_systems/devices(C4 同期)
  • api/grc/serializers/project.py — ProjectUpdateRequestSchema 同上
  • DI container 拿掉 device_mapping / pis service 注入(C4 同期)

不動(保留至 PR2):

  • domain/associations/ — 整個保留
  • infra/associations/ — 整個保留
  • app/associations/ — 整個保留
  • compliance.project_device_mapping table — 保留
  • compliance.project_information_systems table — 保留

7. PR2 變動檔案清單

新檔

  • scripts/sql/2026-MM-DD-c3-drop-project-mapping-tables.sql

刪檔

  • domain/associations/* 整個
  • infra/associations/* 整個
  • app/associations/* 整個
  • jedi_information_system/...project_information_system_* 系列
  • api/project/routes/project_device_route.py — 廢檔(route 內 410 拿掉)
  • api/project/routes/project_*.py 其他 device endpoints 同上

改檔

  • api/project/__init__.py — 拿掉 ProjectDevicesRoute / ProjectDeviceRoute 等註冊
  • DI container 完整清理
  • domain/oscal/strategy/ssp_write_strategy.py:258-277 — 拿掉 TODO Phase E.2

7.1 DROP SQL(PR2)

-- Date: 2026-MM-DD
-- Purpose: C3.PR2 — DROP project_device_mapping + project_information_systems
-- 帳號:cmmgr

BEGIN;

-- ─────────────────────────────────────────────────────────────────────────────
-- DROP project_device_mapping (2026-MM-DD)
-- ─────────────────────────────────────────────────────────────────────────────
-- 確認沒有殘留 caller
-- SELECT COUNT(*) FROM compliance.project_device_mapping;
-- 此時可能還有資料但已無 caller 引用

-- 廢 RLS policy
DROP POLICY IF EXISTS project_device_mapping_select ON compliance.project_device_mapping;
DROP POLICY IF EXISTS project_device_mapping_insert ON compliance.project_device_mapping;
DROP POLICY IF EXISTS project_device_mapping_update ON compliance.project_device_mapping;
DROP POLICY IF EXISTS project_device_mapping_delete ON compliance.project_device_mapping;

-- 廢 FK / index(CASCADE 會處理大部分,但顯式列出較明確)
ALTER TABLE compliance.project_device_mapping
    DROP CONSTRAINT IF EXISTS uq_project_device_mapping;
DROP INDEX IF EXISTS compliance.ix_pdm_project_id;
DROP INDEX IF EXISTS compliance.ix_pdm_device_id;

-- 廢 table
DROP TABLE IF EXISTS compliance.project_device_mapping CASCADE;
-- DROP TABLE 會自動 drop sequence 跟 owned objects

-- ─────────────────────────────────────────────────────────────────────────────
-- DROP project_information_systems (2026-MM-DD)
-- ─────────────────────────────────────────────────────────────────────────────
DROP POLICY IF EXISTS project_information_systems_select ON compliance.project_information_systems;
DROP POLICY IF EXISTS project_information_systems_insert ON compliance.project_information_systems;
DROP POLICY IF EXISTS project_information_systems_update ON compliance.project_information_systems;
DROP POLICY IF EXISTS project_information_systems_delete ON compliance.project_information_systems;

ALTER TABLE compliance.project_information_systems
    DROP CONSTRAINT IF EXISTS uq_project_information_system;

DROP TABLE IF EXISTS compliance.project_information_systems CASCADE;

COMMIT;

-- 驗證
-- \dt compliance.project_device_mapping     -- 預期:Did not find any relation
-- \dt compliance.project_information_systems  -- 同上

8. 風險評估

風險 嚴重度 緩解
Backfill SQL 漏資料(無 active AP 的 project) 🟡 中 Pre-flight 跑 SQL 統計沒有 active AP 的 project 數量 + 列名單,由業務決定是否補建 AP
Backfill 後 SSP items 出現 user 沒預期的「設備」 🟡 中 backfill 用 c3-backfill-2026-MM-DD 標記在 updated_user,可一次性 audit / cleanup
PR1 後 reviewer/auditor 角色看不到 device/audit_systems 🟢 低 derived response 對所有角色都回(permission 只擋寫),仍可看
PR2 DROP 後發現有遺漏 caller 🔴 高 PR1 ~ PR2 之間 1-2 週觀察期;PR1 加 SQL audit query 監控 mapping 表 INSERT 是否 0
既有 dashboard / search 依賴 mapping 表(沒在 caller 盤點到) 🔴 高 PR1 前期需再次 grep pg_class WHERE relname='project_device_mapping' + RLS / view 依賴
jedi-information-system 套件外被引用 🟡 中 套件層 grep 確認
backfill 資料量大 timeout 🟢 低 dev 環境總量小(< 1000 row),prod 跑前估算

9. 監控 / Audit(PR1 期間)

PR1 ship 後到 PR2 期間,跑 SQL 監控確認 mapping 表沒有新 INSERT:

-- 應該 0(PR1 後寫操作回 410,無新增)
SELECT COUNT(*) FROM compliance.project_device_mapping
WHERE created_at > '<PR1-ship-date>';

SELECT COUNT(*) FROM compliance.project_information_systems
WHERE created_at > '<PR1-ship-date>';

若觀察到非 0,表示有遺漏 caller,要先補修才能進 PR2。

10. 決策(已 finalized)

編號 決策
C3-D1 ✅ Backfill SQL 人工跑(PR1 ship 後 DBA 監督執行)
C3-D2 ✅ PR1 ~ PR2 觀察期 1 週
C3-D3 ✅ 沒 active AP 的 project:backfill 跳過(user 啟動 AP 後 SSP 從 MF clone 帶範圍)
C3-D4 ✅ 410 response 用 GrcErrorCode patternGRC_410001 / GRC_410002);FE 用 error_code 查 i18n,msg 為 zh-tw fallback。對齊專案規範。
C3-D5 OscalRole ORM 保留(OSCAL 文件結構用,跟 system_menus 服務不同對象);C1 同步 seed oscal_roles 9 筆

11. 開發後狀態

PR1 後 (2026-05-23 上午, commit range d2c3df2..1253a92)

  • Response 內 dto.devices / dto.audit_systems 從 current SSP derive
  • 寫操作 endpoint 回 410(route 仍存在)
  • mapping 表保留有資料
  • 既有 stack(associations / project_information_system)仍在但已無 active write caller

PR2 後 (2026-05-23 下午, commit 0960070 + 143f354)

  • ✅ mapping 表 DROP(scripts/sql/2026-05-23-c3-pr2-drop-project-mapping-tables.sql 已執行)
  • ✅ associations 內 device 系列 10 檔 + jedi_information_system project_* 系列 7 檔 git rm
  • ✅ 410 endpoint 改為 404(api/project/__init__.py 拿掉 register)
  • ✅ DI cleanup 3 處(associations_containers / information_system_container / oscal_containers)
  • ✅ ssp_write_strategy dead injection(_project_info_sys_repo)拿掉
  • ✅ 程式碼總量縮小

PR2 vs 原 plan T11 偏差:plan 寫 git rm -r app/associations/ 過度激進,實際只能拆 device 兩個系列;保留其他 6 個 mapping(org_unit / system_characteristic / assessment_plan / task_workflow / profile_assessment_workflow)+ job_execution_device(屬「任務指派裝置」非 C3 scope)。詳見 changelog 2026-05-23-tweak-c3-pr2-drop-project-mapping-tables.md §「Plan 矯正」。

跳過觀察期:原 plan §11 寫 PR1 ship 後 1-2 週觀察期再進 PR2,user 確認 DB 有備份、 production 已 0 caller,跳過直接拔。

12. Test Plan

C3 是重型 phase,建議獨立 test-plan:

  • BE unit / integration:見 implementation-plan-C3.md 內 §測試規格
  • E2E / regression:建專案 + 啟動 AP + derive 受評範圍 + 410 endpoint 驗證
  • Migration verify:backfill 前後 row 數對等性檢查 SQL