portal-auth P3(#24 #25):Portal UI+/portal/data/* server-side enforce(不 merge,待總管審) (#52)
This commit was merged in pull request #52.
This commit is contained in:
@@ -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<string, string> = {};
|
||||
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 });
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user