WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
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
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
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
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 base = process.env.ISOLATION_API_URL || 'http://127.0.0.1:18080/api/v1'
|
||||
const prefix = `resource-isolation-${Date.now()}`
|
||||
const checks = [], failures = [], users = [], roles = [], agents = [], skills = [], kbs = [], memoryFixtures = []
|
||||
let admin, alice
|
||||
const literal = value => `'${String(value).replaceAll("'", "''")}'`
|
||||
const sql = statement => execFileSync('docker', ['--context', 'colima-yawan', 'exec', 'WeKnora-postgres', 'psql', '-U', 'postgres', '-d', 'yawan_native_acceptance_20260915', '-v', 'ON_ERROR_STOP=1', '-Atc', statement], {encoding:'utf8', stdio:['ignore','pipe','pipe']}).trim()
|
||||
const memoryTables = ['memory_item_embeddings', 'memory_items', 'memory_tombstones', 'memory_topic_stats', 'memory_doc_affinity', 'memory_subjects']
|
||||
function cleanupMemoryFixture(fixture, runPrefix, executeSQL=sql) {
|
||||
assert.match(runPrefix, /^resource-isolation-\d+$/)
|
||||
assert.match(fixture.id, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)
|
||||
assert.ok(['alice', 'bob'].some(label => fixture.username === `${runPrefix}-${label}`))
|
||||
assert.equal(fixture.email, `${fixture.username}@example.test`)
|
||||
const subjectID = `web_user:${fixture.id}`
|
||||
const scope = `tenant_id=10059 AND subject_id=${literal(subjectID)}`
|
||||
// Verify the disposable account before removing even its empty memory metadata.
|
||||
executeSQL(`BEGIN;
|
||||
DO $fixture_cleanup$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM users WHERE id=${literal(fixture.id)} AND tenant_id=10059 AND username=${literal(fixture.username)} AND email=${literal(fixture.email)}) THEN
|
||||
RAISE EXCEPTION 'Memory cleanup account does not match this verification run';
|
||||
END IF;
|
||||
END; $fixture_cleanup$;
|
||||
${memoryTables.map(table => `DELETE FROM ${table} WHERE ${scope};`).join('\n')}
|
||||
COMMIT;`)
|
||||
const remaining = executeSQL(`SELECT ${memoryTables.map(table => `(SELECT count(*) FROM ${table} WHERE ${scope})`).join(',')}`)
|
||||
assert.equal(remaining, memoryTables.map(() => '0').join('|'), `Memory cleanup left rows for fixture ${fixture.id}`)
|
||||
return subjectID
|
||||
}
|
||||
function check(name, passed, detail) {
|
||||
checks.push({name, passed:!!passed, ...(detail ? {detail} : {})})
|
||||
if (!passed) failures.push(name)
|
||||
console.log(`${passed ? 'PASS' : 'FAIL'} ${name}${detail ? ' '+detail : ''}`)
|
||||
}
|
||||
async function raw(route, method='GET', body, identity=admin, tenant='10059') {
|
||||
const form=body instanceof FormData
|
||||
const response=await fetch(base+route,{method,headers:{...(form?{}:{'Content-Type':'application/json'}),...(identity?{Authorization:`Bearer ${identity.token}`,'X-Tenant-ID':tenant}:{})},body:body===undefined?undefined:form?body:JSON.stringify(body),signal:AbortSignal.timeout(30000)})
|
||||
const text=await response.text()
|
||||
let data;try{data=JSON.parse(text)}catch{data={}}
|
||||
return {status:response.status,data}
|
||||
}
|
||||
async function api(route, method='GET', body, identity=admin) {
|
||||
const response=await raw(route,method,body,identity)
|
||||
assert.ok(response.status>=200&&response.status<300,`${method} ${route}: ${response.status} ${response.data.message||response.data.error?.message||''}`)
|
||||
return response.data
|
||||
}
|
||||
async function denied(name, route, method, body, identity, onUnexpectedSuccess) {
|
||||
const result=await raw(route,method,body,identity)
|
||||
if(result.status>=200&&result.status<300&&onUnexpectedSuccess)onUnexpectedSuccess(result.data)
|
||||
check(name,[403,404].includes(result.status),`status=${result.status}`)
|
||||
}
|
||||
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 permissions=['knowledge.read','knowledge.write','agents.read','agents.write','agents.skills','agents.memory','chat.read','chat.write']
|
||||
const role=(await api('/access/roles','POST',{name:prefix,permissions})).data;roles.push(role.id)
|
||||
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;users.push(user.id)
|
||||
memoryFixtures.push({id:user.id,username:`${prefix}-${label}`,email})
|
||||
return api('/auth/login','POST',{email,password},null)
|
||||
}
|
||||
alice=await member('alice');const bob=await member('bob')
|
||||
const kb=(await api('/knowledge-bases','POST',{name:prefix,type:'document',embedding_model_id:'a2c3ce32-6192-46c8-8072-910abfe8afed',storage_backend_id:'edcd8dc2-4e93-4bd4-839c-d835619fc0a6',indexing_strategy:{vector_enabled:false,keyword_enabled:false,wiki_enabled:false,graph_enabled:false}},alice)).data;kbs.push(kb.id)
|
||||
check('knowledge_creator_is_current_user',kb.creator_id===alice.user.id)
|
||||
await denied('fresh_private_knowledge_not_readable',`/knowledge-bases/${kb.id}`,'GET',undefined,bob)
|
||||
await denied('fresh_private_graph_not_readable',`/knowledge-bases/${kb.id}/evidence-graph`,'GET',undefined,bob)
|
||||
const list=(await api('/knowledge-bases','GET',undefined,bob)).data
|
||||
check('fresh_private_knowledge_not_listed',!JSON.stringify(list).includes(kb.id))
|
||||
const resources=[{kind:'knowledge',id:kb.id}]
|
||||
await api('/access/grants','POST',{resources,user_ids:[bob.user.id],action:'grant'})
|
||||
check('knowledge_grant_enables_access',(await raw(`/knowledge-bases/${kb.id}`,'GET',undefined,bob)).status===200)
|
||||
await denied('knowledge_grant_does_not_allow_write',`/knowledge-bases/${kb.id}`,'PUT',{name:'unwanted fixture rename'},bob)
|
||||
await api('/access/grants','POST',{resources,user_ids:[bob.user.id],action:'revoke'})
|
||||
await denied('knowledge_revoke_denies_immediately',`/knowledge-bases/${kb.id}`,'GET',undefined,bob)
|
||||
await denied('graph_revoke_denies_immediately',`/knowledge-bases/${kb.id}/evidence-graph`,'GET',undefined,bob)
|
||||
await denied('search_revoke_denies_immediately',`/knowledge-bases/${kb.id}/hybrid-search`,'POST',{query_text:'isolation',match_count:1},bob)
|
||||
const cfg={role:'production',agent_mode:'quick-answer',kb_selection_mode:'none',knowledge_bases:[],fusion_agent_ids:[],instruction_skill_ids:[],system_prompt:'Disposable isolation fixture.'}
|
||||
const agent=(await api('/agents','POST',{name:prefix,config:cfg},alice)).data;agents.push(agent.id)
|
||||
const bobAgent=(await api('/agents','POST',{name:prefix+'-bob',config:{...cfg,question_suggestions:{starters:{enabled:true,mode:'knowledge',count:6}}}},bob)).data;agents.push(bobAgent.id)
|
||||
const doc=(await api(`/knowledge-bases/${kb.id}/knowledge/manual`,'POST',{title:prefix,content:'Disposable private knowledge.',status:'draft'},alice)).data
|
||||
const chunkID=crypto.randomUUID(), privateQuestion=prefix+' private question?'
|
||||
sql(`INSERT INTO chunks(id,tenant_id,knowledge_base_id,knowledge_id,content,chunk_index,start_at,end_at,metadata) VALUES (${literal(chunkID)},10059,${literal(kb.id)},${literal(doc.id)},'Disposable private question fixture',0,0,34,${literal(JSON.stringify({generated_questions:[{id:'fixture-question',question:privateQuestion}]}))}::jsonb)`)
|
||||
const suggestions=await api(`/agents/${bobAgent.id}/suggested-questions?knowledge_ids=${doc.id}`,'GET',undefined,bob)
|
||||
check('private_document_suggestions_not_readable',!JSON.stringify(suggestions).includes(privateQuestion))
|
||||
for(const [name,scope] of [
|
||||
['private_kb_retrieval_has_no_results',{knowledge_base_ids:[kb.id]}],
|
||||
['private_document_retrieval_has_no_results',{knowledge_ids:[doc.id]}],
|
||||
['private_tag_retrieval_has_no_results',{knowledge_base_ids:[kb.id],tag_ids:['fixture-private-tag']}],
|
||||
]) {
|
||||
const result=await raw('/knowledge-search','POST',{query:prefix,...scope},bob)
|
||||
check(name,[403,404].includes(result.status)||(result.status===200&&Array.isArray(result.data.data)&&result.data.data.length===0),`status=${result.status}`)
|
||||
}
|
||||
await denied('private_document_preview_denied',`/knowledge/${doc.id}/preview`,'GET',undefined,bob)
|
||||
await denied('private_document_chunks_denied',`/chunks/${doc.id}`,'GET',undefined,bob)
|
||||
await denied('private_agent_read_denied',`/agents/${agent.id}`,'GET',undefined,bob)
|
||||
await denied('private_agent_copy_denied',`/agents/${agent.id}/copy`,'POST',{},bob,result=>{
|
||||
assert.ok(result.data?.id && result.data.id!==agent.id,'Unexpected agent copy must return a new ID')
|
||||
agents.push(result.data.id)
|
||||
})
|
||||
await denied('private_agent_update_denied',`/agents/${agent.id}`,'PUT',{name:'unwanted',config:cfg},bob)
|
||||
const form=new FormData();form.set('file',new Blob(['# Disposable instructions\nUse exact fixture examples.'],{type:'text/markdown'}),prefix+'.md')
|
||||
const skill=(await api('/skills/instructions/import','POST',form,alice)).data;skills.push(skill.id)
|
||||
await denied('private_skill_read_denied',`/skills/instructions/${skill.id}`,'GET',undefined,bob)
|
||||
await denied('private_skill_mount_denied','/skills/instructions/mount','POST',{agent_ids:[bobAgent.id],skill_ids:[skill.id],mode:'add'},bob)
|
||||
await denied('foreign_agent_mount_denied','/skills/instructions/mount','POST',{agent_ids:[agent.id],skill_ids:[skill.id],mode:'add'},bob)
|
||||
const binding = async (id, identity) => (await api(`/agents/${id}`,'GET',undefined,identity)).data.config
|
||||
await api('/skills/instructions/mount','POST',{agent_ids:[agent.id],skill_ids:[skill.id],mode:'add'},alice)
|
||||
let mounted = await binding(agent.id,alice)
|
||||
check('markdown_skill_mount_without_sandbox',mounted.agent_mode==='quick-answer'&&!mounted.sandbox_config_id&&mounted.instruction_skill_ids.includes(skill.id))
|
||||
await denied('foreign_agent_unmount_denied','/skills/instructions/mount','POST',{agent_ids:[agent.id],skill_ids:[skill.id],mode:'remove'},bob)
|
||||
check('denied_unmount_preserves_owner_bindings',(await binding(agent.id,alice)).instruction_skill_ids.includes(skill.id))
|
||||
await api('/skills/instructions/mount','POST',{agent_ids:[agent.id],skill_ids:[skill.id],mode:'remove'},alice)
|
||||
check('markdown_unmount_persists_empty_selection',((await binding(agent.id,alice)).instruction_skill_ids||[]).length===0)
|
||||
check('unmount_keeps_skill_available',(await api(`/skills/instructions/${skill.id}`,'GET',undefined,alice)).data.id===skill.id)
|
||||
await api('/skills/instructions/mount','POST',{agent_ids:[agent.id],skill_ids:[skill.id],mode:'add'},alice)
|
||||
await api('/skills/instructions/mount','POST',{agent_ids:[agent.id],skill_ids:[],mode:'replace'},alice)
|
||||
check('replace_empty_selection_persists',((await binding(agent.id,alice)).instruction_skill_ids||[]).length===0)
|
||||
for(const [kind,id,path] of [['agents',agent.id,'/agents/'],['skills',skill.id,'/skills/instructions/']]) {
|
||||
await api('/access/grants','POST',{resources:[{kind,id}],user_ids:[bob.user.id],action:'grant'})
|
||||
check(`${kind}_grant_enables_read`,(await raw(path+id,'GET',undefined,bob)).status===200)
|
||||
if(kind==='skills') {
|
||||
await api('/skills/instructions/mount','POST',{agent_ids:[bobAgent.id],skill_ids:[skill.id],mode:'add'},bob)
|
||||
check('granted_skill_mounts_on_own_agent_without_sandbox',!(await binding(bobAgent.id,bob)).sandbox_config_id&&(await binding(bobAgent.id,bob)).instruction_skill_ids.includes(skill.id))
|
||||
}
|
||||
await api('/access/grants','POST',{resources:[{kind,id}],user_ids:[bob.user.id],action:'revoke'})
|
||||
await denied(`${kind}_revoke_denies_read`,path+id,'GET',undefined,bob)
|
||||
if(kind==='skills') {
|
||||
await api('/skills/instructions/mount','POST',{agent_ids:[bobAgent.id],skill_ids:[skill.id],mode:'remove'},bob)
|
||||
check('revoked_skill_can_be_unmounted',((await binding(bobAgent.id,bob)).instruction_skill_ids||[]).length===0)
|
||||
}
|
||||
}
|
||||
const memory=(await api('/memory/items','POST',{kind:'fact',content:prefix+' private memory',importance:2},alice)).data
|
||||
check('memory_list_is_user_isolated',!JSON.stringify(await api('/memory/items','GET',undefined,bob)).includes(memory.id))
|
||||
check('memory_export_is_user_isolated',!JSON.stringify(await api('/memory/export','GET',undefined,bob)).includes(prefix))
|
||||
await denied('foreign_memory_update_denied',`/memory/items/${memory.id}`,'PUT',{content:'unwanted',importance:2},bob)
|
||||
await raw(`/memory/items/${memory.id}`,'DELETE',undefined,bob)
|
||||
check('foreign_memory_delete_does_not_remove_owner_item',JSON.stringify(await api('/memory/items','GET',undefined,alice)).includes(memory.id))
|
||||
await api(`/memory/items/${memory.id}`,'DELETE',undefined,alice)
|
||||
const forged=await raw('/knowledge-bases','GET',undefined,bob,'999999998')
|
||||
check('forged_tenant_header_denied',[401,403,404].includes(forged.status),`status=${forged.status}`)
|
||||
const models=(await api('/models','GET',undefined,bob)).data
|
||||
check('model_selectors_do_not_expose_credentials',Array.isArray(models)&&models.every(model=>{
|
||||
const p=model.parameters||{}
|
||||
return !p.api_key&&!p.app_secret&&!p.base_url&&!Object.keys(p.custom_headers||{}).length&&!Object.keys(p.extra_config||{}).length
|
||||
}))
|
||||
await api(`/access/roles/${role.id}`,'PUT',{name:prefix,version:role.version,permissions:['chat.read','chat.write']})
|
||||
for(const [name,path] of [['knowledge','/knowledge-bases'],['agents','/agents'],['skills','/skills/instructions'],['memory','/memory/items']]) await denied(`revoked_module_${name}_denied`,path,'GET',undefined,bob)
|
||||
}
|
||||
if(require.main===module)main().catch(error=>{failures.push(error.message);console.error(error.message)}).finally(async()=>{
|
||||
if(admin) {
|
||||
for(const id of agents)try{await api(`/agents/${id}?detach=true`,'DELETE')}catch(error){failures.push('cleanup agent: '+error.message)}
|
||||
for(const id of skills)try{await api(`/skills/instructions/${id}?detach=true`,'DELETE')}catch(error){failures.push('cleanup skill: '+error.message)}
|
||||
for(const id of kbs)try{await api(`/knowledge-bases/${id}`,'DELETE')}catch(error){failures.push('cleanup knowledge: '+error.message)}
|
||||
const resources=[...agents,...skills,...kbs].map(literal).join(',')||"''"
|
||||
try{sql(`DELETE FROM workbench_access_publications WHERE tenant_id=10059 AND resource_id IN (${resources})`)}catch(error){failures.push('cleanup publications: '+error.message)}
|
||||
for(const fixture of memoryFixtures)try{cleanupMemoryFixture(fixture,prefix);check(`memory_cleanup_${fixture.id}`,true)}catch(error){failures.push('cleanup memory: '+error.message)}
|
||||
for(const id of users)try{await api(`/access/users/${id}`,'DELETE')}catch(error){failures.push('cleanup user: '+error.message)}
|
||||
for(const id of roles)try{await api(`/access/roles/${id}`,'DELETE')}catch(error){failures.push('cleanup role: '+error.message)}
|
||||
}
|
||||
const result={started_at:startedAt,time:new Date().toISOString(),base,prefix,checks,failures,passed:failures.length===0,fixtures:{users,roles,agents,skills,kbs,memory_subjects:memoryFixtures.map(fixture=>`web_user:${fixture.id}`)}}
|
||||
fs.writeFileSync(`${directory}/${process.env.ISOLATION_REPORT || 'resource-isolation-live.json'}`,JSON.stringify(result,null,2))
|
||||
console.log(JSON.stringify({passed:result.passed,checks:checks.length,failures}))
|
||||
if(failures.length)process.exitCode=1
|
||||
})
|
||||
|
||||
module.exports={cleanupMemoryFixture,memoryTables}
|
||||
Reference in New Issue
Block a user