Files
Arcrun/system-dev/docs/3-specs/portal-auth/design.md
T
Leo 5cadc60e36 feat(workflow-discovery): /cypher/search 改為真查 registry——修「查詢回假信號」
📋 SDD:workflow-discovery(本 commit 同時執行 D35 交接:portal-auth 26/26 完成 → closed
   superseded_by workflow-discovery;workflow-discovery paused → active。單一活性已驗=1 份)
🎯 對應 task:3.x 搜尋端誠實化(CP2-B)

病灶(leo 2026-07-30 定性「腹語術」):
search-nodes.ts 無條件回 status:'found'、missingNodes 永遠 []——
型別宣告了 'missing' 但程式碼從不使用。實測「完全不存在的東西xyz」也回 found。
⇒ AI 拿到假信號 → 以為零件存在 → 部署才發現沒有 → 改寫 code
⇒ 正式 workflow 只用 2 個零件、8 個 code 節點含 if×61。

修法:
- 查 registry 判真實存在(走 HTTP,守 D28 禁新增 service binding;
  URL 用既有 wasmWorkerUrl() 慣例組,不自創)
- found 時附 input_schema/success_rate/stability
  ⇒ AI 才填得出 payload、才看得到「測過幾次」(leo:AI 只要填 payload)
- 查不通回 'unknown' 而非 'missing'——**誠實限制**:
  不能因查詢失敗就宣告零件不存在(那會讓 AI 誤判而重寫 code)
- missing 真的回傳出去(原本寫死 [])

⚠️ 實測發現 registry **沒有列表端點**(GET /components → 404,只有 /components/<id>)
⇒ 改為逐個查(節點數通常 <10、5s timeout)。補列表端點後可改抓一次=CP2-B 待辦。

驗:tsc 零錯誤;vitest 9 failed/179 passed=**與改動前 stash 對帳完全相同**(既有債非本次造成)。
 待部署到實例後跑 arcrun-usable/verify.sh 驗 01 那組轉綠。
2026-07-30 20:41:53 +08:00

181 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
status: closed
superseded_by: "workflow-discovery"
closed_at: "2026-07-30"
closed_reason: "26/26 任務全完成、0 未完成待搬;封測 portal 多人授權已上線"
---
# portal-auth — DesignRAG Portal 多人授權)
> 狀態:**定案**2026-07-14 總管審過+leo 裁 D-4=A/D-8=adminPR #49
> 建立:2026-07-13 | 最後更新:2026-07-14
> 對應: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(定案:A 粗閘,leo 2026-07-14**graph 模式=**粗閘**——只對「擁有 graph 來源庫權限」的用戶開放(portal_library 標記哪個庫是圖譜來源,預設 `general`;無權者搜尋頁不顯示 graph 模式、`/portal/data/graph/*` 回 403)。逐節點/逐邊細粒度過濾列第二波。已知殘餘(誠實記):客戶把機密文件餵進圖萃取時,粗閘擋不住節點名本身——導入文件要寫明「機密庫預設不進圖萃取來源」。
## 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 慣例)。
- **【修訂 2026-07-14T6-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:雜湊放 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(定案:adminleo 2026-07-14):工作流頁 admin 可見、一般用戶不顯示**——「工作流頁可以顯示(誠實系統狀態)」與「一般用戶只要兩頁」取交集;要改全員可見=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 搜不到」 |