const startedAt = new Date().toISOString() const assert = require('node:assert/strict') const fs = require('node:fs') const crypto = require('node:crypto') const { execFileSync } = require('node:child_process') const directory = '/Volumes/Extreme SSD/yawan-storage/weknora/acceptance' const origin = 'https://agent.pducv.cn' const prefix = `tenant-isolation-${Date.now()}` const checks = [], failures = [], cleanup = [] let admin, secondTenant const literal = value => `'${String(value).replaceAll("'", "''")}'` const sql = query => execFileSync('docker', ['--context', 'colima-yawan', 'exec', '-i', 'WeKnora-postgres', 'psql', '-X', '-q', '-U', 'postgres', '-d', 'yawan_native_acceptance_20260915', '-At', '-v', 'ON_ERROR_STOP=1'], { input: query, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim() function check(name, passed) { checks.push({ name, passed: !!passed }) assert.ok(passed, name) console.log(`PASS ${name}`) } async function raw(route, method = 'GET', body, identity = admin, tenant = 10059) { const form = body instanceof FormData const response = await fetch(origin + '/api/v1' + route, { method, headers: { ...(form ? {} : { 'Content-Type': 'application/json' }), ...(identity ? { Authorization: `Bearer ${identity.token}`, 'X-Tenant-ID': String(tenant) } : {}), }, body: body === undefined ? undefined : form ? body : JSON.stringify(body), signal: AbortSignal.timeout(30000) }) let data = {}; try { data = await response.json() } catch {} return { status: response.status, data } } async function api(...args) { const response = await raw(...args) assert.ok(response.status >= 200 && response.status < 300, `${args[0]}: HTTP ${response.status}`) return response.data } async function main() { const credentials = JSON.parse(fs.readFileSync(`${directory}/super-admin-login.json`, 'utf8')) admin = await api('/auth/login', 'POST', { email: credentials.email, password: credentials.password }, null) const role = (await api('/access/roles', 'POST', { name: prefix, permissions: ['knowledge.read', 'knowledge.write', 'agents.read', 'agents.write', 'agents.skills', 'content.read', 'content.write', 'content.deliver', 'flows.read', 'flows.write', 'chat.read', 'chat.write'] })).data cleanup.push(() => api(`/access/roles/${role.id}`, 'DELETE')) async function member(label) { const password = crypto.randomBytes(18).toString('base64url'), email = `${prefix}-${label}@example.test` const user = (await api('/access/users', 'POST', { username: `${prefix}-${label}`, email, password, role: 'contributor', status: 'active', custom_role_id: role.id })).data return { id: user.id, email, password } } const aliceUser = await member('alice') cleanup.push(() => api(`/access/users/${aliceUser.id}`, 'DELETE')) const bobUser = await member('bob') let bobTenant = 10059 cleanup.push(async () => { if (bobTenant !== 10059) { sql(`BEGIN; UPDATE users SET tenant_id=10059 WHERE id=${literal(bobUser.id)} AND email=${literal(bobUser.email)}; UPDATE tenant_members SET tenant_id=10059 WHERE user_id=${literal(bobUser.id)} AND tenant_id=${bobTenant}; COMMIT;`) } await api(`/access/users/${bobUser.id}`, 'DELETE') }) secondTenant = Number(sql(`INSERT INTO tenants(name,business,status) VALUES (${literal(prefix)},'isolation-fixture','active') RETURNING id;`)) assert.ok(Number.isSafeInteger(secondTenant) && secondTenant > 10059) // Move only the disposable member to an actual separate workspace. sql(`BEGIN; UPDATE users SET tenant_id=${secondTenant} WHERE id=${literal(bobUser.id)}; UPDATE tenant_members SET tenant_id=${secondTenant} WHERE user_id=${literal(bobUser.id)} AND tenant_id=10059; DELETE FROM workbench_access_assignments WHERE user_id=${literal(bobUser.id)} AND tenant_id=10059; COMMIT;`) bobTenant = secondTenant const alice = await api('/auth/login', 'POST', { email: aliceUser.email, password: aliceUser.password }, null) const bob = await api('/auth/login', 'POST', { email: bobUser.email, password: bobUser.password }, null) check('two_real_distinct_tenants', Number(alice.user.tenant_id) === 10059 && Number(bob.user.tenant_id) === secondTenant) check('second_tenant_member_has_live_access', (await raw('/access/me', 'GET', undefined, bob, secondTenant)).status === 200) for (const [label, identity, tenant] of [['alice', alice, secondTenant], ['bob', bob, 10059]]) { check(`${label}_cannot_select_foreign_existing_tenant`, [401, 403, 404].includes((await raw('/access/me', 'GET', undefined, identity, tenant)).status)) } const resources = [] const cfg = { role: 'production', agent_mode: 'quick-answer', kb_selection_mode: 'none', knowledge_bases: [], system_prompt: 'Disposable tenant boundary fixture.' } const agent = (await api('/agents', 'POST', { name: prefix, config: cfg }, alice)).data resources.push({ name: 'agent', route: `/agents/${agent.id}`, list: '/agents', id: agent.id, update: { name: prefix + '-forbidden', config: cfg } }) cleanup.push(() => api(`/agents/${agent.id}?detach=true`, 'DELETE')) const kb = (await api('/knowledge-bases', 'POST', { name: prefix, type: 'document', indexing_strategy: { vector_enabled: false, keyword_enabled: false, wiki_enabled: false, graph_enabled: false } }, alice)).data resources.push({ name: 'knowledge', route: `/knowledge-bases/${kb.id}`, list: '/knowledge-bases', id: kb.id, update: { name: prefix + '-forbidden' } }) cleanup.push(() => api(`/knowledge-bases/${kb.id}`, 'DELETE')) const form = new FormData(); form.set('file', new Blob(['# Fixture\nDo not use outside isolation verification.'], { type: 'text/markdown' }), `${prefix}.md`) const skill = (await api('/skills/instructions/import', 'POST', form, alice)).data resources.push({ name: 'skill', route: `/skills/instructions/${skill.id}`, list: '/skills/instructions', id: skill.id }) cleanup.push(() => api(`/skills/instructions/${skill.id}?detach=true`, 'DELETE')) for (const kind of ['flows', 'training-maps', 'content-tasks']) { const id = crypto.randomUUID() sql(`INSERT INTO workbench_resources(id,tenant_id,owner_id,owner_name,kind,name,status,version,data,created_at,updated_at) VALUES(${literal(id)},10059,${literal(aliceUser.id)},${literal(prefix)},${literal(kind)},${literal(prefix)},'draft',1,'{}',now(),now());`) cleanup.push(() => sql(`DELETE FROM workbench_resources WHERE id=${literal(id)} AND owner_id=${literal(aliceUser.id)}`)) resources.push({ name: kind, route: `/workbench/resources/${kind}/${id}`, list: `/workbench/resources/${kind}`, id, update: { name: prefix + '-forbidden', version: 1, data: {} } }) } const session = (await api('/sessions', 'POST', { title: prefix }, alice)).data cleanup.push(() => api(`/sessions/${session.id}`, 'DELETE', undefined, alice)) resources.push({ name: 'session', route: `/sessions/${session.id}`, list: '/sessions', id: session.id }) for (const resource of resources) { check(`${resource.name}_owner_positive_read`, (await raw(resource.route, 'GET', undefined, alice)).status === 200) check(`${resource.name}_cross_tenant_read_denied`, [403, 404].includes((await raw(resource.route, 'GET', undefined, bob, secondTenant)).status)) check(`${resource.name}_cross_tenant_list_hidden`, !JSON.stringify(await api(resource.list, 'GET', undefined, bob, secondTenant)).includes(resource.id)) if (resource.update) check(`${resource.name}_cross_tenant_update_denied`, [403, 404].includes((await raw(resource.route, 'PUT', resource.update, bob, secondTenant)).status)) check(`${resource.name}_cross_tenant_delete_denied`, [403, 404].includes((await raw(resource.route, 'DELETE', undefined, bob, secondTenant)).status)) check(`${resource.name}_owner_survives_foreign_mutations`, (await raw(resource.route, 'GET', undefined, alice)).status === 200) } const graph = await raw(`/knowledge-bases/${kb.id}/evidence-graph`, 'GET', undefined, bob, secondTenant) check('cross_tenant_graph_denied', [403, 404].includes(graph.status)) const search = await raw(`/knowledge-bases/${kb.id}/hybrid-search`, 'POST', { query_text: prefix, match_count: 1 }, bob, secondTenant) check('cross_tenant_explicit_search_denied', [403, 404].includes(search.status)) check('no_ai_usage', sql(`SELECT count(*) FROM workbench_usage WHERE owner_id IN (${literal(aliceUser.id)},${literal(bobUser.id)})`) === '0') } main().catch(error => { failures.push(error.message); process.exitCode = 1 }).finally(async () => { for (const action of cleanup.reverse()) try { await action() } catch (error) { failures.push('cleanup: ' + error.message); process.exitCode = 1 } if (secondTenant) { try { sql(`DELETE FROM tenant_members WHERE tenant_id=${secondTenant}; DELETE FROM tenants WHERE id=${secondTenant} AND name=${literal(prefix)};`) } catch { failures.push('cleanup secondary tenant'); process.exitCode = 1 } } const report = { started_at: startedAt, checked_at: new Date().toISOString(), origin, prefix, second_tenant: secondTenant, checks, failures, passed: failures.length === 0 } fs.writeFileSync(`${directory}/public-tenant-isolation.json`, JSON.stringify(report, null, 2)) console.log(JSON.stringify(report)) })