17 KiB
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.tssearchEntries 核實)。若 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.tsupdateRecord 的「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 已知特性)——用既有reindexbackfill 機制補。 - 庫目錄(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 合併——庫數小,成本有界)
- keyword(D1):
- 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_hashslot(見 D-6);驗證用常數時間比對(同 PR#15 慣例)。 - 順帶:不動 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}(KVSESSIONS_KV,值={record_id},TTLPORTAL_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
librarymetadata index 建立+reindexbackfill 是部署步驟(寫進 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 搜不到」 |