---
status: active
superseded_by: ""
---

# portal-auth — Design（RAG Portal 多人授權）

> 狀態：**定案**（2026-07-14 總管審過＋leo 裁 D-4=A/D-8=admin，PR #49）
> 建立：2026-07-13 | 最後更新：2026-07-14
> 對應：Gitea #24（T1 Portal 拆分）＋ #25（T2 多用戶登入）；rag-wave1 design §2/§3/§8
> 姊妹檔：requirements.md（範圍與驗收）、tasks.md（實作切分）

---

## 0. 現況地基（讀 code 核實過，設計都踩在這上面）

| 地基 | 位置 | 現況 |
|---|---|---|
| Console 單人登入 | `cypher-executor/src/routes/console-auth.ts` | 全站一組 email/password 存 KV `console:credentials`（salt＋3 輪 SHA-256）；session KV `console_sess:{token}` TTL 30 天；登入後前端拿固定租戶字串 `CONSOLE_TENANT` 直打 `/kbdb/*` |
| KBDB 萬用表 | `kbdb/migrations/0001_base.sql` | 三表（entries/templates/entry_values）永不 ALTER；record＝template＋slots，slot 值是 `entry_type='value'` 的 entry，`owner_id` 隔離租戶；records API 有 create/update（PATCH）/get/by-template |
| 查詢 API | `kbdb/src/routes/entries.ts`＋`cypher-executor/src/routes/kbdb-proxy.ts` | keyword＝D1 LIKE（owner_id/entry_type filter）；semantic＝Vectorize metadata filter（owner_id/entry_type/source 已建 index）；graph＝kbdb-graph-plugin `/graph/neighbors/:name`（**無任何 scope 概念**）；proxy 以 `X-Arcrun-API-Key` 當 owner_id 強制注入 |
| MCP OAuth | `mcp/OAUTH.md`（PR#15） | access_token（KV, hash key, TTL）→ 解出**綁定 namespace**；owner secret 在 CF Secrets |
| 品牌/裁剪 | `console.ts` #47/#48 | `CONSOLE_BRAND` 字樣覆蓋；`CONSOLE_PROFILE=rag` 裁成 search/card/workflows/settings 四頁、落地搜尋——**是 config 裁剪不是 auth** |
| source 溯源 | `entry-crud.ts` | `metadata_json.$.source`（ingest envelope URI）可 json_extract 過濾，且已進 Vectorize metadata index |

## 1. 架構總覽

```
同仁瀏覽器 ─► /portal（登入殼＋搜尋＋設定＋admin帳號頁）─┐
                                                        │ cypher-executor（同一顆 worker）
owner/導入者 ─► /console（owner secret，現狀不動）────────┤   └─ /portal/data/* server-side enforce
客戶自家 AI ─► /mcp（PR#15 OAuth，第一波不動）────────────┘        （owner_id＋庫 filter 注入）
                                                        │
                                                   KBDB worker（D1 萬用表＋Vectorize）
                                                   kbdb-graph-plugin（graph 模式）
```

### 決策 D-1：Portal 形態＝cypher-executor 的 `/portal` 路由（非獨立 worker）【建議，待總管確認】
- **選項 A（建議）**：同 worker 新增 `routes/portal.ts`＋`routes/portal-auth.ts`＋`routes/portal-data.ts`。
- 選項 B：獨立 worker。
- **理由**：Portal 查詢鐵律是「走既有 cypher/kbdb API、不開新資料路徑」——獨立 worker 得複製一整套 KBDB proxy＋`KBDB_INTERNAL_TOKEN` 持有面，多一個部署單位、多一個 secret 面；同 worker 重用 `kbdbBase()` 慣例零複製。企業實例本來就是「獨立部署的一套 cypher-executor」（帳號=環境模型），worker 級隔離已存在。
- Mira 實例零影響：`/portal` 端點在未 bootstrap 任何 portal_user 時只回登入殼＋「尚未啟用」，不影響既有頁面。
- UI 重用：portal HTML 重用 console 的樣式系統與搜尋/卡片 view 的 render 片段（實作時抽共用 helper），但**獨立 HTML 殼**——不是在 console 上加 if，避免動到「Admin Console 現狀不動」鐵律。

## 2. 用戶模型（KBDB 萬用表，零新表）

### 2.1 template：`portal_user`（seed，`created_by='system'`）

| slot | 內容 | 備註 |
|---|---|---|
| `email` | 登入帳號（存小寫） | |
| `display_name` | 顯示名 | |
| `status` | `active` / `disabled` | 停用＝翻這個 slot（updateRecord PATCH，D6 慣例同 deprecate-via-status-slot） |
| `role` | `user` / `admin` | admin＝多「管帳號＋管權限」兩件事 |
| `password_hash` | KDF 輸出（見 §4），格式 `pbkdf2-sha256$iters$salt$hash` | 自帶演算法前綴，未來換 KDF 可共存漸進遷移 |
| `libraries` | JSON array 字串，例 `["general","finance"]`；特殊值 `["*"]`＝全庫 | 可查庫集合（見 §3） |
| `created_at` / `updated_at` | ISO 時間 | 萬用表 entries 本有時間欄，slot 冗餘存一份供 admin 頁直讀 |

### 2.2 決策 D-2：帳號資料的 owner_id ＝ 系統子 namespace `{tenant}::portal`【建議，重要】
- **問題（盤現況發現的真風險）**：record 的 slot 值是 `entry_type='value'` 的 entries、content 就是值本身；keyword 搜尋是 `content LIKE`、只按 owner_id 過濾（`entry-crud.ts` searchEntries 核實）。若 portal_user 存在 `owner_id=CONSOLE_TENANT` 底下，**同仁的 email、甚至密碼雜湊會出現在搜尋結果裡**。
- **解**：portal_user（含 §3 的 portal_library）一律寫 `owner_id = "{CONSOLE_TENANT}::portal"`。既有租戶查詢面（`/kbdb/*`、Portal 搜尋、MCP）都以 `CONSOLE_TENANT` 過濾 → 物理上撈不到帳號資料。零 KBDB 改動，純 caller 端約定。
- 附帶盤出的既有小缺口：`record-crud.ts` updateRecord 的「grow 新 slot」路徑建 entry 時**沒帶 owner_id**（line 127）——對本設計影響小（getRecord 按 record_id 取不受影響），但 P2 實作時順手補上（帶 record 既有 owner_id），避免孤兒 entry。

### 2.3 email O(1) 查找（登入用）
record 本身沒有可索引的 head——另建一筆 head entry：`entry_type='portal_user'`、`page_name=email`、`content=record_id`、`owner_id={tenant}::portal`。登入時走既有 indexed 查詢 `GET /entries?page_name={email}&entry_type=portal_user&owner_id={tenant}::portal` 一發命中。仍是 entries 表、零新表。（不用 KV 做 email index：帳號→record 映射是長效資料，進 KV 違鐵律。）

## 3. 「庫」是什麼（本 SDD 最核心的一題）

### 3.1 現況盤點：KBDB 語境裡可當「庫」的既有維度

| 候選 | 現況 | 當庫的問題 |
|---|---|---|
| `owner_id`（namespace） | 租戶隔離主鍵，D1＋Vectorize 都有 index | 語意是「租戶」不是「庫」；一庫一 namespace 會把「跨庫搜尋」變成 N 次查詢，且 proxy 強制單一 owner_id 注入、graph/ingest/MCP 全要跟著改，既有 45 萬筆資料要搬家 |
| `metadata_json.$.source` | ingest envelope 的檔案級 URI（`logseq://vault/foo.md`），D1 json_extract＋Vectorize index 都可過濾 | 粒度是「單檔」不是「庫」；Vectorize filter 是等值比對，做不了前綴匹配 |
| `page_name` / `tags_json` | D1 可查 | 語意已被佔用（頁名/標籤），Vectorize 沒 index |
| `parent_id`（project 樹） | project→workflow 樹 | 是任務樹不是知識分區 |

**結論：現況沒有現成的「庫」維度**——source 太細、owner_id 太粗。庫是新概念，但可以不動 schema 地長出來。

### 3.2 決策 D-3：庫＝`metadata_json.$.library`（ingest 蓋章的一級 metadata 欄位）【建議，待總管/leo 裁——本 SDD 最大單一決策】
- **定義**：庫＝知識條目上的 `library` 標記（如 `general`/`finance`/`hr`），**ingest 寫入時蓋章**（ingest 端按「收集資料夾/repo → 庫名」的對照表蓋；對照表是 arcrun-rag 包的 ingest config，不歸本 repo）。
- **儲存**：`metadata_json.$.library`——json_extract 可查（同 source 的 #5.1 先例），**表不變**。
- **semantic**：Vectorize upsert metadata 加 `library` 欄＋建 metadata index（同 owner_id/source 先例）。既有向量要重推才進 index（embed.ts line 117 已知特性）——用既有 `reindex` backfill 機制補。
- **庫目錄（admin 頁要列庫）**：template `portal_library`（slots：`name`/`display_name`/`description`/`status`），owner_id 同 `{tenant}::portal`。零新表。
- **未蓋章的舊資料**：視同 `library="general"`（查詢端 fallback：json_extract 為 NULL → 歸 general）。回填 job 非必要（fallback 已覆蓋），可列第二波清潔工。
- **放棄的選項**：owner_id 子 namespace 當庫（3.1 表列問題）；source 前綴當庫（Vectorize 做不了前綴）。

### 3.3 查詢怎麼 enforce（server-side，非前端藏）

- **KBDB base 擴充**（P1）：`/entries/search` 與 `/entries` 加 `library` 參數（**可多值**，逗號分隔）：
  - keyword（D1）：`(json_extract(metadata_json,'$.library') IN (?,…) OR (json_extract(metadata_json,'$.library') IS NULL AND 'general' IN (?,…)))`
  - semantic（Vectorize）：filter `{ library: { $in: [...] } }`（Vectorize v2 metadata filter 支援 `$in`；**實作時以 wrangler/文件核實**，若版本不支援 → fallback：每庫一次 topK 查詢、按 score 合併——庫數小，成本有界）
- **Portal 查詢面**（P3）：前端**只打 `/portal/data/*`**，server 端從 session→user record 取 `libraries`，注入 `owner_id=CONSOLE_TENANT`＋`library=<集合>` 後轉發 KBDB。
- **關鍵差異（vs 現行 console）**：console 登入後把 `CONSOLE_TENANT` 交給前端當 API key 直打 `/kbdb/*`；**Portal 絕不下發租戶字串**——portal_user 拿到租戶字串就能繞過庫 filter 直打 `/kbdb/search`。前端只有 portal session token，enforce 全在 server。
- `["*"]`（全庫）＝不注入 library filter，只注入 owner_id。

### 3.4 graph 模式的庫權限（已知薄弱點，明寫）
kbdb-graph-plugin 的 `/graph/neighbors/:name` 只吃節點名，**無 owner/庫概念**（proxy 也只驗有 key）。三元組是跨文件萃取的衍生物，逐邊回查來源 entry 的 library＝N+1 且語意模糊（一條邊可能來自多庫文件）。
- **決策 D-4（定案：A 粗閘，leo 2026-07-14）**：graph 模式＝**粗閘**——只對「擁有 graph 來源庫權限」的用戶開放（portal_library 標記哪個庫是圖譜來源，預設 `general`；無權者搜尋頁不顯示 graph 模式、`/portal/data/graph/*` 回 403）。逐節點/逐邊細粒度過濾列第二波。已知殘餘（誠實記）：客戶把機密文件餵進圖萃取時，粗閘擋不住節點名本身——導入文件要寫明「機密庫預設不進圖萃取來源」。

## 4. 密碼與登入

### 4.1 決策 D-5：KDF＝PBKDF2-SHA256（WebCrypto 原生，600k 迭代）【建議；issue 字面寫 bcrypt/argon2，偏離需總管點頭】
| 選項 | 評估 |
|---|---|
| **PBKDF2-SHA256 600k iters（建議）** | Workers `crypto.subtle.deriveBits` **原生**、零依賴；OWASP 現行建議值；console-auth 已有「登入雜湊寫在 cypher TS」先例（rule 2.2 限的是 workflow credential 原語，不是 console/portal 登入） |
| argon2id | 記憶體硬化更強，但 Workers 無原生，要引 WASM 模組（新依賴＋冷啟成本）；Workers 128MB 記憶體下參數也開不大 |
| bcrypt | 純 JS 實作燒 CPU（Workers CPU limit），無原生 |
- 格式 `pbkdf2-sha256$600000$<salt_b64>$<hash_b64>` 存 `password_hash` slot（見 D-6）；驗證用常數時間比對（同 PR#15 慣例）。
- **【修訂 2026-07-14，T6-cloud 真雲實撞】迭代數 600k → 100k**：CF Workers **正式 runtime** 的
  PBKDF2 上限是 100,000（`crypto.subtle.deriveBits` 超過直接拒絕）→ `/portal/admin/bootstrap`
  真雲 500；miniflare 無此限制＝本機全綠假象（證據：arcrun-rag `docs/manual/uncle6-deploy-record.md`）。
  OWASP 建議 600k 但平台封頂——儲存格式自帶迭代數（verify 從儲存值解析），未來平台放寬可無痛升。
  誠實記：600k 舊 hash 的「漸進遷移」只在 miniflare/放寬後成立，真雲 deriveBits 同樣封頂；
  所幸真雲 bootstrap 從未成功，雲端不存在 600k hash，無實際遷移面。
- 順帶：**不動 console-auth 既有 3 輪 SHA-256**（Admin Console 現狀不動鐵律）；portal 是新面、直接用對的。

### 4.2 決策 D-6：雜湊放 slot（KBDB），不放 CF Secrets【建議＝定案傾向強】
- CF Secrets 是**部署期靜態**（`wrangler secret put`），admin 在 runtime CRUD 用戶時**物理上寫不進去**——多用戶動態資料放 Secrets 不可行。
- KV 放長效帳號資料違「KV 只留暫存」。
- slot＝KBDB D1 長效儲存，符合三層 by 用途（credential-storage-three-tier：長期→D1）。雜湊非明碼、不可逆；外洩風險靠強 KDF＋D-2 子 namespace（搜不到）緩解。

### 4.3 登入/Session 流程
- `POST /portal/login {email,password}` → head entry 查 record → status=active → KDF 驗證 → 發 `portal_sess:{token}`（KV `SESSIONS_KV`，值＝`{record_id}`，TTL `PORTAL_SESSION_TTL` 預設 **7 天**；issue 要求「短效」，比 console 30 天緊）。
- **每個 `/portal/data/*` 請求都回讀 user record**（session 只存 record_id，權限/狀態以 record 為唯一真相源）→ **停用/改權限即時生效**，不需 session 反向索引、不需等 TTL。停用時 admin API 同時盡力刪已知 session（best-effort），但正確性不依賴它。
- 登入失敗節流（建議含，可裁）：KV 計數 `portal_lockfail:{email}`，5 次失敗鎖 15 分鐘（TTL 自然過期）。公開登入面的最低保險，工程量半天內。
- 改自己密碼：`POST /portal/me/password {current,new}`（驗舊密）；admin 重設他人：`POST /portal/admin/users/:id/reset-password`（回一次性新密碼，要求首登改密＝slot 加 `must_change:true`——**可裁**，第一波可簡化為 admin 口頭轉交新密碼）。

### 4.4 決策 D-7：owner（console secret）與 portal admin 的關係＝**並存**【建議】
- owner＝**超級管理員**（Admin Console，owner secret，機器/部署層）；portal admin＝**業務管理員**（管同仁帳號與庫權限）。不取代：Admin Console 現狀不動是 #24 鐵律，且 owner secret 是安裝期人閘、portal admin 是 runtime 角色，層次不同。
- **Bootstrap**：`POST /portal/admin/bootstrap` 需帶 **console owner session**（重用 console-auth 的 `validateConsoleSession`）→ 建第一個 role=admin 的 portal_user。不引入新 secret、不開放無閘註冊。安裝器（rag-wave1 §6）把這步寫進一條龍。

## 5. API 面（全掛 cypher-executor，`/portal` 前綴）

| Method/Path | auth | 作用 |
|---|---|---|
| `GET /portal` | 無（回登入殼） | Portal HTML（brand＝`CONSOLE_BRAND`） |
| `POST /portal/login` / `POST /portal/logout` | — / session | 登入/登出 |
| `GET /portal/session` | session | 驗 session＋回 `{display_name, role, libraries}`（前端據此渲染；**不回租戶字串**） |
| `POST /portal/me/password` | session | 改自己密碼 |
| `GET /portal/data/search?q=&mode=` | session | 三模式查詢（server 注入 owner_id＋library；回應含 source 溯源欄） |
| `GET /portal/data/entries/:id` | session | 卡片詳頁（**逐筆驗 library**：entry 的 library 不在用戶集合 → 404——防拿 id 直讀越庫） |
| `GET /portal/data/graph/neighbors/:name` | session＋D-4 粗閘 | graph 模式 |
| `GET /portal/data/workflows` | session（見 D-8） | 工作流顯示（唯讀 list＋最近執行；**不開 trigger**） |
| `POST /portal/admin/bootstrap` | console owner session | 建第一個 admin |
| `GET/POST /portal/admin/users`、`PATCH /portal/admin/users/:id`（status/role/libraries/reset-password） | session＋role=admin | 帳號 CRUD＋權限 |
| `GET/POST/PATCH /portal/admin/libraries` | session＋role=admin | 庫目錄登記 |

## 6. 頁面（leo 2026-07-13 頁面級拍板為綱）

- **一般用戶＝兩頁**：搜尋頁（三模式切換＋結果 source 溯源＋卡片詳頁）＋設定頁（改密碼、看自己的角色與可查庫、主題切換）。
- **admin 多一頁**：帳號管理（同仁列表/新增/停用/重設密碼＋每帳號勾選可查庫）＋庫目錄管理。掛 Portal 內 admin-only nav 項（**非** console settings 擴充——console 不動鐵律；且受眾不同：console 是 owner、這頁是 portal admin）。
- **決策 D-8（定案：admin，leo 2026-07-14）：工作流頁 admin 可見、一般用戶不顯示**——「工作流頁可以顯示（誠實系統狀態）」與「一般用戶只要兩頁」取交集；要改全員可見＝config `PORTAL_SHOW_WORKFLOWS=all` 一行。唯讀、不開 trigger（trigger 是 owner/console 的事）。
- Mira 專屬頁（駕駛艙/分流台/專案管理/自動開發）**一律不進**。

## 7. 設定與部署

- `[vars]`：`PORTAL_SESSION_TTL`（預設 604800）、`PORTAL_SHOW_WORKFLOWS`（`admin`/`all`/`off`，預設 admin）；沿用 `CONSOLE_TENANT`/`CONSOLE_BRAND`。無新 secret（bootstrap 走 console owner session）。
- 部署：gated leo21c wrangler 直推（B 類流程）；Vectorize `library` metadata index 建立＋`reindex` backfill 是部署步驟（寫進 PR 的部署清單與 rag-wave1 安裝器）。
- Mira 實例：不 bootstrap 即不啟用；`CONSOLE_PROFILE` 與 Portal 互不干涉（#48 是 console 的裁剪、本 SDD 是新面）。

## 8. 不做清單（第二波以後，明寫）

SSO/AD/SAML、Google OAuth2（fast-follow）、per-user MCP token、審計 log、忘記密碼 email 自助、graph 逐節點細粒度過濾、舊資料 library 回填 job（fallback 已覆蓋）、密碼策略進階（複雜度規則/輪換）。

## 9. MCP 第二波預告（只預告，不實作）

PR#15 的 access_token KV record 現存「綁定 namespace」→ 擴成 `{namespace, libraries}`；`/authorize` 同意頁讓 owner 勾庫集合；partner-auth 解 token 後把 libraries 帶進 MCP 工具的 KBDB 呼叫（同 §3.3 filter）。P1 的 KBDB library filter 地基即為此鋪路——第二波只動 mcp/，不再動 kbdb。

## 10. 風險

| 風險 | 緩解 |
|---|---|
| Vectorize `$in` filter 版本支援不確定 | P1 第一件事實測；fallback 每庫 fan-out 合併（設計已含） |
| graph 模式洩漏機密庫節點名 | D-4 粗閘＋可收緊成 admin-only；第二波細粒度 |
| 既有向量不重推就 filter 不到（embed.ts 已知特性） | 部署清單強制 reindex backfill＋驗收抽查 semantic 命中 |
| Portal 時程拖住 pilot | requirements 降級方案（rag profile＋共享唯讀連結＋IP 白名單），啟用回 leo 裁 |
| 帳號資料進搜尋結果 | D-2 子 namespace（物理隔離）＋P2 驗收明測「搜 email 搜不到」 |
