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
114 lines
9.0 KiB
JavaScript
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))
|
|
})
|