Files
xinmi/scripts/verify_public_tenant_isolation.cjs
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

114 lines
9.0 KiB
JavaScript

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))
})