# FR-032 系統資產盤點（資訊系統作為資產納入 SSP inventory）— 設計文件

| 項目 | 內容 |
|------|------|
| FR 編號 | FR-032 |
| 提出日期 | 2026-06-04 |
| arc 關聯 | relates to FR-018（MF 範本預設）/ 概念上對齊 jedi_information_system 受評標的 |
| 狀態 | Design（brainstorm 完成，待 spec review + 寫 implementation plan） |
| 本期範圍 | A 範本層系統資產表 + picker、B 啟動專案 clone、C 專案內 SSP 編輯、D OSCAL 匯出 |
| 出 scope（後續分階段） | E 匯入回灌、F device 也加 picker、G jedi_information_system 拆套件 |

---

## 1. 問題與動機

### 1.1 白話需求

合規資源庫範本（Module Frame）與專案 SSP 目前的「資產清冊」只盛裝**設備**（device 性質的資產：IPv4 / MAC / 主機名稱 / 作業系統…）。實務上做資產盤點時，**資訊系統本身也是一種資產**，需要跟設備並列在盤點清冊裡。

使用者希望在 `/module-frame/<uid>/template-edit` 的「系統元件、外部授權與資產」tab，於既有「資產清冊」下方**再多一張「系統資產」表**，可以：

1. 從現有資訊系統清單挑選，自動帶入欄位
2. 系統清單沒有對應資料時，允許直接在表單自由輸入
3. 同時保存鉤稽 id，方便溯源

### 1.2 受評標的維持獨立（不動）

`information_system` 在系統中目前對應 OSCAL SSP 的 **system-characteristics（受評標的）**：一份 SSP 只有一個受評主體。本期**不改變**受評標的的角色與資料。系統資產是「同一筆 `information_system` 在別的範本 / SSP 中以資產身分被引用」的新視角，與受評標的並存。

### 1.3 三者 OSCAL 對照（釐清「不是同一個東西」）

| 系統概念 | 對應 OSCAL | OSCAL 層 | 角色 |
|---------|-----------|---------|------|
| 合規資源庫（Module Frame） | Profile / Baseline | Control Layer | 評分標準（控制項基線） |
| 受評標的 | SSP → system-characteristics | Implementation Layer | 被評的主體系統（一份 SSP 一個） |
| 資訊系統（information_system） | system-characteristics（受評標的視角）／ inventory-item（資產視角，本期新增） | Implementation Layer | 雙重身分 |

合規資源庫（Profile）與受評標的（SSP）是不同層；受評標的與資訊系統現況是同一概念的兩個名字。本期讓資訊系統**多一個資產身分**。

---

## 2. 設計原則

### 2.1 雙重身分（dual-identity）

同一筆 `compliance.information_systems`：

- 在**它自己的受評案**裡 → 受評標的（system-characteristics，主體）
- 在**別的範本 / SSP** 裡被引用 → inventory-item（資產），純引用

### 2.2 Hybrid soft-ref（複製欄位 + 選填鉤稽 id）

採用專案既有的 soft-ref + 快照 + 自由輸入模式（對齊 OSCAL party / 參與人員 matching：`matched_user_id` soft ref + enrich 顯示快照 + 配不到維持自由輸入）：

- 一筆系統資產列存**複製下來的欄位**（一定有值，文件自包含）＋**選填的 ref**（`ref_type` + `ref_id`）
- ref 只負責「自動帶入」與「溯源」，**不負責即時同步**
- 同步方向**單向**：master → 表單（挑選時 / 未來重新帶入時）；**永不回寫 master**

選此模式而非純 reference / 純 copy 的理由：

| 做法 | 系統無資料時 | 匯出路徑 | 文件自包含 | 稽核快照 |
|------|------------|---------|-----------|---------|
| 純 Copy（現況 device） | 可自由輸入 | 不改 | 是 | 凍結 |
| 純 Reference | 卡住（沒得選不能填） | 要新寫 join | 否 | 會被事後改 |
| **Hybrid（採用）** | 可自由輸入 | **不改**（props 本來就有值） | 是 | 凍結 + 可溯源 |

### 2.3 與設備並存、共用管線

設備維持 copy 行為**不動**（jedi-device 未接入，本就無 master 可鉤稽）；系統因有 master（`information_systems`）才走 hybrid。兩者**同桌**（共用 inventory 表），靠 `asset_type` 鑑別，UI 渲染成兩張表。

### 2.4 雙重身分安全保證

在 inventory 表（範本 default 或 SSP copy）的任何編輯**只動本地列，永不回寫** `compliance.information_systems` master。改 master 只能走資訊系統管理頁。

---

## 3. 資料模型

### 3.1 共用 inventory 表加欄位（純 additive，既有設備列零影響）

於 **`compliance.module_frame_inventory_item_defaults`（範本層）** 與 **`oscal.ssp_inventory_items`（SSP 層）** 各加：

| 新欄位 | 型別 | 說明 |
|--------|------|------|
| `asset_type` | VARCHAR | 鑑別欄：`hardware`（既有設備，backfill 預設）/ `information_system`（系統資產） |
| `ref_type` | VARCHAR NULL | soft-ref 類型：`information_system`（本期）；未來 `device`。自由輸入為 null |
| `ref_id` | VARCHAR NULL | 鉤稽 master uid（`information_systems.uid`）。自由輸入為 null |

**Migration**：

- 既有列 backfill `asset_type='hardware'`、`ref_type/ref_id=NULL` → 設備行為完全不變
- 只加欄位、不新建表，既有 `GRANT` 沿用（無需新授權）
- 兩張表的 migration 各加日期註解，檔頭加 `-- Date: YYYY-MM-DD`（per SQL migration 規範）
- migration 用 `cmmgr` 帳號跑（`cm_app` 受 RLS）

### 3.2 系統資產列的資料落點

| 落點 | 內容 |
|------|------|
| `description` 欄 | 系統描述（OSCAL inventory-item description 必填；空則 fallback 系統名稱） |
| `props`(JSONB) | `{ name, abbreviation, owner_id, owner_name, deployment_model, system_status }` |
| `asset_type` | `information_system` |
| `ref_type` / `ref_id` | 從清單挑：`information_system` / 系統 uid；自由輸入：null / null |

**不放** CIA（security_objective_*）與 authorization_boundary — 那是受評標的層級，避免兩邊重疊混淆。

審計欄位：`owner_id` 配 `owner_name`（nickname）。**注意**：本設計的 `owner_name` 是 snapshot 當下凍結進 `props` 的值（符合 §2.2「凍結」原則），**讀取時不再 re-enrich**。這與一般「審計欄位 read 時批次轉 nickname」慣例不同 — 系統資產是時間點快照，刻意保留挑選當下的 owner nickname。snapshot 寫入時（BE 抓 master 那一刻）查 `User.login_name → nickname` 一次即可。implementation plan 要明確標示此點，避免被誤改成 read 時 re-enrich。

---

## 4. 資料流

```
A. 範本層（template-edit 頁）
   「系統資產」表 →「新增系統資產」
     ├─ 從清單挑：picker 撈 /information-systems/menu → 選定
     │    → BE 用 ref_id 回 master 撈 → snapshot 欄位進 props + 存 ref_type/ref_id
     │      （snapshot 由 BE 抓 master，不信 FE 傳值）
     └─ 自由輸入：直接填 → ref 為 null
   存入 compliance.module_frame_inventory_item_defaults（asset_type='information_system'）
        │
        │ B. 啟動專案 clone（沿用既有 INSERT...SELECT，補上 3 個新欄位）
        ▼
   oscal.ssp_inventory_items：props 快照逐字複製、ref 帶過來 → SSP 凍結評估當下狀態
        │
        │ C. 專案內 SSP 編輯（同表、同元件、同 picker；編輯只動該份 SSP copy，與 master 分岔可接受）
        ▼
   D. 匯出：mapper 對 asset_type='information_system' 列吐 inventory-item + asset-type prop
```

**clone 的最小改動**：現有 clone step 已逐字複製 props，只需把 `asset_type / ref_type / ref_id` 加進 INSERT...SELECT 欄位清單；系統資產列同桌故自動 clone，不另寫管線。

---

## 5. API 設計

### 5.1 範本層（沿用既有 inventory 端點，加 asset_type 分支）

沿用 `/module-frame/<uid>/inventory` 的 POST/PUT/DELETE，payload 多帶 `asset_type` + 選填 `ref_type/ref_id`：

- service 分支：
  - `ref_id` 有值 → 注入 `information_system_domain_service` 回 master 撈、組 props 快照
  - 無 `ref_id` → 收 FE 自由輸入的 props
- 不另開新資源，維持 API 面最小
- **marshmallow schema 必須加 `asset_type` / `ref_type` / `ref_id` 欄位**：既有 inventory route 若用嚴格 `use_kwargs`，未宣告的新 payload key 會在進 service 前被 silently drop（plan pre-flight 要驗 schema 是否嚴格、補欄位）

### 5.2 SSP 層（C）

沿用 FR-011.3「專案內直接編輯 SSP」既有的 SSP inventory 編輯端點，同樣加 asset_type 分支。
**實作期 pre-flight 必驗**：FR-011.3 既有端點的實際路徑與 service 方法（plan vs reality）。

### 5.3 picker

直接重用既有 `GET /information-systems/menu`，不新增端點。

### 5.4 權限與 error code

- 寫入走 service 層 manager/auditor 角色檢查（per 開發規範：寫入 API 必須角色權限檢查）
- 新 error code（per `GRC_<HTTP><序號>` 命名）：`information_system` ref_id 無效時拋 `NotFound`
  - 實作期到 `common/code/grc_error_code.py` 查最大序號 +1，避免序號被佔（per plan vs reality）

### 5.5 DDD 規範

- Route 不查 DB、不 import ORM model；權限檢查在 service 層透過 domain service
- app service public method 加 `@transaction`；snapshot 抓 master 走 `information_system_domain_service`
- 不直接 import infra ORM model

---

## 6. 匯出（D1-A：repoint docx/pdf loader 到 live inventory 表）

### 6.1 Pre-flight 驗證後的 live 現實（取代原假設）

驗證確認「資產清冊」live path = `module_frame_inventory_item_defaults`（範本，經 `/module-frame/:uid/inventory`）→ clone → `oscal.ssp_inventory_items`（SSP）。但**匯出 loader 沒跟著 §11.29 遷移 repoint**：

- `MfSspContentLoader`（範本匯出）讀 `resources`（= 廢棄的 `ssp_components` devices/info_systems），**不是** `module_frame_inventory_item_defaults` → 連 rich 設備列現在都匯不出（既有遺漏）
- `SspVersionContentLoader`（專案 SSP 匯出）讀永遠空的 `ssp.system_implementations` → inventory/components/leveraged 全空
- OSCAL-native（json/yaml/xml，`ssp_yaml_mapper`）同讀空的 `system_implementations`

### 6.2 D1-A 決策

把 **docx/pdf/odt 匯出的 inventory builder repoint 到 live 表**，同時吐 `hardware` + `information_system`：

- `MfSspContentLoader._build_inventory_items` → 改讀 `module_frame_inventory_item_defaults`（按 asset_type 分流）
- `SspVersionContentLoader._build_inventory_items` → 改讀 `ssp_inventory_items`（按 asset_type 分流）
- 系統資產列在 docx 內以 inventory 列呈現（`SspInventoryItemExportItem.device_type='information-system'` 區分；或視 generator 渲染另立 sub-section）
- **副作用（已知並接受）**：設備匯出輸出改為讀正確的新表（一併修好既有遺漏）

### 6.3 本期不碰（follow-up）

- **OSCAL-native json/yaml/xml**：讀空 `system_implementations` 是**跟本功能無關的更深層既有缺陷**（影響整個 system-implementation 區塊）→ 列 follow-up，不在 FR-032 修
- 因此 §原「asset-type prop on OSCAL inventory-item」shape（含 `system-name`/`deployment-model` 等 props）屬 OSCAL-native path，**遞延到該 path 修復時再落地**；本期 docx 走 rendered 列
- `ref_id` 本期不寫進任何匯出（round-trip 是 E 期）

---

## 7. 前端設計

- 新「系統資產」表元件，置於「系統元件、外部授權與資產」tab 的資產清冊下方，比照既有 inventory 表樣式（PrimeVue / BaseService / `src/config/api/api.js` 常數，per FE CLAUDE.md）
- 「新增系統資產」dialog：
  - 上方 picker（下拉選系統，選了自動帶入欄位）
  - 下方可編輯欄位（支援自由輸入 / 微調帶入值）
- 同一元件於 C 的專案內 SSP 編輯畫面重用
- 跨 repo 作業：動工前先讀 FE CLAUDE.md（per 跨 repo 規範）

---

## 8. 預設決策（YAGNI）

| 決策 | 取捨 |
|------|------|
| **不做「重新帶入 / re-sync」按鈕** | 要更新就重選一次系統即可，省一個維護面 |
| **snapshot 由 BE 從 master 抓** | 不信 FE 傳的鉤稽快照值，確保資料正確 |
| **device 不在本期加 picker** | jedi-device 未接入，留 F 期 |

---

## 9. 測試策略

- BE app service 測試：
  - 範本層 add/update 系統資產（有 ref / 無 ref 兩路徑）
  - ref_id 無效 → 拋 NotFound
  - snapshot 由 master 取值（不採 FE 傳值）
  - clone 帶過 asset_type / ref_type / ref_id
  - docx 匯出（repoint 後）含系統資產列 + 設備列（D1-A）
  - 加 logger patch autouse fixture（per DBLogHandler 雷，抄 test_ssp_excel_import_app_service.py:46）
- E2E（後續於 compliance-manager-test repo）：template-edit 新增系統資產 → 啟動專案 → SSP 內可見可編輯 → docx 匯出含系統列

---

## 10. Pre-flight 驗證結果（2026-06-04，開工前已驗）

| # | 待驗項 | 結果 |
|---|--------|------|
| 1 | 匯出路徑聚合關係 | **已釐清**：docx/pdf 走 `MfSspContentLoader`（讀廢棄 ssp_components）/ `SspVersionContentLoader`（讀空 system_implementations）；OSCAL-native 走 `ssp_yaml_mapper`（讀空 system_implementations）。決策 D1-A：repoint docx/pdf loader 到 live 表；OSCAL-native 列 follow-up（§6） |
| 2 | SSP inventory 編輯端點 | **已確認**：`api/oscal/routes/ssp/ssp_inventory_items_route.py`（`GET/POST/PUT/DELETE /ssp/<ssp_uid>/inventory-items`）→ `SspInventoryItemsAppService`；payload 走 raw `request.get_json()`，無嚴格 schema |
| 3 | clone INSERT...SELECT | **已確認**：`module_frame_template_copy_service.py:270-294` Step 6（10 INSERT 欄、需補 asset_type/ref_type/ref_id） |
| 4 | error code 序號 | **已確認**：最大 GRC_404034 → 用 **GRC_404035** |
| 5 | inventory route / schema 嚴格度 | **已確認**：`/module-frame/:uid/inventory`（`module_frame_inventory_service`）走 raw `request.get_json()`，新 key 不會被 drop；ORM `ModuleFrameInventoryItemDefault` / entity 目前無 asset_type/ref 欄位，需補 |

**已廢棄但可借用的成熟邏輯**：`SspResourcesContextService`（§11.29 前的 info_systems 機制）已實作 information_system soft-ref + matched_* enrich + `information_system_domain_service` 注入 —— 搬這套邏輯到新 inventory 機制，不重新發明。

---

## 11. Implementation Reality / Reconciliation

A/B/C/D/F 收尾（2026-06-04）逐條列出與原設計的偏差與原因。E-a 換 session 接手（見 handoff）。

| # | 偏差 / 落地實況 | 原因 |
|---|----------------|------|
| 11.1 | error code 沿用既有 `GRC_INFORMATION_SYSTEM_NOT_FOUND`(404020)，未用原規劃的 GRC_404035 | pre-flight 只比序號漏看名稱，發現已存在 → 沿用；404035 改給新 `GRC_DEVICE_NOT_FOUND`(F) |
| 11.2 | jedi-oscal 不需 `poetry update`，BE 重啟即生效 | venv 內 jedi_oscal 是 symlink editable（指向 source）；原 handoff 假設要 poetry update |
| 11.3 | 系統資產的 hybrid soft-ref 邏輯抽成 `common/util/system_asset_snapshot.py` 共用 | 範本層 + SSP 層兩入口共用（DRY），非各自 inline |
| 11.4 | 匯出（D）實況比 spec 複雜：原設計假設「加 asset-type prop 到 inventory-item」，實際匯出層是壞的 | 驗證發現 docx loader 讀廢棄 ssp_components / 空的 system_implementations；採 D1-A：repoint docx loader 到 live 表，OSCAL-native 列 follow-up |
| 11.5 | docx 系統資產**獨立成 System Assets 表**（非混入 inventory-item 帶 asset-type prop） | user 回報混表 + IP/OS 欄位不適用系統 → 拆獨立表（Name/Abbreviation/Owner/Deployment/Status）；新增 `SspSystemAssetExportItem` + `partition_inventory_rows` |
| 11.6 | ssp_version docx 匯出原本就壞（`ssp.system_implementations` AttributeError），順手修 getattr 防呆 | 該路徑以前沒人跑過，repoint inventory 時引爆；components/leveraged 仍空（follow-up） |
| 11.7 | F（device picker）採 **merge**（device 填 hostname/ipv4/os，其餘 hardware 欄位保留 FE 輸入），非系統資產的 full-replace | DeviceEntity 只有 hostname/ip/os 三欄，full-replace 會丟失 mac/asset_id 等 |
| 11.8 | F 為了「挑選當下表單即帶 hostname/os」擴充 jedi-device device menu（加 hostname/os） | menu 原本只有 uid/name/ip；jedi-device 是 pin → dev 改 path dependency + user poetry update |
| 11.9 | F 順手把「資產清冊」文案改「設備資產」、「描述」改「名稱」（col_device_name 獨立 key） | user 要求；不共用 col_inventory_description 以免動到系統資產表的描述欄 |
| 11.10 | 廢棄機制（SspResourcesContextService，§11.29 前的 info_systems/devices）保留未動 | 走新 inventory 機制，廢棄機制不在本期 scope（避免 scope creep）|

### E-a 收尾（2026-06-04，Excel round-trip）

| # | 偏差 / 落地實況 | 原因 |
|---|----------------|------|
| 11.11 | 系統資產走**獨立「系統資產」sheet**（非塞進「資產」sheet 加 asset_type 欄） | 系統欄位（name/abbreviation/owner/deployment/status）跟設備（IP/MAC/FQDN）不同；對稱 docx 獨立 System Assets 表 |
| 11.12 | 系統資產匯入**依 name 比對 master**（非 uid）；deployment/status enum 直接用 OSCAL key | Excel 給名稱不給 uid；exact name match → 配到設 ref + master snapshot / 配不到自由輸入。enum 用 OSCAL key 免反向 mapping（master 也存同 key）|
| 11.13 | 範本 template-edit 下載填值原本「資產」+「系統資產」**都空白** | MF-scoped `_populate_filled_data` 寫死 `inventory_items=[]`（舊缺口）且未注入 MF inventory default service → 注入 + 新增 `_fetch_mf_inventory_items`/`_fetch_mf_system_assets` 依 asset_type 分流（commit e51bd809）|
| 11.14 | 「資產」改名「設備資產」+ 第一欄 header 描述→名稱 | user 要求對齊 docx / FE live 表；header i18n 改值，column key 仍 `description`（OSCAL 必填）；舊名「資產」加 legacy alias |
| 11.15 | 設備資產 / 系統資產比照「參與人員」加 picker（matched_device / matched_info_system 下拉 + autofill）| 復用既有 v2 device/info_system lookup + autofill 基建（DEVICES/INFO_SYSTEMS helper）；picker 放欄位**尾端**保 backward-compat（舊樣板無此欄 parser 讀 None 不錯位）|
| 11.16 | 設備鉤稽走 **merge**（hostname/ipv4/os 覆蓋、其餘 hardware 欄位保留），系統資產走 full-replace（snapshot） | DeviceEntity 只有 hostname/ip/os 三欄，full-replace 會丟 mac/asset_id（對齊 §11.7 F 期 merge 語意）|
| 11.17 | 系統資產匯入端**不讀 matched_info_system**（仍依 name 比對）；設備匯入端讀 matched_device | autofill 已把 name 填成所挑系統 → name match 等效；設備 picker 才是 device 鉤稽的 key（device 名稱識別度較低）|
| 11.18 | 下載填值 round-trip **回填 picker**（已鉤稽列反查 master 填 matched_device 'name (ip)' / matched_info_system 'abbr - name'）| 下載 → picker 顯示已選 → re-upload 可重新鉤稽，round-trip 完整 |
| 11.19 | Excel VLOOKUP autofill **依賴 client 計算模式**（MS Excel 手動模式不即時帶入） | openpyxl 寫的公式無 cached value，靠 fullCalcOnLoad + Excel 自動重算；公式經 LibreOffice recalc 驗證正確（非 bug）。不影響匯入結果（BE 從 master 填欄位）。follow-up：可於「說明」分頁加重算提示 |

### §11 index（對應段同步）
- A/B/C/D/F：完工
- **E-a：✅ 完工（2026-06-04，見 FIXED SUMMARY + §11.11~11.19）**
- **G（jedi_information_system 拆套件）：✅ 完工（2026-06-04，解耦 jedi-auth + 抽
  `jedi-information-system==0.0.1` 推 Nexus；見 analysis 可行性評估 + changelog
  `2026-06-04-tweak-fr032-g-jedi-information-system-extract.md`）**
- E-b / OSCAL-native 匯出 / docx components-leveraged repoint：follow-up
