From a3f5c77332363aed7f54033695d8a1e15452f3f2 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Tue, 14 Jul 2026 13:05:23 +0800 Subject: [PATCH] =?UTF-8?q?portal-auth=20P3=EF=BC=9A/portal/data/*=20serve?= =?UTF-8?q?r-side=20enforce=EF=BC=8B/portal=20=E5=96=AE=E6=AA=94=20HTML=20?= =?UTF-8?q?=E6=AE=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - portal-data.ts(安全核心):search(server 注入 owner_id+library,caller 的 owner_id/library 參數一律被覆蓋);entries/:id 逐筆驗租戶+庫(越庫/不存在同一句 404 不洩存在性);graph D-4 粗閘(無權 403,SDD 明定例外);workflows D-8 (admin/all/off,唯讀+最近執行,無 trigger、無 webhook_url) - portal-ui.ts:獨立 HTML 殼(不動 console;樣式複製 console 設計語言子集)—— 登入/搜尋(三模式+source 溯源+卡片詳頁)/設定(改密碼/看權限/主題)/工作流 (admin);前端零租戶字串、零 X-Arcrun-API-Key,只持 portal session token - index.ts 掛載兩個新 router;tsc exit 0 Co-Authored-By: Claude Fable 5 --- cypher-executor/src/index.ts | 6 +- cypher-executor/src/routes/portal-data.ts | 190 ++++++ cypher-executor/src/routes/portal-ui.ts | 743 ++++++++++++++++++++++ 3 files changed, 938 insertions(+), 1 deletion(-) create mode 100644 cypher-executor/src/routes/portal-data.ts create mode 100644 cypher-executor/src/routes/portal-ui.ts diff --git a/cypher-executor/src/index.ts b/cypher-executor/src/index.ts index e1478df..efe485c 100644 --- a/cypher-executor/src/index.ts +++ b/cypher-executor/src/index.ts @@ -25,6 +25,8 @@ import { consoleRouter } from './routes/console'; import { consoleAuthRouter } from './routes/console-auth'; import { consoleDashboardRouter } from './routes/console-dashboard'; import { portalRouter } from './routes/portal'; +import { portalDataRouter } from './routes/portal-data'; +import { portalUiRouter } from './routes/portal-ui'; const app = new Hono<{ Bindings: Bindings }>(); @@ -57,7 +59,9 @@ app.route('/', kbdbProxyRouter); // kbdb-base 9.5:KBDB 資料層 proxy(讓 app.route('/', consoleRouter); // Arcrun#3:搜尋/控制台頁 v0(單檔 HTML+原生 JS,薄殼) app.route('/', consoleAuthRouter); // Arcrun#3 發現②:console 專用簡單 email+password 登入(單一管理員帳密,非多租戶) app.route('/', consoleDashboardRouter); // T-cockpit ②:駕駛艙 dashboard(聚合 KBDB dash_* entries,無需登入唯讀) -app.route('/', portalRouter); // portal-auth P2(#24/#25):RAG Portal 多人授權——用戶模型+認證 API(P3 UI 另一波) +app.route('/', portalRouter); // portal-auth P2(#24/#25):RAG Portal 多人授權——用戶模型+認證 API +app.route('/', portalDataRouter); // portal-auth P3:/portal/data/* server-side enforce(owner_id+library 注入,安全核心) +app.route('/', portalUiRouter); // portal-auth P3:GET /portal 單檔 HTML 殼(登入/搜尋/設定;獨立於 console) // Worker 導出(fetch + scheduled) // scheduled handler 對應 wrangler.toml [triggers].crons,每分鐘 tick; diff --git a/cypher-executor/src/routes/portal-data.ts b/cypher-executor/src/routes/portal-data.ts new file mode 100644 index 0000000..7093885 --- /dev/null +++ b/cypher-executor/src/routes/portal-data.ts @@ -0,0 +1,190 @@ +/** + * RAG Portal 查詢面 — P3:/portal/data/* server-side enforce(portal-auth design §3.3/§3.4/§5, + * Gitea #24/#25)。本檔是本 SDD 的**安全核心**。 + * + * 安全模型(design §3.3,與 console 的關鍵差異): + * - console 登入後把 CONSOLE_TENANT 下發給前端直打 /kbdb/*;**portal 前端絕不持有租戶字串**, + * 只有 portal session token。portal_user 拿到租戶字串就能繞過庫 filter 直打 /kbdb/search, + * 所以 enforce 全在 server:session → 回讀 user record(唯一真相源)→ 取 libraries → + * server 注入 owner_id+library 後轉發 KBDB。 + * - caller 自帶的 owner_id / library query 參數**一律忽略**(不是拒絕——拒絕會變成 + * 「試參數名」的 oracle;直接靜默覆蓋,怎麼傳都是自己的權限範圍)。 + * - ["*"]=全庫:只注 owner_id、不注 library(design §3.3)。 + * + * 不洩存在性(紅線):越庫的 entry(含根本不存在的 id、別的租戶的 id)一律回**同一句 404**, + * 不讓攻擊者從 403/404 差異推斷某 id / 某庫存在。唯一例外=graph 粗閘按 SDD 明定回 403(D-4)。 + * + * 薄殼(rule 07):本檔沒有新能力——搜尋/取條目能力真身在 KBDB base(P1 的 library filter), + * graph 真身在 kbdb-graph-plugin,工作流真身在 WEBHOOKS/ANALYTICS KV(與 /webhooks/named、 + * /workflows/:name/executions 同一資料源)。這裡只做「權限注入+轉發/讀取」。 + * + * log 紅線:本檔不 log 任何 token / 密碼 / 查詢內容。 + */ +import { Hono } from 'hono'; +import type { Context } from 'hono'; +import type { Bindings } from '../types'; +import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible } from './portal'; +import { graphBase } from './kbdb-proxy'; + +export const portalDataRouter = new Hono<{ Bindings: Bindings }>(); + +/** 越庫/不存在 一律同一句 404(不洩存在性)。 */ +function notFound(c: Context<{ Bindings: Bindings }>): Response { + return c.json({ error: '找不到這筆資料' }, 404); +} + +/** entry 的庫歸屬:metadata_json.$.library,未標記 → 'general'(design §3.2 fallback,與 KBDB P1 同語意)。 */ +export function entryLibrary(entry: { metadata_json?: string | null }): string { + try { + const meta = JSON.parse(entry.metadata_json ?? 'null') as { library?: unknown } | null; + if (meta && typeof meta.library === 'string' && meta.library.trim()) return meta.library; + } catch { + /* metadata 壞掉 → 視同未標記 */ + } + return 'general'; +} + +/** 用戶庫集合是否覆蓋某庫(["*"]=全庫)。 */ +function canReadLibrary(userLibraries: string[], library: string): boolean { + return userLibraries.includes('*') || userLibraries.includes(library); +} + +// GET /portal/data/search?q=&mode=&entry_type=&limit= — 三模式中的 keyword/semantic +//(graph 走 /portal/data/graph/*)。server 注入 owner_id+library;回應照 KBDB 原形 +//(entries 含 metadata_json,前端自取 source 溯源;mode/capability_hint 誠實透傳—— +// semantic 未開的降級行為沿 KBDB 既有,P1 已保 library 照 enforce)。 +portalDataRouter.get('/portal/data/search', (c) => + run(c, async () => { + const auth = await requirePortalUser(c); + if (!auth.ok) return auth.res; + const q = c.req.query('q'); + if (!q) return c.json({ error: 'q 必填' }, 400); + + const libraries = parseLibraries(auth.user.values.libraries); + if (libraries.length === 0) { + // 帳號沒被授權任何庫:誠實空結果(不打 KBDB——沒有可查範圍就沒有查詢) + return c.json({ success: true, entries: [], count: 0, mode: 'keyword', note: '此帳號尚未被授權任何知識庫,請聯絡管理員。' }); + } + + const params = new URLSearchParams({ q, owner_id: portalTenant(c.env) }); + if (!libraries.includes('*')) params.set('library', libraries.join(',')); + // 透傳的只有「在權限範圍內再收窄」的 filter;owner_id/library 上面已由 server 定死, + // caller 傳什麼都不看(URLSearchParams 是新建的,蓋不掉)。 + if (c.req.query('mode') === 'semantic') params.set('mode', 'semantic'); + const entryType = c.req.query('entry_type'); + if (entryType) params.set('entry_type', entryType); + const limit = c.req.query('limit'); + if (limit && /^\d{1,3}$/.test(limit)) params.set('limit', limit); + + const res = await kbdbFetch(c.env, `/entries/search?${params.toString()}`); + return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); + }), +); + +// GET /portal/data/entries/:id — 卡片詳頁。**逐筆驗庫**(design §5): +// ① entry 必須屬於本實例租戶(owner_id=CONSOLE_TENANT)——防拿別租戶 id 直讀; +// ② entry 的 library(NULL→general)必須在用戶庫集合內——防拿越庫 id 直讀。 +// 兩者不符與不存在同回 404(不洩存在性)。 +portalDataRouter.get('/portal/data/entries/:id', (c) => + run(c, async () => { + const auth = await requirePortalUser(c); + if (!auth.ok) return auth.res; + const libraries = parseLibraries(auth.user.values.libraries); + if (libraries.length === 0) return notFound(c); + + const res = await kbdbFetch(c.env, `/entries/${encodeURIComponent(c.req.param('id'))}`); + if (res.status === 404) return notFound(c); + if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502); + const body = (await res.json()) as { entry?: { owner_id?: string | null; metadata_json?: string | null } }; + const entry = body.entry; + if (!entry) return notFound(c); + if ((entry.owner_id ?? '') !== portalTenant(c.env)) return notFound(c); + if (!canReadLibrary(libraries, entryLibrary(entry))) return notFound(c); + return c.json({ success: true, entry }); + }), +); + +// GET /portal/data/graph/neighbors/:name — graph 模式(D-4 粗閘): +// 只對「擁有 graph 來源庫權限」的用戶開放;無權 → 403(SDD 明定,graph 粗閘是 404 紅線的例外)。 +// 放行後純轉發 kbdb-graph-plugin(token 只在 server 側,同 kbdb-proxy 慣例)。 +portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) => + run(c, async () => { + const auth = await requirePortalUser(c); + if (!auth.ok) return auth.res; + const libraries = parseLibraries(auth.user.values.libraries); + if (!(await hasGraphAccess(c.env, libraries))) { + return c.json({ error: '無知識圖譜檢視權限' }, 403); + } + const base = graphBase(c.env); + const headers: Record = {}; + if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`; + try { + const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param('name'))}`, { headers }); + return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); + } catch (e) { + // plugin 沒部署/不可達 → 誠實 502(前端顯示「關聯服務不可達」,不假裝無關聯) + return c.json({ error: `kbdb-graph-plugin 不可達:${e instanceof Error ? e.message : String(e)}` }, 502); + } + }), +); + +// GET /portal/data/workflows — 工作流顯示(D-8):唯讀 list+每條的最近一次執行,**不開 trigger** +//(trigger 是 owner/console 的事;回應也不含 webhook_url,不給可打的把手)。 +// 可見性:PORTAL_SHOW_WORKFLOWS=admin(預設,role 閘 403)/ all / off(整頁不存在 → 404)。 +portalDataRouter.get('/portal/data/workflows', (c) => + run(c, async () => { + const auth = await requirePortalUser(c); + if (!auth.ok) return auth.res; + const setting = (c.env.PORTAL_SHOW_WORKFLOWS ?? 'admin').toLowerCase(); + if (setting === 'off') return notFound(c); + if (!workflowsVisible(c.env, auth.user.values.role ?? 'user')) { + return c.json({ error: '需要 admin 權限' }, 403); + } + + // 資料源與 /webhooks/named + /workflows/:name/executions 同一份(WEBHOOKS/ANALYTICS KV)。 + // 不經 HTTP 打自己(global_fetch_strictly_public 下 fetch 自己 hostname 會 self-loop), + // 直讀同 worker 的 KV binding;欄位收斂成唯讀展示需要的最小集合。 + const tenant = portalTenant(c.env); + const prefix = `${tenant}:wf:`; + const list = await c.env.WEBHOOKS.list({ prefix }); + const workflows = await Promise.all( + list.keys.map(async (k) => { + const name = k.name.slice(prefix.length); + const raw = await c.env.WEBHOOKS.get(k.name, 'text'); + let description = ''; + let created_at = ''; + let cron_expr: string | undefined; + if (raw) { + try { + const rec = JSON.parse(raw) as { description?: string; created_at?: string; cron_expr?: string }; + description = rec.description ?? ''; + created_at = rec.created_at ?? ''; + cron_expr = rec.cron_expr; + } catch { + /* 壞 record 誠實留空 */ + } + } + // 最近一次執行:ANALYTICS_KV stats:{name}:{unix_ms}——key 後綴定長毫秒 timestamp, + // 字典序=時間序,取最後一把 key 即最新(同 /workflows/:name/executions 的排序邏輯)。 + let last_execution: { timestamp: string; verdict?: string } | null = null; + const stats = await c.env.ANALYTICS_KV.list({ prefix: `stats:${name}:`, limit: 1000 }); + if (stats.keys.length > 0) { + const latest = stats.keys.reduce((a, b) => (a.name > b.name ? a : b)); + const ts = latest.name.split(':').pop() ?? ''; + const rawStat = await c.env.ANALYTICS_KV.get(latest.name); + let verdict: string | undefined; + if (rawStat) { + try { + verdict = (JSON.parse(rawStat) as { verdict?: string }).verdict; + } catch { + /* 壞 record 誠實留空 */ + } + } + last_execution = { timestamp: ts, verdict }; + } + return { name, description, created_at, cron_expr, last_execution }; + }), + ); + return c.json({ success: true, workflows, total: workflows.length, read_only: true }); + }), +); diff --git a/cypher-executor/src/routes/portal-ui.ts b/cypher-executor/src/routes/portal-ui.ts new file mode 100644 index 0000000..a6f8687 --- /dev/null +++ b/cypher-executor/src/routes/portal-ui.ts @@ -0,0 +1,743 @@ +/** + * RAG Portal 前端殼 — P3:GET /portal 單檔 HTML(portal-auth design §1 D-1/§6,Gitea #24/#25) + * + * 形態(D-1):cypher-executor 的新路由、**獨立 HTML 殼**——不在 console 上加 if(「Admin + * Console 現狀不動」鐵律)。樣式重用 console 的設計語言(紙感/明體標題/琥珀強調/深淺主題), + * 取捨=**複製樣式子集而非抽共用模組**:抽共用要動 console.ts(違「console 不動」),且兩頁 + * 受眾不同(owner vs 同仁)預期各自演化;代價=樣式雙份、改版要兩邊同步(PR 說明誠實記)。 + * + * 頁面(design §6,leo 頁面級拍板): + * - 登入頁(brand=CONSOLE_BRAND;未登入只見這個殼) + * - 搜尋頁(落地頁):keyword/semantic/graph 三模式+結果含 source 溯源+卡片詳頁 + * - 設定頁:改自己密碼、看自己角色與可查庫、主題切換 + * - 工作流頁:D-8 定案 admin 可見(PORTAL_SHOW_WORKFLOWS 可調 all/off);唯讀、不開 trigger + * - graph 模式按 D-4 粗閘顯示(/portal/session 的 graph_allowed) + * - Mira 專屬頁(駕駛艙/分流台/憑證/專案)一律不進 + * + * 安全(design §3.3 關鍵差異 vs console): + * - 本頁 JS **沒有任何租戶字串、沒有 X-Arcrun-API-Key**——只持 portal session token + * (localStorage `arcrun_portal_session`),一切資料走 /portal/data/*(server-side enforce)。 + * - 前端的「藏」(graph 模式不顯示、工作流頁不顯示)只是 UX;真閘在 /portal/data/* 路由層 + * (403/404),curl 直打也繞不過(#48 guard 精神)。 + * - 所有動態內容經 esc() 跳脫(防 XSS,同 console 慣例);密碼欄位值不進 log、不進 URL。 + * + * 薄殼(rule 07):零業務邏輯,只 fetch /portal/* 端點渲染。 + */ +import { Hono } from 'hono'; +import type { Bindings } from '../types'; +import { TAIPEI_CLIENT_JS } from '../lib/taipei-time'; + +export const portalUiRouter = new Hono<{ Bindings: Bindings }>(); + +function renderPortalHtml(brand: string): string { + return ` + + + + +${brand} Portal + + + + + + +
+
+
+
${brand}
+
知識入口・Portal
+
+
+ + + +
+
+
帳號由管理員發放。忘記密碼請聯絡管理員重設。
+ +
+
+ + +
+
+ + + + + +
+
+
+ + + + + +
+
+ + 知識卡片 +
+
載入中…
+
+ + +
+
工作流唯讀・系統狀態
+
+ 此頁只顯示系統裡的工作流與最近執行狀態,不能觸發執行(觸發屬系統擁有者權限)。 +
+
載入中…
+
+ + +
+
設定
+
+
+
我的帳號
+
載入中…
+
+
+
+
+
深色模式
+
預設淺色(紙感)。切換立即生效,選擇記在這台裝置。
+
+
+
+
+
+
更改密碼
+
需輸入舊密碼驗證身分;新密碼至少 8 碼
+
+ + + + +
+
+
+ +
+
+ +
+
+ + +
+
搜尋
+
工作流
+
設定
+
+ +
+ + + +`; +} + +// GET /portal — Portal HTML 殼(無 auth:回的是純殼,資料全在 /portal/data/* session 閘後)。 +// 未 bootstrap 任何 portal_user 時登入必失敗(「尚未啟用」的誠實形態),不影響 console。 +portalUiRouter.get('/portal', (c) => { + const brand = c.env.CONSOLE_BRAND || 'Arcrun'; + return c.html(renderPortalHtml(brand)); +});