sdd(portal-auth): design — 庫=metadata library/子namespace隔離帳號/PBKDF2/server-side enforce(8 決策點含待裁標記)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-07-13 22:08:11 +08:00
parent 66062843ee
commit f23042939f
@@ -0,0 +1,167 @@
# portal-auth — DesignRAG Portal 多人授權)
> 狀態:草稿(待總管/leo 審)
> 建立:2026-07-13 | 最後更新:2026-07-13
> 對應:Gitea #24T1 Portal 拆分)+ #25T2 多用戶登入);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`salt3 輪 SHA-256);session KV `console_sess:{token}` TTL 30 天;登入後前端拿固定租戶字串 `CONSOLE_TENANT` 直打 `/kbdb/*` |
| KBDB 萬用表 | `kbdb/migrations/0001_base.sql` | 三表(entries/templates/entry_values)永不 ALTERrecordtemplateslotsslot 值是 `entry_type='value'` 的 entry`owner_id` 隔離租戶;records API 有 create/updatePATCH/get/by-template |
| 查詢 API | `kbdb/src/routes/entries.ts``cypher-executor/src/routes/kbdb-proxy.ts` | keywordD1 LIKEowner_id/entry_type filter);semanticVectorize metadata filterowner_id/entry_type/source 已建 index);graphkbdb-graph-plugin `/graph/neighbors/:name`**無任何 scope 概念**);proxy 以 `X-Arcrun-API-Key` 當 owner_id 強制注入 |
| MCP OAuth | `mcp/OAUTH.md`PR#15 | access_tokenKV, 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/導入者 ─► /consoleowner secret,現狀不動)────────┤ └─ /portal/data/* server-side enforce
客戶自家 AI ─► /mcpPR#15 OAuth,第一波不動)────────────┘ (owner_id+庫 filter 注入)
KBDB workerD1 萬用表+Vectorize
kbdb-graph-plugingraph 模式)
```
### 決策 D-1Portal 形態=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` | 停用=翻這個 slotupdateRecord PATCHD6 慣例同 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 | 租戶隔離主鍵,D1Vectorize 都有 index | 語意是「租戶」不是「庫」;一庫一 namespace 會把「跨庫搜尋」變成 N 次查詢,且 proxy 強制單一 owner_id 注入、graph/ingest/MCP 全要跟著改,既有 45 萬筆資料要搬家 |
| `metadata_json.$.source` | ingest envelope 的檔案級 URI`logseq://vault/foo.md`),D1 json_extractVectorize 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 先例)。既有向量要重推才進 indexembed.ts line 117 已知特性)——用既有 `reindex` backfill 機制補。
- **庫目錄(admin 頁要列庫)**template `portal_library`slots`name`/`display_name`/`description`/`status`),owner_id 同 `{tenant}::portal`。零新表。
- **未蓋章的舊資料**:視同 `library="general"`(查詢端 fallbackjson_extract 為 NULL → 歸 general)。回填 job 非必要(fallback 已覆蓋),可列第二波清潔工。
- **放棄的選項**owner_id 子 namespace 當庫(3.1 表列問題);source 前綴當庫(Vectorize 做不了前綴)。
### 3.3 查詢怎麼 enforceserver-side,非前端藏)
- **KBDB base 擴充**P1):`/entries/search``/entries``library` 參數(**可多值**,逗號分隔):
- keywordD1):`(json_extract(metadata_json,'$.library') IN (?,…) OR (json_extract(metadata_json,'$.library') IS NULL AND 'general' IN (?,…)))`
- semanticVectorize):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 tokenenforce 全在 server。
- `["*"]`(全庫)=不注入 library filter,只注入 owner_id。
### 3.4 graph 模式的庫權限(已知薄弱點,明寫)
kbdb-graph-plugin 的 `/graph/neighbors/:name` 只吃節點名,**無 owner/庫概念**(proxy 也只驗有 key)。三元組是跨文件萃取的衍生物,逐邊回查來源 entry 的 library=N+1 且語意模糊(一條邊可能來自多庫文件)。
- **決策 D-4(第一波,建議)**:graph 模式=**粗閘**——只對「擁有 graph 來源庫權限」的用戶開放(portal_library 標記哪個庫是圖譜來源,預設 `general`;無權者搜尋頁不顯示 graph 模式、`/portal/data/graph/*` 回 403)。逐節點/逐邊細粒度過濾列第二波。**待裁**(涉及「機密庫內容會不會經圖譜洩漏」的品味判斷:若客戶把機密文件也餵進圖萃取,粗閘擋不住節點名本身;第一波可再收緊成「graph 模式只給 admin」)。
## 4. 密碼與登入
### 4.1 決策 D-5KDFPBKDF2-SHA256WebCrypto 原生,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 實作燒 CPUWorkers CPU limit),無原生 |
- 格式 `pbkdf2-sha256$600000$<salt_b64>$<hash_b64>``password_hash` slot(見 D-6);驗證用常數時間比對(同 PR#15 慣例)。
- 順帶:**不動 console-auth 既有 3 輪 SHA-256**Admin Console 現狀不動鐵律);portal 是新面、直接用對的。
### 4.2 決策 D-6:雜湊放 slotKBDB),不放 CF Secrets【建議=定案傾向強】
- CF Secrets 是**部署期靜態**`wrangler secret put`),admin 在 runtime CRUD 用戶時**物理上寫不進去**——多用戶動態資料放 Secrets 不可行。
- KV 放長效帳號資料違「KV 只留暫存」。
- slotKBDB 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 同時盡力刪已知 sessionbest-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-7ownerconsole secret)與 portal admin 的關係=**並存**【建議】
- owner**超級管理員**Admin Consoleowner 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 HTMLbrand`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_idlibrary;回應含 source 溯源欄) |
| `GET /portal/data/entries/:id` | session | 卡片詳頁(**逐筆驗 library**entry 的 library 不在用戶集合 → 404——防拿 id 直讀越庫) |
| `GET /portal/data/graph/neighbors/:name` | sessionD-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 | sessionrole=admin | 帳號 CRUD+權限 |
| `GET/POST/PATCH /portal/admin/libraries` | sessionrole=admin | 庫目錄登記 |
## 6. 頁面(leo 2026-07-13 頁面級拍板為綱)
- **一般用戶=兩頁**:搜尋頁(三模式切換+結果 source 溯源+卡片詳頁)+設定頁(改密碼、看自己的角色與可查庫、主題切換)。
- **admin 多一頁**:帳號管理(同仁列表/新增/停用/重設密碼+每帳號勾選可查庫)+庫目錄管理。掛 Portal 內 admin-only nav 項(**非** console settings 擴充——console 不動鐵律;且受眾不同:console 是 owner、這頁是 portal admin)。
- **決策 D-8:工作流頁預設 admin 可見、一般用戶不顯示**【待裁,輕】——leo 拍板「工作流頁可以顯示(誠實系統狀態)」但同句拍板「一般用戶只要兩頁」。兩者取交集=admin 見(admin 的第四頁)、一般用戶不見;若 leo 意在全員可見,改 config `PORTAL_SHOW_WORKFLOWS=all` 一行的事。唯讀、不開 triggertrigger 是 owner/console 的事)。
- Mira 專屬頁(駕駛艙/分流台/專案管理/自動開發)**一律不進**。
## 7. 設定與部署
- `[vars]``PORTAL_SESSION_TTL`(預設 604800)、`PORTAL_SHOW_WORKFLOWS``admin`/`all`/`off`,預設 admin);沿用 `CONSOLE_TENANT`/`CONSOLE_BRAND`。無新 secretbootstrap 走 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 OAuth2fast-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 搜不到」 |