# FR-042 合規框架與客戶儲存空間脫鉤，改由系統部門集中管理

> 狀態：設計定案（待開實作 session）｜提出：2026-06-28｜來源：雷門
> Notion case：合規框架與客戶儲存空間脫鉤，改由系統部門集中管理（任務清單 DB）
> relates to FR-039（檔案儲存抽象 / RemoteAgent）、FR-024（框架 PDF 匯入 v2）

## 1. 背景 / 需求

合規框架是**產品資產**，應由系統持有者（系統部門 / 最高層 tenant）集中維護，不下放給客戶 user。框架不應綁在客戶儲存空間。具體兩個需求：

1. **所有 tenant 都讀得到框架**（定義 + PDF）。
2. **框架 PDF 要跨 tenant 抓得到** — 目前 PDF 上傳到系統儲存後，其他 tenant 拿不到系統儲存 config，導致要不到 PDF。**這是主要待解問題。**

## 2. 現況機制（已驗證）

| 元件 | 現況 | 是否符合需求 |
|---|---|---|
| 框架/catalog 定義表（`oscal.oscal_frameworks` / `oscal_framework_versions` / `catalogs` / `catalog_controls`） | 系統層、無 tenant_id、無 RLS、全客戶共用 | 需求 1 定義層已成立（**實作前 psql 複查一次 RLS**） |
| 框架 PDF 實體檔（`upload_files`，在 **jedi-file-upload 套件**） | **無 tenant_id**，只記 `storage_type` 字串，**不記後端連線**（bucket/endpoint/drive） | ❌ 核心問題所在 |
| 匯入暫存 `oscal.framework_parse_jobs` | tenant-scoped（tenant_id + RLS） | 暫存安全 |

**真正的根因（一句話）**：[`app/upload_file/service/managed_file_upload_service.py:51`](../../../app/upload_file/service/managed_file_upload_service.py) 的 `_load_config()` 在**上傳和下載時都**從 `system_config(group=STORAGE_CONFIG, key=CONFIG)` 讀儲存設定，而該 config 是 **tenant-scoped（吃 RLS）**。

連鎖反應：
1. 系統部門 admin 上傳框架 PDF → 落在 **admin tenant 後端**。
2. `upload_files` 列無 tenant_id、不記是哪個後端。
3. 別的 tenant 下載 → `_load_config()` 解析**它自己**的 `STORAGE_CONFIG` → 指向**它自己**的後端 → 檔案不在 → 抓不到。

→ **儲存解析跟著「呼叫者」走，但檔案實體跟著「上傳者」走，兩者對不上。**

關聯鏈（migration 盤點用）：`oscal.framework_versions.file_uid`（= `framework_parse_job.file_path` 存的 upload uid）→ `public.upload_files.uid`。
> ⚠️ 實作注意：jedi-oscal ORM `__tablename__` 寫 `oscal_framework_versions`，但 DB 實際表是 **`oscal.framework_versions`**（無 `oscal_` 前綴，FR-038 重設計後落差）。寫 migration / 直接 SQL 以實際表名為準。

### 環境普查（2026-06-28，回答 pre-flight ①「有沒有正式框架檔」）

| 環境 | 框架 | 有 PDF 版本 | published+PDF | 後端分布 |
|---|---|---|---|---|
| DEV | 1 | 1 | 1 | remote_agent ×1 |
| STG | 1 | 5 | 2 | minio ×2、remote_agent ×3 |
| POC | 1 | 3 | 2 | remote_agent ×3 |

**結論：migration 不可省，且主力在 `remote_agent`（FR-039 分散式客戶 agent）。** minio 可 server 端 copy；remote_agent 的 bytes 在客戶端 agent 上，搬遷需「透過 agent fetch 回來 → push 到系統儲存」，跨網路、依賴 agent 在線、需 mTLS+JWT 認證——這是整條 migration 最硬的部分，難度維持「高」。

底層 tenant 為**物化路徑階層**（`set_tenant_path` trigger + `parent_id`），root tenant = `parent_id IS NULL`；有 `is_super_admin` 可繞 RLS（Tenant ORM 在 jedi-auth，實作時確認確切 locator）。

## 3. 定案決策（2026-06-28 與雷門確認）

| 面向 | 決定 |
|---|---|
| 系統儲存形態 | 框架 PDF 掛在**最高層 tenant** 名下，用它的 `STORAGE_CONFIG` 當系統儲存；下載以 super-admin context 讀，繞呼叫者 RLS（不新增游離於 RLS 外的全域 config） |
| 標記機制 | `upload_files` 加 `storage_scope`（system/customer，預設 customer），框架檔標 system |
| 既有檔 | 寫 migration 一次性把 bytes 從舊後端搬到系統儲存 + 改標記（dev/stg/poc 各跑一次，SQL 走 cmmgr） |
| 下載存取 | 框架 PDF 下載端補 `@jwt_required`，**登入即可讀、不分 tenant**（產品共享資產） |

設計取向：以「storage_scope」為主軸，但讓「系統儲存來源」可日後收斂成更通用的 `owner_tenant_id`（scope=system ≡ owner=最高層 tenant），不必重做。

## 4. 實作輪廓（4 塊）

1. **套件 schema 變更（jedi-file-upload）** ⚠️ 動外部套件
   - `upload_files` 加 `storage_scope`（system/customer，預設 customer）；`UploadFile` model + mapper + entity 同步。
   - dev 階段走 poetry path dependency，feature 完成才發版（依 CLAUDE.md jedi-* 規範）。

2. **儲存解析（主專案 `ManagedFileUploadService`）**
   - 新增 system-scope 來源：以 super-admin context 讀「最高層 tenant 的 `STORAGE_CONFIG`」建 adapter。
   - 框架匯入上傳：寫系統儲存 + 標 `scope=system`。
   - 下載/預覽：依「檔案的 scope」解析後端（system → 系統儲存；其餘 → 維持現行 caller-tenant 解析）。

3. **Migration**
   - 撈 `oscal_framework_versions.file_uid` 對應的 `upload_files` → bytes 從舊後端搬到系統 bucket → 改 `storage_scope` + 必要的 `save_file_name/path`。
   - dev/stg/poc 各跑一次；收尾 `INSERT public.schema_migrations`。

4. **下載 auth**
   - 框架 PDF 下載 route 補 `@jwt_required`、登入即可讀。
   - 先確認是否走共用 `/file/download/<uid>`；若共用，避免誤傷其他用途（可能需區分框架專用唯讀 route 或用 storage_scope 判斷）。

## 5. Pre-flight（實作 session 開工前驗）

1. psql 實查框架**定義表** RLS（需求 1 是否真已成立 — case 說已確認，但探查報告有矛盾）。
2. jedi-auth 裡「最高層 tenant」確切 locator。
3. 下載 route 是否共用、補 auth 的影響面。

## 6. 風險 / 待確認

- 動 jedi-file-upload 套件 schema → 影響其他 consumer（所有用 upload_files 的模組）；加欄位有預設值、向後相容，風險低但需報備。
- 「最高層 tenant 的 STORAGE_CONFIG」必須先在系統儲存（獨立於客戶前綴的 bucket/folder）配好。
- migration 搬 bytes 需確認舊後端（local/minio/remote_agent）逐型搬法。
- 維護權限（import/publish 限 system-admin、客戶端唯讀）**本 FR 暫未納入**，視需要另議或併入。

---

## 7. Pre-flight 驗證結果與校正（2026-06-28 實作 session）

實查 dev（188/guidant_ai_dev）後對定案的修正：

| design 假設 | 實際 | 處置 |
|---|---|---|
| `oscal.oscal_frameworks` / `oscal_framework_versions` | 實際 **`oscal.frameworks` / `oscal.framework_versions`**（無 `oscal_` 前綴） | 表名校正 |
| 定義表系統層、無 RLS | ✅ `frameworks`/`framework_versions`/`catalogs`/`catalog_controls` 全 `rls=f` 無 tenant_id | 需求 1 已成立，不動 |
| `system_config` | 實際 **`public.system_configs`**（複數），有 tenant_id + **RLS=t** | 根因確認；讀 root config 須繞 RLS |
| 最高層 tenant = `parent_id IS NULL` | ⚠️ **兩筆**：id=1 Guidant.AI(/1/)、id=134 測試豬戶A(/134/) | 定位改為「`parent_id IS NULL` 中**最小 id**」= id=1（與雷門確認）|
| 既有框架檔分布 | dev 僅 1 筆：`framework_versions id=178`，落 **remote_agent `192.168.50.123:8443`** | bytes 搬移見 §8 待辦(b) |

**前置依賴（未完成）**：root tenant（id=1）目前**無 STORAGE_CONFIG**。雷門將於「系統設定 → 儲存設備」以系統管理員手動配一份；配好前整條 end-to-end 無法實測。

## 8. 實作決策與結果（取代 §3 部分細節）

| 面向 | 定案 → 實作 |
|---|---|
| 系統檔標記 | **方案 A**：jedi-file-upload `upload_files` 加 `storage_scope`（system/customer，預設 customer，向後相容） |
| 系統 root 定位 | `parent_id IS NULL` 取 min(id) = id=1 Guidant.AI（編碼於 `system_storage_config_reader`） |
| root config 讀法 | 另開獨立 `SessionLocal()` 設 `app.is_super_admin='t'` 讀單列，**不污染呼叫端請求 session** 的 RLS 狀態（infra 層 raw SQL） |
| 雙 adapter 解析 | `ManagedFileUploadService` 依「檔案自己的 storage_scope」選 adapter：system→系統 adapter（root 儲存）/ customer→呼叫者 adapter。**下載/預覽 route 不需改**（LOCAL 系統檔走 route 直讀檔案系統；MINIO/REMOTE_AGENT 走 service 內部 scope 解析）|
| 框架上傳 | `framework_parse_job_service` 改呼叫 `upload_system_file()` → 寫系統儲存 + 標 scope=system |
| **Block 4 下載 auth** | **⚠️ 與現況衝突，未實作**：共用 `/file/download`、`/file/pdf-preview` 故意無 auth（FE 用 window.open/iframe，瀏覽器原生 GET 無法帶 bearer，靠 unguessable uuid 當憑證）。加 `@jwt_required` 會**打爆所有預覽/下載含框架**。核心需求 #2 由 storage_scope 解析解決，不靠 auth。**建議不加，待雷門拍板**（若要鎖需配套改 FE 用 fetch+blob 帶 bearer）|

### 已實作檔案

**jedi-file-upload 套件**（dev path dependency，未發版）
- `common/code/storage_scope_code.py`（新）— `StorageScopeCode.SYSTEM/CUSTOMER`
- `infra/models/upload_file.py` — 加 `storage_scope` 欄（NOT NULL server_default 'customer'）
- `domain/entities/upload_file_entity.py` / `infra/mapper/upload_file_mapper.py` / `app/dto/upload_file.py` — 同步
- `infra/repository/upload_file_repo_impl.py` — add/update 持久化 `storage_scope`
- `ports/.../upload_file_provider.py` + `infra/adapter/local|minio` — `save_file(..., storage_scope='customer')`
- `app/service/file_upload_service.py` — `upload_file/upload_files(..., storage_scope=...)`

**主專案**
- `infra/upload_file/system_storage_config_reader.py`（新）— super-admin 讀 root tenant STORAGE_CONFIG
- `infra/upload_file/remote_agent_adapter.py` — `save_file` 簽名 + entity 同步
- `app/upload_file/service/managed_file_upload_service.py` — system adapter + scope-aware get/convert/delete + `upload_system_file`
- `app/oscal/service/framework_parse_job_service.py` — 框架 PDF 改 `upload_system_file`
- `scripts/sql/2026-06-28-fr042-upload-files-add-storage-scope.sql`（新，可立即跑 dev/stg/poc）

### 待辦 / gated

- **(a) 欄位 migration**：可立即跑（`cmmgr`，dev/stg/poc 各一次）。
- **(b) 既有框架檔 bytes 搬移**：gated 於 root STORAGE_CONFIG 配好。跨後端（remote_agent→系統儲存）非純 SQL。dev 僅 1 筆，最簡單是配好後**重新匯入框架**；stg/poc/prod 有實資料才需正式搬移腳本（pull old backend bytes → `upload_system_file` 重寫 → 更新 `framework_versions.file_uid`）。
- **套件發版**：feature 完成 + 雷門明示後才 bump jedi-file-upload 推 Nexus，主專案改回 pin。
- **Block 4 auth**：待拍板（建議不加）。
