fix(mcp): MCP 用登入者的身分查詢,不再去找一把服務內部金鑰
leo 2026-08-12:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;
AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ 下游不得再要求第二次認證。
病根(不是金鑰沒同步,是身分沒接住):
oauth/routes.ts 驗完 Portal 帳密只留下 `loginOk = res.ok` 一個布林值,身分當場丟棄,
namespace 改從 `MCP_OWNER_NAMESPACE || "leo"` 拿。於是查詢時手上沒有身分可帶,
只好用 KBDB_INTERNAL_TOKEN 直打 KBDB——那條路繞過 portal 所有庫過濾,
而且不管誰登入都看到同一格、看到全部。CLI 也從不注入 MCP_OWNER_NAMESPACE,
所以那個 "leo" 預設值是每台實例的實際行為,不是理論上的邊角。
修法(走既有那條路,不發明新的):
1. 接住身分:/authorize 解析 /portal/login 回應,把 portal session token +
display_name/role/libraries 存進 authorization code → access token。
/portal/login 補回 session_expires_in,access_token TTL 夾成
min(自己的 TTL, portal session TTL)——不讓「MCP 還連著、底下 session 早死」。
cypher 回 200 但沒給 session_token(舊版)→ 不發碼,不簽一張沒有身分的 token。
2. 攜帶身分:kbdb_* 全部改走 cypher `/portal/data/*`,Authorization 帶登入者的
session。庫過濾/租戶注入/停用即時生效全在 server 側,與人類走 portal 網頁同一道閘。
kbdb_graph_neighbors 因此不再需要 kbdb_base(server 自己知道查哪個庫)。
藏書地圖(含連線時注入 instructions 的那份)同樣只回有權限的庫,快取改 per-session
分格——地圖本身就是情報,不能讓先連上的人把視野留給下一個。
3. fail-closed:舊 token 沒有身分 → 誠實要求重新連線,不偷偷退回服務金鑰那條老路。
服務級憑據(static token / partner key)維持既有 KBDB 直連,arcrun_* 零回歸。
新增 cypher portal 資料面端點(能力長在 API,MCP 只暴露;rule 07):
GET /portal/data/map、/portal/data/map/:library
GET /portal/data/templates、POST /portal/data/templates
GET /portal/data/records/by-template/:t、GET /portal/data/records/:id
POST /portal/data/records
全部:呼叫端自帶 owner_id 一律不生效;越權與不存在同回 404;寫入 owner_id 由 server 定死。
KBDB base:`GET /records/:id` 與 by-template 補回 owner_id 欄位——原本不回,
呼叫端無從判斷「這筆是不是我的」,按 id 直讀等於沒有租戶邊界。
沒動:KBDB fail-closed 閘、任何金鑰、租戶字串仍不下發給呼叫端。
驗證:
mcp tsc 綠;vitest 113/113 綠(改前 48 綠 29 紅)
cypher vitest 400 綠 / 14 紅,14 紅與 base commit a24f291 逐條相同(既有)
kbdb vitest 208 綠 / 5 紅,5 紅同為既有(migrations/*.sql 被 gitignore)
端到端 ◐ 未驗:需部署到 leo21c,那道閘要 leo 親手解(見 PR)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -232,6 +232,214 @@ describe('GET /portal/data/entries/:id(逐筆驗庫)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 3b. 授權的 AI(arcrun-mcp)走的資料面 ═══════════════
|
||||
//
|
||||
// leo 2026-08-12:「AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
|
||||
// ⇒ 這幾支端點與人類走的 search/entries 是同一道閘:同一個 session、同一份庫權限、
|
||||
// 同樣「呼叫端自帶 owner_id 一律不生效」、同樣「越權與不存在同一句 404」。
|
||||
|
||||
describe('藏書地圖 /portal/data/map(MCP 走的那條)', () => {
|
||||
it('只回這個帳號有權限的庫;全館其他庫不出現在回應裡', async () => {
|
||||
await seedSession('tok-m1', 'rec_1');
|
||||
mockGetRecord('rec_1', userValues({ libraries: '["finance"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/map?'), method: 'GET' })
|
||||
.reply(200, {
|
||||
success: true,
|
||||
libraries: [
|
||||
{ library: 'finance', narrative: '財務', top_entities: [], triplet_count: 3 },
|
||||
{ library: 'hr', narrative: '人資', top_entities: [], triplet_count: 9 },
|
||||
],
|
||||
count: 2,
|
||||
});
|
||||
const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m1' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { libraries: { library: string }[]; count: number };
|
||||
expect(data.libraries.map((l) => l.library)).toEqual(['finance']);
|
||||
expect(data.count).toBe(1);
|
||||
});
|
||||
|
||||
it('["*"] 全庫 → 全部庫都回', async () => {
|
||||
await seedSession('tok-m2', 'rec_2');
|
||||
mockGetRecord('rec_2', userValues({ libraries: '["*"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/map?'), method: 'GET' })
|
||||
.reply(200, {
|
||||
success: true,
|
||||
libraries: [
|
||||
{ library: 'finance', narrative: '', top_entities: [], triplet_count: 3 },
|
||||
{ library: 'hr', narrative: '', top_entities: [], triplet_count: 9 },
|
||||
],
|
||||
count: 2,
|
||||
});
|
||||
const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m2' });
|
||||
const data = (await res.json()) as { libraries: { library: string }[] };
|
||||
expect(data.libraries.map((l) => l.library)).toEqual(['finance', 'hr']);
|
||||
});
|
||||
|
||||
it('庫集合為空 → 誠實空結果+說明,不打 KBDB', async () => {
|
||||
await seedSession('tok-m3', 'rec_3');
|
||||
mockGetRecord('rec_3', userValues({ libraries: '[]' }));
|
||||
const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m3' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { count: number; note?: string };
|
||||
expect(data.count).toBe(0);
|
||||
expect(data.note).toContain('尚未被授權');
|
||||
});
|
||||
|
||||
it('單庫詳圖:無權該庫 → 404 同一句(不打 KBDB,不洩該庫存不存在)', async () => {
|
||||
await seedSession('tok-m4', 'rec_4');
|
||||
mockGetRecord('rec_4', userValues({ libraries: '["finance"]' }));
|
||||
const res = await get('/portal/data/map/hr', { Authorization: 'Bearer tok-m4' });
|
||||
expect(res.status).toBe(404);
|
||||
expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料');
|
||||
});
|
||||
|
||||
it('單庫詳圖:有權該庫 → 200 轉發', async () => {
|
||||
await seedSession('tok-m5', 'rec_5');
|
||||
mockGetRecord('rec_5', userValues({ libraries: '["finance"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/map/finance'), method: 'GET' })
|
||||
.reply(200, { success: true, map: { library: 'finance', triplet_count: 3 } });
|
||||
const res = await get('/portal/data/map/finance', { Authorization: 'Bearer tok-m5' });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('未登入 → 401', async () => {
|
||||
expect((await get('/portal/data/map')).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('結構化資料 /portal/data/records、/portal/data/templates(MCP 走的那條)', () => {
|
||||
it('by-template:server 注入 owner_id;caller 自帶的被靜默覆蓋(繞不過)', async () => {
|
||||
await seedSession('tok-r1', 'rec_1');
|
||||
mockGetRecord('rec_1', userValues({ libraries: '["*"]' }));
|
||||
let captured = '';
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({
|
||||
path: (p: string) => {
|
||||
if (!p.startsWith('/records/by-template/contact')) return false;
|
||||
captured = p;
|
||||
return true;
|
||||
},
|
||||
method: 'GET',
|
||||
})
|
||||
.reply(200, { success: true, records: [], count: 0 });
|
||||
const res = await get('/portal/data/records/by-template/contact?owner_id=someone-else', {
|
||||
Authorization: 'Bearer tok-r1',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(new URL(`http://x${captured}`).searchParams.get('owner_id')).toBe(TENANT);
|
||||
});
|
||||
|
||||
it('by-template:有標 library 的 record 越庫的被濾掉;沒標 library 的照回', async () => {
|
||||
await seedSession('tok-r2', 'rec_2');
|
||||
mockGetRecord('rec_2', userValues({ libraries: '["finance"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
|
||||
.reply(200, {
|
||||
success: true,
|
||||
records: [
|
||||
{ record_id: 'r1', owner_id: TENANT, values: { library: 'finance', subject: 'A' } },
|
||||
{ record_id: 'r2', owner_id: TENANT, values: { library: 'hr', subject: 'B' } },
|
||||
{ record_id: 'r3', owner_id: TENANT, values: { subject: 'C' } }, // 沒標庫=結構化資料列
|
||||
],
|
||||
count: 3,
|
||||
});
|
||||
const res = await get('/portal/data/records/by-template/triplet', { Authorization: 'Bearer tok-r2' });
|
||||
const data = (await res.json()) as { records: { record_id: string }[] };
|
||||
expect(data.records.map((r) => r.record_id)).toEqual(['r1', 'r3']);
|
||||
});
|
||||
|
||||
it('單筆:別的租戶的 record → 404 同一句(就算全庫權限也擋)', async () => {
|
||||
await seedSession('tok-r3', 'rec_3');
|
||||
mockGetRecord('rec_3', userValues({ libraries: '["*"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records/r_other', method: 'GET' })
|
||||
.reply(200, { success: true, record: { record_id: 'r_other', owner_id: 'other-tenant', values: {} } });
|
||||
const res = await get('/portal/data/records/r_other', { Authorization: 'Bearer tok-r3' });
|
||||
expect(res.status).toBe(404);
|
||||
expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料');
|
||||
});
|
||||
|
||||
it('單筆:越庫的 record → 404 同一句;有權的 → 200', async () => {
|
||||
await seedSession('tok-r4', 'rec_4');
|
||||
mockGetRecord('rec_4', userValues({ libraries: '["finance"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records/r_hr', method: 'GET' })
|
||||
.reply(200, { success: true, record: { record_id: 'r_hr', owner_id: TENANT, values: { library: 'hr' } } });
|
||||
expect((await get('/portal/data/records/r_hr', { Authorization: 'Bearer tok-r4' })).status).toBe(404);
|
||||
|
||||
await seedSession('tok-r5', 'rec_5');
|
||||
mockGetRecord('rec_5', userValues({ libraries: '["finance"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records/r_fin', method: 'GET' })
|
||||
.reply(200, { success: true, record: { record_id: 'r_fin', owner_id: TENANT, values: { library: 'finance' } } });
|
||||
expect((await get('/portal/data/records/r_fin', { Authorization: 'Bearer tok-r5' })).status).toBe(200);
|
||||
});
|
||||
|
||||
it('寫入:owner_id 由 server 定死,呼叫端塞的不算', async () => {
|
||||
await seedSession('tok-r6', 'rec_6');
|
||||
mockGetRecord('rec_6', userValues({ libraries: '["*"]' }));
|
||||
let body: Record<string, unknown> = {};
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({
|
||||
path: '/records',
|
||||
method: 'POST',
|
||||
body: (b: string) => {
|
||||
body = JSON.parse(b) as Record<string, unknown>;
|
||||
return true;
|
||||
},
|
||||
})
|
||||
.reply(200, { success: true, record: { record_id: 'r_new' } });
|
||||
const res = await SELF.fetch('http://localhost/portal/data/records', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer tok-r6', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ template: 'contact', values: { name: 'Leo' }, owner_id: 'someone-else' }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.owner_id).toBe(TENANT);
|
||||
});
|
||||
|
||||
it('寫入越庫 → 403(明確拒絕,庫名是呼叫端自己指定的,沒有存在性可洩)', async () => {
|
||||
await seedSession('tok-r7', 'rec_7');
|
||||
mockGetRecord('rec_7', userValues({ libraries: '["finance"]' }));
|
||||
const res = await SELF.fetch('http://localhost/portal/data/records', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer tok-r7', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ template: 'note', values: { library: 'hr', body: 'x' } }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('templates 全域共享(schema 非內容):登入即可列', async () => {
|
||||
await seedSession('tok-t1', 'rec_t1');
|
||||
mockGetRecord('rec_t1', userValues({ libraries: '["finance"]' }));
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/templates', method: 'GET' })
|
||||
.reply(200, { success: true, templates: [{ id: 'tpl1', name: 'contact' }], count: 1 });
|
||||
const res = await get('/portal/data/templates', { Authorization: 'Bearer tok-t1' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(((await res.json()) as { count: number }).count).toBe(1);
|
||||
});
|
||||
|
||||
it('未登入 → 401(records / templates 都是)', async () => {
|
||||
expect((await get('/portal/data/templates')).status).toBe(401);
|
||||
expect((await get('/portal/data/records/by-template/contact')).status).toBe(401);
|
||||
expect((await get('/portal/data/records/r1')).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 4. graph D-4 粗閘 ═══════════════
|
||||
|
||||
describe('GET /portal/data/graph/neighbors/:name(D-4 粗閘)', () => {
|
||||
|
||||
Reference in New Issue
Block a user