From ab9f705843d41fdb8ada3e3aa99fc0e14018c6df Mon Sep 17 00:00:00 2001 From: wizardchen Date: Thu, 30 Jul 2026 12:10:31 +0800 Subject: [PATCH] fix: harden shared agent source permissions --- frontend/src/api/chat/streame.ts | 5 +- .../src/api/chat/temporary-attachments.ts | 2 + frontend/src/api/knowledge-base/index.ts | 14 +- frontend/src/api/organization/index.ts | 2 + frontend/src/components/AgentSelector.vue | 9 +- frontend/src/components/AttachmentUpload.vue | 2 + frontend/src/components/Input-field.vue | 38 +++- frontend/src/components/MentionSelector.vue | 11 +- frontend/src/stores/chatResources.ts | 18 +- frontend/src/utils/agentWebSearch.test.mjs | 15 ++ frontend/src/utils/agentWebSearch.ts | 2 + frontend/src/views/chat/index.vue | 10 +- .../application/repository/agent_share.go | 30 ++++ .../repository/agent_share_source_test.go | 47 +++++ internal/application/service/agent_service.go | 24 +++ internal/application/service/agent_share.go | 165 +++++++++++------- .../service/agent_share_runtime_test.go | 91 ++++++++++ .../application/service/session_agent_qa.go | 3 +- .../application/service/temporary_document.go | 18 +- internal/handler/knowledge.go | 15 +- internal/handler/knowledgebase.go | 6 +- internal/handler/session/qa.go | 67 ++++--- .../handler/session/temporary_document.go | 9 +- internal/handler/session/types.go | 1 + internal/middleware/kb_access.go | 4 +- internal/middleware/kb_access_test.go | 2 +- internal/types/agent.go | 4 + internal/types/interfaces/organization.go | 5 +- internal/types/organization.go | 4 + internal/types/qa_request.go | 33 ++-- internal/types/temporary_document.go | 7 +- 31 files changed, 510 insertions(+), 153 deletions(-) create mode 100644 internal/application/repository/agent_share_source_test.go create mode 100644 internal/application/service/agent_share_runtime_test.go diff --git a/frontend/src/api/chat/streame.ts b/frontend/src/api/chat/streame.ts index c042af9bb..1ec712201 100644 --- a/frontend/src/api/chat/streame.ts +++ b/frontend/src/api/chat/streame.ts @@ -36,7 +36,7 @@ export function useStream() { let renderTimer: number | null = null // 启动流式请求 - const startStream = async (params: { session_id: any; query: any; knowledge_base_ids?: string[]; knowledge_ids?: string[]; tag_ids?: string[]; agent_enabled?: boolean; agent_id?: string; web_search_enabled?: boolean; summary_model_id?: string; mcp_service_ids?: string[]; skill_names?: string[]; mentioned_items?: Array<{id: string; name: string; type: string; kb_type?: string; kb_id?: string; kb_name?: string; service_id?: string; skill_name?: string}>; images?: Array<{data: string}>; attachment_uploads?: Array<{data: string; file_name: string; file_size: number}>; attachment_ids?: string[]; suggestion_attribution?: { suggestion_set_id: string; question_id: string }; method: string; url: string; embed_token?: string; embed_session_sig?: string; embed_visitor_id?: string }) => { + const startStream = async (params: { session_id: any; query: any; knowledge_base_ids?: string[]; knowledge_ids?: string[]; tag_ids?: string[]; agent_enabled?: boolean; agent_id?: string; agent_source_tenant_id?: string | number; web_search_enabled?: boolean; summary_model_id?: string; mcp_service_ids?: string[]; skill_names?: string[]; mentioned_items?: Array<{id: string; name: string; type: string; kb_type?: string; kb_id?: string; kb_name?: string; service_id?: string; skill_name?: string}>; images?: Array<{data: string}>; attachment_uploads?: Array<{data: string; file_name: string; file_size: number}>; attachment_ids?: string[]; suggestion_attribution?: { suggestion_set_id: string; question_id: string }; method: string; url: string; embed_token?: string; embed_session_sig?: string; embed_visitor_id?: string }) => { const myGeneration = ++streamGeneration // 重置状态 output.value = ''; @@ -99,6 +99,9 @@ export function useStream() { if (params.agent_id) { postBody.agent_id = params.agent_id; } + if (params.agent_source_tenant_id) { + postBody.agent_source_tenant_id = Number(params.agent_source_tenant_id); + } // Include web_search_enabled if provided if (params.web_search_enabled !== undefined) { postBody.web_search_enabled = params.web_search_enabled; diff --git a/frontend/src/api/chat/temporary-attachments.ts b/frontend/src/api/chat/temporary-attachments.ts index ca11d8b2c..00db683c3 100644 --- a/frontend/src/api/chat/temporary-attachments.ts +++ b/frontend/src/api/chat/temporary-attachments.ts @@ -26,12 +26,14 @@ export function uploadTemporaryAttachment( sessionId: string, file: File, agentId?: string, + agentSourceTenantId?: string, parserEngine?: string, onProgress?: (percent: number) => void, ): Promise { const form = new FormData(); form.append('file', file); if (agentId) form.append('agent_id', agentId); + if (agentSourceTenantId) form.append('agent_source_tenant_id', agentSourceTenantId); if (parserEngine) form.append('parser_engine', parserEngine); return postUpload( `/api/v1/sessions/${sessionId}/attachments`, diff --git a/frontend/src/api/knowledge-base/index.ts b/frontend/src/api/knowledge-base/index.ts index ebe646cb0..a519666fb 100644 --- a/frontend/src/api/knowledge-base/index.ts +++ b/frontend/src/api/knowledge-base/index.ts @@ -29,6 +29,7 @@ export async function listKnowledgeBaseActivity( // 知识库管理 API(列表、创建、获取、更新、删除、复制) export function listKnowledgeBases(params?: { agent_id?: string; + agent_source_tenant_id?: string; /** * Optional creator filter. Server-side semantics: * - "mine" → only KBs whose creator_id matches the caller @@ -41,6 +42,7 @@ export function listKnowledgeBases(params?: { }) { const query = new URLSearchParams(); if (params?.agent_id) query.set('agent_id', params.agent_id); + if (params?.agent_source_tenant_id) query.set('agent_source_tenant_id', params.agent_source_tenant_id); if (params?.creator && params.creator !== 'all') query.set('creator', params.creator); const qs = query.toString(); return get(qs ? `/api/v1/knowledge-bases?${qs}` : '/api/v1/knowledge-bases'); @@ -125,9 +127,10 @@ export function createKnowledgeBase(data: { return post(`/api/v1/knowledge-bases`, data); } -export function getKnowledgeBaseById(id: string, options?: { agent_id?: string }) { +export function getKnowledgeBaseById(id: string, options?: { agent_id?: string; agent_source_tenant_id?: string }) { const query = new URLSearchParams(); if (options?.agent_id) query.set('agent_id', options.agent_id); + if (options?.agent_source_tenant_id) query.set('agent_source_tenant_id', options.agent_source_tenant_id); const qs = query.toString(); return get(qs ? `/api/v1/knowledge-bases/${id}?${qs}` : `/api/v1/knowledge-bases/${id}`); } @@ -277,9 +280,10 @@ export function listKnowledgeFiles( return get(`/api/v1/knowledge-bases/${kbId}/knowledge?${qs}`); } -export function getKnowledgeDetails(id: string, options?: { agent_id?: string }) { +export function getKnowledgeDetails(id: string, options?: { agent_id?: string; agent_source_tenant_id?: string }) { const query = new URLSearchParams(); if (options?.agent_id) query.set('agent_id', options.agent_id); + if (options?.agent_source_tenant_id) query.set('agent_source_tenant_id', options.agent_source_tenant_id); const qs = query.toString(); return get(qs ? `/api/v1/knowledge/${id}?${qs}` : `/api/v1/knowledge/${id}`); } @@ -322,10 +326,11 @@ export function previewKnowledgeFile(id: string) { } /** @param idsQueryString - query string with ids (e.g. ids=xxx&ids=yyy) */ -export function batchQueryKnowledge(idsQueryString: string, kbId?: string, agentId?: string) { +export function batchQueryKnowledge(idsQueryString: string, kbId?: string, agentId?: string, agentSourceTenantId?: string) { let qs = idsQueryString; if (kbId) qs += `&kb_id=${encodeURIComponent(kbId)}`; if (agentId) qs += `&agent_id=${encodeURIComponent(agentId)}`; + if (agentSourceTenantId) qs += `&agent_source_tenant_id=${encodeURIComponent(agentSourceTenantId)}`; return get(`/api/v1/knowledge/batch?${qs}`); } @@ -539,7 +544,7 @@ export function searchKnowledge( offset = 0, limit = 20, fileTypes?: string[], - options?: { agent_id?: string; recent?: boolean } + options?: { agent_id?: string; agent_source_tenant_id?: string; recent?: boolean } ) { const query = new URLSearchParams(); if (keyword) { @@ -551,6 +556,7 @@ export function searchKnowledge( query.set('file_types', fileTypes.join(',')); } if (options?.agent_id) query.set('agent_id', options.agent_id); + if (options?.agent_source_tenant_id) query.set('agent_source_tenant_id', options.agent_source_tenant_id); if (options?.recent) query.set('recent', 'true'); return get(`/api/v1/knowledge/search?${query.toString()}`); } diff --git a/frontend/src/api/organization/index.ts b/frontend/src/api/organization/index.ts index 664053591..9fc038a0a 100644 --- a/frontend/src/api/organization/index.ts +++ b/frontend/src/api/organization/index.ts @@ -311,6 +311,8 @@ export interface SharedAgentInfo { shared_at: string shared_by_user_id?: string shared_by_username?: string + /** 由后端在源空间解析,不与当前空间的搜索引擎列表比较 */ + web_search_ready: boolean /** 当前用户是否已停用该共享智能体(仅影响本人对话下拉显示) */ disabled_by_me?: boolean } diff --git a/frontend/src/components/AgentSelector.vue b/frontend/src/components/AgentSelector.vue index c0902b0e8..899426081 100644 --- a/frontend/src/components/AgentSelector.vue +++ b/frontend/src/components/AgentSelector.vue @@ -228,7 +228,7 @@ const emit = defineEmits<{ type AgentDetailTarget = { agent: CustomAgent; sourceTenantId?: string; - sharedMeta?: { org_name?: string; shared_by_username?: string }; + sharedMeta?: { org_name?: string; shared_by_username?: string; web_search_ready?: boolean }; }; type SharedAgentSelection = Omit & { @@ -322,7 +322,11 @@ const isWebSearchEnabledForAgent = (agent: CustomAgent): boolean => { }; const isWebSearchReadyForAgent = (agent: CustomAgent): boolean => { - return isAgentWebSearchReady(agent.config, webSearchProviders.value); + return isAgentWebSearchReady( + agent.config, + webSearchProviders.value, + activeDetail.value?.sourceTenantId ? activeDetail.value.sharedMeta?.web_search_ready : undefined, + ); }; const isImageUploadEnabledForAgent = (agent: CustomAgent): boolean => { @@ -475,6 +479,7 @@ const onSharedOptionEnter = (shared: SharedAgentSelection, event: MouseEvent) => onOptionEnter(shared.agent, event, String(shared.source_tenant_id), { org_name: shared.org_name, shared_by_username: shared.shared_by_username, + web_search_ready: shared.web_search_ready, }); }; diff --git a/frontend/src/components/AttachmentUpload.vue b/frontend/src/components/AttachmentUpload.vue index 70e9f62ac..a5d7d4841 100644 --- a/frontend/src/components/AttachmentUpload.vue +++ b/frontend/src/components/AttachmentUpload.vue @@ -32,6 +32,7 @@ const props = defineProps<{ disabled?: boolean; sessionId?: string; agentId?: string; + agentSourceTenantId?: string; }>(); const emit = defineEmits<{ @@ -139,6 +140,7 @@ const uploadAttachment = async (attachment: AttachmentFile) => { props.sessionId, attachment.file, props.agentId, + props.agentSourceTenantId, 'auto', (progress) => { attachment.progress = progress; diff --git a/frontend/src/components/Input-field.vue b/frontend/src/components/Input-field.vue index ea4c92fa9..efd429b48 100644 --- a/frontend/src/components/Input-field.vue +++ b/frontend/src/components/Input-field.vue @@ -173,6 +173,13 @@ const selectedAgent = computed(() => { config: { agent_mode: 'quick-answer' as const } } as CustomAgent; }); +const selectedSharedAgent = computed(() => { + const sourceTenantId = settingsStore.selectedAgentSourceTenantId; + if (!sourceTenantId) return undefined; + return orgStore.sharedAgents?.find( + s => s.agent.id === selectedAgentId.value && String(s.source_tenant_id) === sourceTenantId + ); +}); // 判断是否为自定义智能体(非内置) const isCustomAgent = computed(() => { @@ -247,7 +254,7 @@ watch([selectedAgentId, agentKnowledgeBases, agentKBSelectionMode], ([newAgentId watch([selectedAgentId, () => settingsStore.selectedAgentSourceTenantId], async ([agentId, sourceTenantId]) => { if (sourceTenantId && agentId) { try { - const list = await chatResources.ensureAgentKnowledgeBases(agentId); + const list = await chatResources.ensureAgentKnowledgeBases(agentId, sourceTenantId); sharedAgentKbList.value = list.map((kb: any) => ({ id: kb.id, name: kb.name, @@ -438,7 +445,11 @@ const showWebSearchButton = computed(() => { if (!hasAgentConfig.value) { return isTenantWebSearchReady(webSearchProviders.value); } - return isAgentWebSearchReady(currentAgentConfig.value, webSearchProviders.value); + return isAgentWebSearchReady( + currentAgentConfig.value, + webSearchProviders.value, + selectedSharedAgent.value?.web_search_ready, + ); }); const showImageUploadButton = computed(() => isImageUploadEnabledByAgent.value); @@ -721,7 +732,7 @@ const loadKnowledgeBases = async (force = false) => { const agentId = settingsStore.selectedAgentId; if (sourceTenantId && agentId) { try { - const list = await chatResources.ensureAgentKnowledgeBases(agentId, force); + const list = await chatResources.ensureAgentKnowledgeBases(agentId, sourceTenantId, force); list.forEach((kb: any) => kb?.id && sharedAgentKbIdSet.add(kb.id)); } catch { sharedAgentKbIdSet = new Set(); @@ -766,7 +777,8 @@ const loadFiles = async () => { const runBatch = async (batchIds: string[], kbId?: string, agentId?: string) => { const query = new URLSearchParams(); batchIds.forEach((id: string) => query.append('ids', id)); - const res: any = await batchQueryKnowledge(query.toString(), kbId, agentId); + const sourceTenantId = agentId ? settingsStore.selectedAgentSourceTenantId ?? undefined : undefined; + const res: any = await batchQueryKnowledge(query.toString(), kbId, agentId, sourceTenantId); if (res.data && Array.isArray(res.data)) { res.data.forEach((f: any) => allNewFiles.push({ id: f.id, name: f.title || f.file_name })); } @@ -801,22 +813,29 @@ watch(selectedFileIds, () => { const isWebSearchConfigured = computed(() => { if (hasAgentConfig.value) { - return isAgentWebSearchReady(currentAgentConfig.value, webSearchProviders.value); + return isAgentWebSearchReady( + currentAgentConfig.value, + webSearchProviders.value, + selectedSharedAgent.value?.web_search_ready, + ); } return isTenantWebSearchReady(webSearchProviders.value); }); +const isWebSearchReadinessKnown = computed( + () => !settingsStore.selectedAgentSourceTenantId || selectedSharedAgent.value !== undefined +); const loadWebSearchConfig = async (force = false) => { try { await chatResources.ensureWebSearchProviders(force); - if (!isWebSearchConfigured.value && settingsStore.isWebSearchEnabled) { + if (isWebSearchReadinessKnown.value && !isWebSearchConfigured.value && settingsStore.isWebSearchEnabled) { settingsStore.toggleWebSearch(false); } } catch (error) { console.error('Failed to load web search config:', error); chatResources.invalidate('webSearchProviders'); - if (settingsStore.isWebSearchEnabled) { + if (!settingsStore.selectedAgentSourceTenantId && settingsStore.isWebSearchEnabled) { settingsStore.toggleWebSearch(false); } } @@ -1166,7 +1185,7 @@ const loadMentionItems = async (q: string, resetIndex = true, append = false) => if (sourceTenantId && agentId) { // 共享智能体:按 agent_id 拉取该智能体配置的知识库范围(后端从共享关系解析空间) try { - const list = await chatResources.ensureAgentKnowledgeBases(agentId); + const list = await chatResources.ensureAgentKnowledgeBases(agentId, sourceTenantId); const orgLabel = sharedAgentOrgName.value || ''; // 保留 capabilities / indexing_strategy,后面过滤时要用 availableKbs = list.map((kb: any) => ({ @@ -1345,7 +1364,7 @@ const loadMentionItems = async (q: string, resetIndex = true, append = false) => const sourceTenantId = settingsStore.selectedAgentSourceTenantId; const agentId = selectedAgentId.value; const searchOptions = { - ...(sourceTenantId && agentId ? { agent_id: agentId } : {}), + ...(sourceTenantId && agentId ? { agent_id: agentId, agent_source_tenant_id: sourceTenantId } : {}), recent: !fileSearchKeyword, }; const res: any = await searchKnowledge( @@ -2480,6 +2499,7 @@ defineExpose({ diff --git a/frontend/src/components/MentionSelector.vue b/frontend/src/components/MentionSelector.vue index 4db59ada4..14e2dc814 100644 --- a/frontend/src/components/MentionSelector.vue +++ b/frontend/src/components/MentionSelector.vue @@ -308,6 +308,7 @@ const agentIdForDetail = computed(() => { const agentId = settingsStore.selectedAgentId; return sourceTenantId && agentId ? agentId : undefined; }); +const agentSourceTenantIdForDetail = computed(() => settingsStore.selectedAgentSourceTenantId ?? undefined); const kbItems = computed(() => props.items.filter(item => item.type === 'kb')); const fileItems = computed(() => props.items.filter(item => item.type === 'file')); @@ -445,7 +446,10 @@ async function fetchKbDetail(item: { id: string }) { if (detailCache.value[item.id]?.data || detailCache.value[item.id]?.loading) return; detailCache.value = { ...detailCache.value, [item.id]: { loading: true } }; try { - const opts = agentIdForDetail.value ? { agent_id: agentIdForDetail.value } : undefined; + const opts = agentIdForDetail.value ? { + agent_id: agentIdForDetail.value, + agent_source_tenant_id: agentSourceTenantIdForDetail.value, + } : undefined; const res: any = await getKnowledgeBaseById(item.id, opts); detailCache.value = { ...detailCache.value, [item.id]: { loading: false, data: res?.data ?? res } }; } catch (e: any) { @@ -457,7 +461,10 @@ async function fetchFileDetail(item: { id: string }) { if (detailCache.value[item.id]?.data || detailCache.value[item.id]?.loading) return; detailCache.value = { ...detailCache.value, [item.id]: { loading: true } }; try { - const opts = agentIdForDetail.value ? { agent_id: agentIdForDetail.value } : undefined; + const opts = agentIdForDetail.value ? { + agent_id: agentIdForDetail.value, + agent_source_tenant_id: agentSourceTenantIdForDetail.value, + } : undefined; const res: any = await getKnowledgeDetails(item.id, opts); detailCache.value = { ...detailCache.value, [item.id]: { loading: false, data: res?.data ?? res } }; } catch (e: any) { diff --git a/frontend/src/stores/chatResources.ts b/frontend/src/stores/chatResources.ts index f9e409272..0bd51bda5 100644 --- a/frontend/src/stores/chatResources.ts +++ b/frontend/src/stores/chatResources.ts @@ -186,25 +186,29 @@ export const useChatResourcesStore = defineStore('chatResources', () => { ]) } - async function ensureAgentKnowledgeBases(agentId: string, force = false): Promise { - const cached = agentKbCache.get(agentId) + async function ensureAgentKnowledgeBases(agentId: string, sourceTenantId?: string, force = false): Promise { + const cacheKey = `${agentId}:${sourceTenantId || 'current'}` + const cached = agentKbCache.get(cacheKey) if (!force && cached && Date.now() - cached.at < CACHE_TTL_MS) { return cached.data } - const existing = agentKbInflight.get(agentId) + const existing = agentKbInflight.get(cacheKey) if (existing) return existing const p = (async () => { try { - const res: any = await listKnowledgeBases({ agent_id: agentId }) + const res: any = await listKnowledgeBases({ + agent_id: agentId, + agent_source_tenant_id: sourceTenantId, + }) const list = res?.data && Array.isArray(res.data) ? res.data : [] - agentKbCache.set(agentId, { at: Date.now(), data: list }) + agentKbCache.set(cacheKey, { at: Date.now(), data: list }) return list } finally { - agentKbInflight.delete(agentId) + agentKbInflight.delete(cacheKey) } })() - agentKbInflight.set(agentId, p) + agentKbInflight.set(cacheKey, p) return p } diff --git a/frontend/src/utils/agentWebSearch.test.mjs b/frontend/src/utils/agentWebSearch.test.mjs index daf645794..c280079eb 100644 --- a/frontend/src/utils/agentWebSearch.test.mjs +++ b/frontend/src/utils/agentWebSearch.test.mjs @@ -50,6 +50,21 @@ test('isAgentWebSearchReady requires enabled flag and resolvable provider', () = ); }); +test('isAgentWebSearchReady trusts source workspace readiness for a shared agent', () => { + assert.equal( + isAgentWebSearchReady( + { web_search_enabled: true, web_search_provider_id: 'source-provider' }, + [], + true, + ), + true, + ); + assert.equal( + isAgentWebSearchReady({ web_search_enabled: true }, providers, false), + false, + ); +}); + test('isTenantWebSearchReady checks default provider only', () => { assert.equal(isTenantWebSearchReady(providers), true); assert.equal( diff --git a/frontend/src/utils/agentWebSearch.ts b/frontend/src/utils/agentWebSearch.ts index 4aea1248b..c6aac73bb 100644 --- a/frontend/src/utils/agentWebSearch.ts +++ b/frontend/src/utils/agentWebSearch.ts @@ -26,8 +26,10 @@ export function isAgentWebSearchEnabled(config: AgentWebSearchConfig | undefined export function isAgentWebSearchReady( config: AgentWebSearchConfig | undefined, providers: WebSearchProviderEntity[], + sourceWorkspaceReady?: boolean, ): boolean { if (!isAgentWebSearchEnabled(config)) return false; + if (sourceWorkspaceReady !== undefined) return sourceWorkspaceReady; return resolveAgentWebSearchProviderId(config, providers) !== null; } diff --git a/frontend/src/views/chat/index.vue b/frontend/src/views/chat/index.vue index 66ca08cfa..8544e69f1 100644 --- a/frontend/src/views/chat/index.vue +++ b/frontend/src/views/chat/index.vue @@ -664,6 +664,9 @@ const sendMsg = async (value, modelId = '', mentionedItems = [], imageFiles = [] isReplying.value = true; loading.value = true; const selectedAgentId = props.embeddedMode ? props.agentId : (useSettingsStoreInstance.selectedAgentId || ''); + const selectedAgentSourceTenantId = props.embeddedMode + ? undefined + : (useSettingsStoreInstance.selectedAgentSourceTenantId || undefined); // Images are unified with the attachment pipeline: on the authenticated web // client they upload as temporary documents (understood in the background by @@ -690,7 +693,9 @@ const sendMsg = async (value, modelId = '', mentionedItems = [], imageFiles = [] continue; } try { - const upload = await uploadTemporaryAttachment(session_id.value, file, selectedAgentId, 'auto'); + const upload = await uploadTemporaryAttachment( + session_id.value, file, selectedAgentId, selectedAgentSourceTenantId, 'auto' + ); imageAttachmentIds.push(upload.data.id); } catch (e) { console.error('[Image] Temporary image upload failed, falling back to inline:', e); @@ -710,7 +715,7 @@ const sendMsg = async (value, modelId = '', mentionedItems = [], imageFiles = [] await Promise.all(localAttachments.map(async (attachment) => { attachment.status = 'uploading'; const upload = await uploadTemporaryAttachment( - session_id.value, attachment.file, selectedAgentId, 'auto' + session_id.value, attachment.file, selectedAgentId, selectedAgentSourceTenantId, 'auto' ); attachment.documentId = upload.data.id; attachment.status = upload.data.status; @@ -818,6 +823,7 @@ const sendMsg = async (value, modelId = '', mentionedItems = [], imageFiles = [] knowledge_ids: knowledgeIds, agent_enabled: agentEnabled, agent_id: selectedAgentId, + agent_source_tenant_id: selectedAgentSourceTenantId, web_search_enabled: webSearchEnabled, summary_model_id: modelId, mcp_service_ids: requestMcpServiceIds, diff --git a/internal/application/repository/agent_share.go b/internal/application/repository/agent_share.go index 0babd89bc..0e2b789ac 100644 --- a/internal/application/repository/agent_share.go +++ b/internal/application/repository/agent_share.go @@ -209,3 +209,33 @@ func (r *agentShareRepository) GetShareByAgentIDForTenant(ctx context.Context, t } return &share, nil } + +// GetShareByAgentIDAndSourceForTenant validates an exact source selector +// against organization membership. This avoids loading every shared agent and +// makes same-ID builtins from multiple workspaces deterministic. +func (r *agentShareRepository) GetShareByAgentIDAndSourceForTenant( + ctx context.Context, + tenantID uint64, + agentID string, + sourceTenantID uint64, +) (*types.AgentShare, error) { + var share types.AgentShare + tx := r.db.WithContext(ctx). + Joins("JOIN organization_tenant_members otm ON otm.organization_id = agent_shares.organization_id"). + Joins("JOIN organizations ON organizations.id = agent_shares.organization_id AND organizations.deleted_at IS NULL"). + Joins("JOIN custom_agents ON custom_agents.id = agent_shares.agent_id AND custom_agents.tenant_id = agent_shares.source_tenant_id AND custom_agents.deleted_at IS NULL"). + Where("agent_shares.agent_id = ?", agentID). + Where("agent_shares.source_tenant_id = ?", sourceTenantID). + Where("otm.tenant_id = ?", tenantID). + Where("agent_shares.deleted_at IS NULL"). + Order("agent_shares.id"). + Limit(1). + Find(&share) + if tx.Error != nil { + return nil, tx.Error + } + if tx.RowsAffected == 0 { + return nil, ErrAgentShareNotFound + } + return &share, nil +} diff --git a/internal/application/repository/agent_share_source_test.go b/internal/application/repository/agent_share_source_test.go new file mode 100644 index 000000000..b73d67e8f --- /dev/null +++ b/internal/application/repository/agent_share_source_test.go @@ -0,0 +1,47 @@ +package repository + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestGetShareByAgentIDAndSourceForTenantDisambiguatesSource(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:agent-share-source?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + statements := []string{ + `CREATE TABLE agent_shares ( + id TEXT PRIMARY KEY, agent_id TEXT, organization_id TEXT, + shared_by_user_id TEXT, source_tenant_id INTEGER, permission TEXT, + created_at DATETIME, updated_at DATETIME, deleted_at DATETIME + )`, + `CREATE TABLE organization_tenant_members (organization_id TEXT, tenant_id INTEGER)`, + `CREATE TABLE organizations (id TEXT PRIMARY KEY, deleted_at DATETIME)`, + `CREATE TABLE custom_agents (id TEXT, tenant_id INTEGER, deleted_at DATETIME)`, + `INSERT INTO organizations(id) VALUES ('org')`, + `INSERT INTO organization_tenant_members(organization_id, tenant_id) VALUES ('org', 7)`, + `INSERT INTO custom_agents(id, tenant_id) VALUES ('builtin-smart-reasoning', 42), ('builtin-smart-reasoning', 84)`, + `INSERT INTO agent_shares(id, agent_id, organization_id, source_tenant_id) VALUES + ('share-42', 'builtin-smart-reasoning', 'org', 42), + ('share-84', 'builtin-smart-reasoning', 'org', 84)`, + } + for _, statement := range statements { + require.NoError(t, db.Exec(statement).Error) + } + + repo := &agentShareRepository{db: db} + share, err := repo.GetShareByAgentIDAndSourceForTenant( + context.Background(), 7, "builtin-smart-reasoning", 84, + ) + require.NoError(t, err) + require.Equal(t, "share-84", share.ID) + require.Equal(t, uint64(84), share.SourceTenantID) + + _, err = repo.GetShareByAgentIDAndSourceForTenant( + context.Background(), 8, "builtin-smart-reasoning", 84, + ) + require.ErrorIs(t, err, ErrAgentShareNotFound) +} diff --git a/internal/application/service/agent_service.go b/internal/application/service/agent_service.go index d6182af2b..171420eaa 100644 --- a/internal/application/service/agent_service.go +++ b/internal/application/service/agent_service.go @@ -438,6 +438,9 @@ func (s *agentService) registerTools( allowedTools = tools.DefaultAllowedTools() logger.Infof(ctx, "Using default allowed tools: %v", allowedTools) } + if config.SharedAgentReadOnly { + allowedTools = filterSharedAgentWriteTools(allowedTools) + } // ---- Capability detection from SearchTargets ---- var hasVectorKB bool @@ -679,6 +682,27 @@ func (s *agentService) registerTools( return nil } +// filterSharedAgentWriteTools enforces the read-only contract of AgentShare. +// These tools write source-workspace Wiki state and otherwise bypass the HTTP +// KB permission middleware because they execute inside the agent engine. +func filterSharedAgentWriteTools(allowed []string) []string { + sourceWorkspaceWrites := map[string]bool{ + tools.ToolWikiFlagIssue: true, + tools.ToolWikiUpdateIssue: true, + tools.ToolWikiWritePage: true, + tools.ToolWikiReplaceText: true, + tools.ToolWikiRenamePage: true, + tools.ToolWikiDeletePage: true, + } + filtered := make([]string, 0, len(allowed)) + for _, name := range allowed { + if !sourceWorkspaceWrites[name] { + filtered = append(filtered, name) + } + } + return filtered +} + // ValidateConfig validates the agent configuration func (s *agentService) ValidateConfig(config *types.AgentConfig) error { if config == nil { diff --git a/internal/application/service/agent_share.go b/internal/application/service/agent_share.go index f462f2ace..9cdad5e8d 100644 --- a/internal/application/service/agent_share.go +++ b/internal/application/service/agent_share.go @@ -62,11 +62,12 @@ func agentRequiresRerankModel(agent *types.CustomAgent) bool { // tenant. callerTenantRole flows through every read path so the 3-D // cap (tenant Viewer → at most OrgRoleViewer) lands consistently. type agentShareService struct { - shareRepo interfaces.AgentShareRepository - disabledRepo interfaces.TenantDisabledSharedAgentRepository - orgRepo interfaces.OrganizationRepository - agentRepo interfaces.CustomAgentRepository - userRepo interfaces.UserRepository + shareRepo interfaces.AgentShareRepository + disabledRepo interfaces.TenantDisabledSharedAgentRepository + orgRepo interfaces.OrganizationRepository + agentRepo interfaces.CustomAgentRepository + userRepo interfaces.UserRepository + webSearchProviderRepo interfaces.WebSearchProviderRepository } // NewAgentShareService creates a new agent share service @@ -76,16 +77,72 @@ func NewAgentShareService( orgRepo interfaces.OrganizationRepository, agentRepo interfaces.CustomAgentRepository, userRepo interfaces.UserRepository, + webSearchProviderRepo interfaces.WebSearchProviderRepository, ) interfaces.AgentShareService { return &agentShareService{ - shareRepo: shareRepo, - disabledRepo: disabledRepo, - orgRepo: orgRepo, - agentRepo: agentRepo, - userRepo: userRepo, + shareRepo: shareRepo, + disabledRepo: disabledRepo, + orgRepo: orgRepo, + agentRepo: agentRepo, + userRepo: userRepo, + webSearchProviderRepo: webSearchProviderRepo, } } +func (s *agentShareService) sharedAgentInfo( + ctx context.Context, + share *types.AgentShare, + effective types.OrgMemberRole, + webSearchReadyCache map[string]bool, +) *types.SharedAgentInfo { + info := &types.SharedAgentInfo{ + Agent: share.Agent, + ShareID: share.ID, + OrganizationID: share.OrganizationID, + Permission: effective, + SourceTenantID: share.SourceTenantID, + SharedAt: share.CreatedAt, + SharedByUserID: share.SharedByUserID, + } + if share.Organization != nil { + info.OrgName = share.Organization.Name + } + if share.SharedByUserID != "" && s.userRepo != nil { + if u, err := s.userRepo.GetUserByID(ctx, share.SharedByUserID); err == nil && u != nil { + info.SharedByUsername = u.Username + } + } + cacheKey := fmt.Sprintf("%d:%t:%s", share.SourceTenantID, share.Agent.Config.WebSearchEnabled, share.Agent.Config.WebSearchProviderID) + if ready, ok := webSearchReadyCache[cacheKey]; ok { + info.WebSearchReady = ready + } else { + info.WebSearchReady = s.isAgentWebSearchReady(ctx, share.Agent, share.SourceTenantID) + webSearchReadyCache[cacheKey] = info.WebSearchReady + } + return info +} + +// isAgentWebSearchReady resolves only an availability bit in the source +// workspace. Returning source provider rows would leak configuration, while +// comparing provider IDs with the receiver workspace produces false negatives. +func (s *agentShareService) isAgentWebSearchReady( + ctx context.Context, + agent *types.CustomAgent, + sourceTenantID uint64, +) bool { + if agent == nil || !agent.Config.WebSearchEnabled || s.webSearchProviderRepo == nil { + return false + } + var provider *types.WebSearchProviderEntity + var err error + if agent.Config.WebSearchProviderID != "" { + provider, err = s.webSearchProviderRepo.GetByID(ctx, sourceTenantID, agent.Config.WebSearchProviderID) + } else { + provider, err = s.webSearchProviderRepo.GetDefault(ctx, sourceTenantID) + } + return err == nil && provider != nil +} + // ShareAgent shares an agent to an organization. Permission is forced to // OrgRoleViewer (cross-tenant agent edit is not part of v1). func (s *agentShareService) ShareAgent(ctx context.Context, agentID string, orgID string, userID string, tenantID uint64, permission types.OrgMemberRole) (*types.AgentShare, error) { @@ -221,6 +278,7 @@ func (s *agentShareService) ListSharedAgents(ctx context.Context, tenantID uint6 } agentInfoMap := make(map[string]*types.SharedAgentInfo) + webSearchReadyCache := make(map[string]bool) for _, share := range shares { if share.SourceTenantID == tenantID { continue @@ -234,24 +292,7 @@ func (s *agentShareService) ListSharedAgents(ctx context.Context, tenantID uint6 } effective := types.MinOrgRole(share.Permission, tm.Role) effective = applyTenantRoleCap(effective, callerTenantRole) - info := &types.SharedAgentInfo{ - Agent: share.Agent, - ShareID: share.ID, - OrganizationID: share.OrganizationID, - OrgName: "", - Permission: effective, - SourceTenantID: share.SourceTenantID, - SharedAt: share.CreatedAt, - SharedByUserID: share.SharedByUserID, - } - if share.Organization != nil { - info.OrgName = share.Organization.Name - } - if share.SharedByUserID != "" { - if u, err := s.userRepo.GetUserByID(ctx, share.SharedByUserID); err == nil && u != nil { - info.SharedByUsername = u.Username - } - } + info := s.sharedAgentInfo(ctx, share, effective, webSearchReadyCache) key := fmt.Sprintf("%s_%d", share.AgentID, share.SourceTenantID) existing, exists := agentInfoMap[key] if !exists { @@ -302,6 +343,7 @@ func (s *agentShareService) ListSharedAgentsInOrganization(ctx context.Context, } result := make([]*types.OrganizationSharedAgentItem, 0, len(shares)) + webSearchReadyCache := make(map[string]bool) for _, share := range shares { if share.Agent == nil { continue @@ -310,25 +352,7 @@ func (s *agentShareService) ListSharedAgentsInOrganization(ctx context.Context, effective := types.MinOrgRole(share.Permission, tm.Role) effective = applyTenantRoleCap(effective, callerTenantRole) - orgName := "" - if share.Organization != nil { - orgName = share.Organization.Name - } - info := &types.SharedAgentInfo{ - Agent: share.Agent, - ShareID: share.ID, - OrganizationID: share.OrganizationID, - OrgName: orgName, - Permission: effective, - SourceTenantID: share.SourceTenantID, - SharedAt: share.CreatedAt, - SharedByUserID: share.SharedByUserID, - } - if share.SharedByUserID != "" { - if u, err := s.userRepo.GetUserByID(ctx, share.SharedByUserID); err == nil && u != nil { - info.SharedByUsername = u.Username - } - } + info := s.sharedAgentInfo(ctx, share, effective, webSearchReadyCache) item := &types.OrganizationSharedAgentItem{ SharedAgentInfo: *info, @@ -380,6 +404,7 @@ func (s *agentShareService) ListSharedAgentsInOrganizations(ctx context.Context, disabledSet[fmt.Sprintf("%s_%d", d.AgentID, d.SourceTenantID)] = true } } + webSearchReadyCache := make(map[string]bool) for orgID, list := range byOrg { tm := members[orgID] result := make([]*types.OrganizationSharedAgentItem, 0, len(list)) @@ -389,25 +414,7 @@ func (s *agentShareService) ListSharedAgentsInOrganizations(ctx context.Context, } effective := types.MinOrgRole(share.Permission, tm.Role) effective = applyTenantRoleCap(effective, callerTenantRole) - orgName := "" - if share.Organization != nil { - orgName = share.Organization.Name - } - info := &types.SharedAgentInfo{ - Agent: share.Agent, - ShareID: share.ID, - OrganizationID: share.OrganizationID, - OrgName: orgName, - Permission: effective, - SourceTenantID: share.SourceTenantID, - SharedAt: share.CreatedAt, - SharedByUserID: share.SharedByUserID, - } - if share.SharedByUserID != "" { - if u, err := s.userRepo.GetUserByID(ctx, share.SharedByUserID); err == nil && u != nil { - info.SharedByUsername = u.Username - } - } + info := s.sharedAgentInfo(ctx, share, effective, webSearchReadyCache) item := &types.OrganizationSharedAgentItem{ SharedAgentInfo: *info, IsMine: share.SourceTenantID == tenantID, @@ -442,10 +449,34 @@ func (s *agentShareService) SetSharedAgentDisabledByMe(ctx context.Context, tena // callerTenantRole is currently only used for symmetry / future caps on // agent execution (e.g. tenant Viewers might be banned from // state-changing tool calls in a follow-up). -func (s *agentShareService) GetSharedAgentForTenant(ctx context.Context, tenantID uint64, callerTenantRole types.TenantRole, agentID string) (*types.CustomAgent, error) { +func (s *agentShareService) GetSharedAgentForTenant( + ctx context.Context, + tenantID uint64, + callerTenantRole types.TenantRole, + agentID string, + sourceTenantID ...uint64, +) (*types.CustomAgent, error) { if agentID == "" { return nil, ErrAgentShareNotFound } + if len(sourceTenantID) > 0 && sourceTenantID[0] != 0 { + if sourceTenantID[0] == tenantID { + return nil, ErrAgentShareNotFound + } + share, err := s.shareRepo.GetShareByAgentIDAndSourceForTenant(ctx, tenantID, agentID, sourceTenantID[0]) + if err != nil { + if errors.Is(err, repository.ErrAgentShareNotFound) { + return nil, ErrAgentSharePermission + } + return nil, err + } + agent, err := s.agentRepo.GetAgentByID(ctx, agentID, share.SourceTenantID) + if err != nil || agent == nil { + return nil, ErrAgentNotFoundForShare + } + _ = callerTenantRole + return agent, nil + } share, err := s.shareRepo.GetShareByAgentIDForTenant(ctx, tenantID, agentID, tenantID) if err != nil { if errors.Is(err, repository.ErrAgentShareNotFound) { diff --git a/internal/application/service/agent_share_runtime_test.go b/internal/application/service/agent_share_runtime_test.go new file mode 100644 index 000000000..bf89fffab --- /dev/null +++ b/internal/application/service/agent_share_runtime_test.go @@ -0,0 +1,91 @@ +package service + +import ( + "context" + "testing" + + "github.com/Tencent/WeKnora/internal/agent/tools" + "github.com/Tencent/WeKnora/internal/types" + "github.com/stretchr/testify/require" +) + +type sharedAgentWebSearchRepo struct { + byIDTenant uint64 + byID string + defaultTenant uint64 + explicit *types.WebSearchProviderEntity + defaultProvider *types.WebSearchProviderEntity +} + +func (r *sharedAgentWebSearchRepo) Create(context.Context, *types.WebSearchProviderEntity) error { + return nil +} + +func (r *sharedAgentWebSearchRepo) GetByID(_ context.Context, tenantID uint64, id string) (*types.WebSearchProviderEntity, error) { + r.byIDTenant = tenantID + r.byID = id + return r.explicit, nil +} + +func (r *sharedAgentWebSearchRepo) GetDefault(_ context.Context, tenantID uint64) (*types.WebSearchProviderEntity, error) { + r.defaultTenant = tenantID + return r.defaultProvider, nil +} + +func (r *sharedAgentWebSearchRepo) List(context.Context, uint64) ([]*types.WebSearchProviderEntity, error) { + return nil, nil +} + +func (r *sharedAgentWebSearchRepo) Update(context.Context, *types.WebSearchProviderEntity) error { + return nil +} + +func (r *sharedAgentWebSearchRepo) Delete(context.Context, uint64, string) error { return nil } + +func (r *sharedAgentWebSearchRepo) ClearDefault(context.Context, uint64, string) error { return nil } + +func TestSharedAgentWebSearchReadyUsesSourceWorkspace(t *testing.T) { + repo := &sharedAgentWebSearchRepo{ + explicit: &types.WebSearchProviderEntity{ID: "source-provider", TenantID: 42}, + } + svc := &agentShareService{webSearchProviderRepo: repo} + agent := &types.CustomAgent{Config: types.CustomAgentConfig{ + WebSearchEnabled: true, + WebSearchProviderID: "source-provider", + }} + + require.True(t, svc.isAgentWebSearchReady(context.Background(), agent, 42)) + require.Equal(t, uint64(42), repo.byIDTenant) + require.Equal(t, "source-provider", repo.byID) +} + +func TestSharedAgentWebSearchReadyUsesSourceDefault(t *testing.T) { + repo := &sharedAgentWebSearchRepo{ + defaultProvider: &types.WebSearchProviderEntity{ID: "source-default", TenantID: 42, IsDefault: true}, + } + svc := &agentShareService{webSearchProviderRepo: repo} + agent := &types.CustomAgent{Config: types.CustomAgentConfig{WebSearchEnabled: true}} + + require.True(t, svc.isAgentWebSearchReady(context.Background(), agent, 42)) + require.Equal(t, uint64(42), repo.defaultTenant) +} + +func TestFilterSharedAgentWriteTools(t *testing.T) { + got := filterSharedAgentWriteTools([]string{ + tools.ToolWikiReadPage, + tools.ToolWikiFlagIssue, + tools.ToolWikiWritePage, + tools.ToolWikiReplaceText, + tools.ToolWikiRenamePage, + tools.ToolWikiDeletePage, + tools.ToolWikiReadIssue, + tools.ToolWikiUpdateIssue, + tools.ToolWebSearch, + }) + + require.Equal(t, []string{ + tools.ToolWikiReadPage, + tools.ToolWikiReadIssue, + tools.ToolWebSearch, + }, got) +} diff --git a/internal/application/service/session_agent_qa.go b/internal/application/service/session_agent_qa.go index fa7d540d3..aed7b305d 100644 --- a/internal/application/service/session_agent_qa.go +++ b/internal/application/service/session_agent_qa.go @@ -233,6 +233,7 @@ func (s *sessionService) buildAgentConfig( RetrieveKBOnlyWhenMentioned: customAgent.Config.RetrieveKBOnlyWhenMentioned, LLMCallTimeout: customAgent.Config.LLMCallTimeout, RetainRetrievalHistory: customAgent.Config.RetainRetrievalHistory, + SharedAgentReadOnly: req.SharedAgentReadOnly, } // Falls back to global configuration if no specific timeout is set for the agent. @@ -260,7 +261,7 @@ func (s *sessionService) buildAgentConfig( // Apply per-turn @Skill / @MCP scope. Each helper narrows the agent's // whitelist to the mentioned items and records the pinned set used for the // hint, keeping all scope logic in one place per resource type. - isSharedAgent := req.Session != nil && req.Session.TenantID != customAgent.TenantID + isSharedAgent := req.SharedAgentReadOnly applyPerRequestSkillScope(ctx, agentConfig, customAgent.Config.SkillsSelectionMode, req.SkillNames) applyPerRequestMCPScope(ctx, agentConfig, customAgent.Config.MCPServices, isSharedAgent, req.MCPServiceIDs) diff --git a/internal/application/service/temporary_document.go b/internal/application/service/temporary_document.go index c8907765c..ad1062ae5 100644 --- a/internal/application/service/temporary_document.go +++ b/internal/application/service/temporary_document.go @@ -150,7 +150,11 @@ func (s *temporaryDocumentService) Create( return nil, fmt.Errorf("unsafe file name: %w", err) } ext := strings.ToLower(filepath.Ext(baseName)) - if !s.supportsExtension(ctx, tenantID, ext) { + resourceTenantID := options.ResourceTenantID + if resourceTenantID == 0 { + resourceTenantID = tenantID + } + if !s.supportsExtension(ctx, resourceTenantID, ext) { return nil, fmt.Errorf("unsupported file type: %s", ext) } maxSize := secutils.GetMaxFileSizeMB() * 1024 * 1024 @@ -288,9 +292,15 @@ func (s *temporaryDocumentService) Process(ctx context.Context, task *asynq.Task if err := s.repo.MarkProcessing(ctx, payload.TenantID, payload.DocumentID, startedAt); err != nil { return err } - // Ensure model resolution (VLM/ASR) has a tenant ID in context. - ctx = context.WithValue(ctx, types.TenantIDContextKey, payload.TenantID) - if tenant, tenantErr := s.tenantService.GetTenantByID(ctx, payload.TenantID); tenantErr == nil && tenant != nil { + // The attachment row and file remain scoped to payload.TenantID, while + // parser/model dependencies may belong to a verified shared-agent source. + resourceTenantID := payload.TenantID + var options types.TemporaryDocumentCreateOptions + if json.Unmarshal(document.ProcessingOptions, &options) == nil && options.ResourceTenantID != 0 { + resourceTenantID = options.ResourceTenantID + } + ctx = context.WithValue(ctx, types.TenantIDContextKey, resourceTenantID) + if tenant, tenantErr := s.tenantService.GetTenantByID(ctx, resourceTenantID); tenantErr == nil && tenant != nil { ctx = context.WithValue(ctx, types.TenantInfoContextKey, tenant) } content, images, metadata, parseErr := s.parse(ctx, document) diff --git a/internal/handler/knowledge.go b/internal/handler/knowledge.go index 984764c42..f8786db71 100644 --- a/internal/handler/knowledge.go +++ b/internal/handler/knowledge.go @@ -188,7 +188,8 @@ func (h *KnowledgeHandler) resolveKnowledgeAndValidateKBAccess(c *gin.Context, k if h.agentShareService != nil && requiredPermission == types.OrgRoleViewer { agentID := c.Query("agent_id") if agentID != "" { - agent, err := h.agentShareService.GetSharedAgentForTenant(ctx, tenantID, callerTenantRole, agentID) + sourceTenantID, _ := strconv.ParseUint(c.Query("agent_source_tenant_id"), 10, 64) + agent, err := h.agentShareService.GetSharedAgentForTenant(ctx, tenantID, callerTenantRole, agentID, sourceTenantID) if err == nil && agent != nil { if knowledge.TenantID != agent.TenantID { return nil, ctx, errors.NewForbiddenError("Permission denied to access this knowledge") @@ -1384,9 +1385,10 @@ func (h *KnowledgeHandler) PreviewKnowledgeFile(c *gin.Context) { // GetKnowledgeBatchRequest defines parameters for batch knowledge retrieval type GetKnowledgeBatchRequest struct { - IDs []string `form:"ids" binding:"required"` // List of knowledge IDs - KBID string `form:"kb_id"` // Optional: scope to this KB (validates access and uses effective tenant for shared KB) - AgentID string `form:"agent_id"` // Optional: when using a shared agent, use agent's tenant for retrieval (validates shared agent access) + IDs []string `form:"ids" binding:"required"` // List of knowledge IDs + KBID string `form:"kb_id"` // Optional: scope to this KB (validates access and uses effective tenant for shared KB) + AgentID string `form:"agent_id"` // Optional: when using a shared agent, use agent's tenant for retrieval (validates shared agent access) + AgentSourceTenantID uint64 `form:"agent_source_tenant_id"` // Optional source selector, verified against the share relation } // GetKnowledgeBatch godoc @@ -1439,7 +1441,7 @@ func (h *KnowledgeHandler) GetKnowledgeBatch(c *gin.Context) { return } callerTenantRole := types.TenantRoleFromContext(ctx) - agent, err := h.agentShareService.GetSharedAgentForTenant(ctx, currentTenantID, callerTenantRole, agentID) + agent, err := h.agentShareService.GetSharedAgentForTenant(ctx, currentTenantID, callerTenantRole, agentID, req.AgentSourceTenantID) if err != nil || agent == nil { logger.Warnf(ctx, "GetKnowledgeBatch: invalid or inaccessible shared agent %s: %v", agentID, err) c.Error(errors.NewForbiddenError("Invalid or inaccessible shared agent").WithDetails(err.Error())) @@ -1992,7 +1994,8 @@ func (h *KnowledgeHandler) SearchKnowledge(c *gin.Context) { return } callerTenantRole := types.TenantRoleFromContext(ctx) - agent, err := h.agentShareService.GetSharedAgentForTenant(ctx, currentTenantID, callerTenantRole, agentID) + requestedSourceTenantID, _ := strconv.ParseUint(c.Query("agent_source_tenant_id"), 10, 64) + agent, err := h.agentShareService.GetSharedAgentForTenant(ctx, currentTenantID, callerTenantRole, agentID, requestedSourceTenantID) if err != nil { if goerrors.Is(err, service.ErrAgentShareNotFound) || goerrors.Is(err, service.ErrAgentSharePermission) || goerrors.Is(err, service.ErrAgentNotFoundForShare) { c.Error(errors.NewForbiddenError("no permission for this shared agent")) diff --git a/internal/handler/knowledgebase.go b/internal/handler/knowledgebase.go index 76b3fab07..d455a18f8 100644 --- a/internal/handler/knowledgebase.go +++ b/internal/handler/knowledgebase.go @@ -474,7 +474,8 @@ func (h *KnowledgeBaseHandler) validateAndGetKnowledgeBase(c *gin.Context) (*typ currentTenantID := tenantID.(uint64) agentID := c.Query("agent_id") if agentID != "" { - agent, err := h.agentShareService.GetSharedAgentForTenant(ctx, currentTenantID, callerTenantRole, agentID) + sourceTenantID, _ := strconv.ParseUint(c.Query("agent_source_tenant_id"), 10, 64) + agent, err := h.agentShareService.GetSharedAgentForTenant(ctx, currentTenantID, callerTenantRole, agentID, sourceTenantID) if err == nil && agent != nil { if kb.TenantID != agent.TenantID { logger.Warnf(ctx, "Shared agent workspace mismatch, KB %s tenant: %d, agent tenant: %d", id, kb.TenantID, agent.TenantID) @@ -581,7 +582,8 @@ func (h *KnowledgeBaseHandler) ListKnowledgeBases(c *gin.Context) { return } callerTenantRole := types.TenantRoleFromContext(ctx) - agent, err := h.agentShareService.GetSharedAgentForTenant(ctx, currentTenantID, callerTenantRole, agentID) + requestedSourceTenantID, _ := strconv.ParseUint(c.Query("agent_source_tenant_id"), 10, 64) + agent, err := h.agentShareService.GetSharedAgentForTenant(ctx, currentTenantID, callerTenantRole, agentID, requestedSourceTenantID) if err != nil { if stderrors.Is(err, service.ErrAgentShareNotFound) || stderrors.Is(err, service.ErrAgentSharePermission) || stderrors.Is(err, service.ErrAgentNotFoundForShare) { c.Error(apperrors.NewForbiddenError("no permission for this shared agent")) diff --git a/internal/handler/session/qa.go b/internal/handler/session/qa.go index 59eb61e83..4000faddb 100644 --- a/internal/handler/session/qa.go +++ b/internal/handler/session/qa.go @@ -43,6 +43,7 @@ type qaRequestContext struct { webSearchEnabled bool mentionedItems types.MentionedItems effectiveTenantID uint64 // when using shared agent, tenant ID for model/KB/MCP resolution; 0 = use context tenant + sharedAgentReadOnly bool // access was granted by a read-only agent share images []ImageAttachment // Uploaded images with analysis text userMessageID string // Created user message ID (populated after createUserMessage) channel string // Source channel: "web", "api", "im", etc. @@ -62,21 +63,22 @@ type qaRequestContext struct { func (rc *qaRequestContext) buildQARequest() *types.QARequest { imageURLs, imageDescription := extractImageURLsAndOCRText(rc.images) return &types.QARequest{ - Session: rc.session, - Query: rc.query, - AssistantMessageID: rc.assistantMessage.ID, - SummaryModelID: rc.summaryModelID, - CustomAgent: rc.customAgent, - KnowledgeBaseIDs: rc.knowledgeBaseIDs, - KnowledgeIDs: rc.knowledgeIDs, - TagScopes: rc.tagScopes, - MCPServiceIDs: rc.mcpServiceIDs, - SkillNames: rc.skillNames, - ImageURLs: imageURLs, - ImageDescription: imageDescription, - UserMessageID: rc.userMessageID, - WebSearchEnabled: rc.webSearchEnabled, - Attachments: rc.attachments, + Session: rc.session, + Query: rc.query, + AssistantMessageID: rc.assistantMessage.ID, + SummaryModelID: rc.summaryModelID, + CustomAgent: rc.customAgent, + SharedAgentReadOnly: rc.sharedAgentReadOnly, + KnowledgeBaseIDs: rc.knowledgeBaseIDs, + KnowledgeIDs: rc.knowledgeIDs, + TagScopes: rc.tagScopes, + MCPServiceIDs: rc.mcpServiceIDs, + SkillNames: rc.skillNames, + ImageURLs: imageURLs, + ImageDescription: imageDescription, + UserMessageID: rc.userMessageID, + WebSearchEnabled: rc.webSearchEnabled, + Attachments: rc.attachments, } } @@ -138,7 +140,10 @@ func (h *Handler) parseQARequest(c *gin.Context, logPrefix string) (*qaRequestCo } // Get custom agent if agent_id is provided. Backend resolves shared agent from share relation (no client-provided tenant). - customAgent, effectiveTenantID := h.resolveAgent(ctx, c, request.AgentID) + customAgent, effectiveTenantID, sharedAgentReadOnly := h.resolveAgent(ctx, c, request.AgentID, request.AgentSourceTenantID) + if request.AgentSourceTenantID != 0 && customAgent == nil { + return nil, nil, errors.NewNotFoundError("Shared agent not found") + } // Merge @mentioned items into knowledge_base_ids and knowledge_ids kbIDs, knowledgeIDs := mergeKnowledgeTargets(request.KnowledgeBaseIDs, request.KnowledgeIds, request.MentionedItems) @@ -160,6 +165,7 @@ func (h *Handler) parseQARequest(c *gin.Context, logPrefix string) (*qaRequestCo ); scopedTenantID != 0 { customAgent = scopedAgent effectiveTenantID = scopedTenantID + sharedAgentReadOnly = false } } @@ -205,6 +211,10 @@ func (h *Handler) parseQARequest(c *gin.Context, logPrefix string) (*qaRequestCo } tenantID := c.GetUint64(types.TenantIDContextKey.String()) + attachmentRuntimeCtx := ctx + if effectiveTenantID != 0 { + attachmentRuntimeCtx = context.WithValue(ctx, types.TenantIDContextKey, effectiveTenantID) + } // Use ASR only when the agent has audio upload enabled. asrModelID := "" @@ -229,7 +239,7 @@ func (h *Handler) parseQARequest(c *gin.Context, logPrefix string) (*qaRequestCo } processed, err := h.attachmentProcessor.ProcessAttachment( - ctx, data, att.FileName, att.FileSize, tenantID, asrModelID, + attachmentRuntimeCtx, data, att.FileName, att.FileSize, tenantID, asrModelID, ) if err != nil { errChan <- fmt.Errorf("attachment %d processing failed: %w", idx+1, err) @@ -341,6 +351,7 @@ func (h *Handler) parseQARequest(c *gin.Context, logPrefix string) (*qaRequestCo webSearchEnabled: request.WebSearchEnabled, mentionedItems: convertMentionedItems(request.MentionedItems), effectiveTenantID: effectiveTenantID, + sharedAgentReadOnly: sharedAgentReadOnly, images: request.Images, channel: request.Channel, attachments: processedAttachments, @@ -447,9 +458,14 @@ func cloneTagScopes(scopes []types.TagScope) []types.TagScope { // resolveAgent resolves the custom agent by ID, trying shared agent first, then own agent. // Returns (nil, 0) if agentID is empty or not found. -func (h *Handler) resolveAgent(ctx context.Context, c *gin.Context, agentID string) (*types.CustomAgent, uint64) { +func (h *Handler) resolveAgent( + ctx context.Context, + c *gin.Context, + agentID string, + sourceTenantID uint64, +) (*types.CustomAgent, uint64, bool) { if agentID == "" { - return nil, 0 + return nil, 0, false } logger.Infof(ctx, "Resolving agent, agent ID: %s", secutils.SanitizeForLog(agentID)) @@ -457,21 +473,26 @@ func (h *Handler) resolveAgent(ctx context.Context, c *gin.Context, agentID stri // Try shared agent first var customAgent *types.CustomAgent var effectiveTenantID uint64 + var sharedAgentReadOnly bool userIDVal, _ := c.Get(types.UserIDContextKey.String()) currentTenantID := c.GetUint64(types.TenantIDContextKey.String()) if h.agentShareService != nil && userIDVal != nil && currentTenantID != 0 { callerTenantRole := types.TenantRoleFromContext(ctx) - agent, err := h.agentShareService.GetSharedAgentForTenant(ctx, currentTenantID, callerTenantRole, agentID) + var agent *types.CustomAgent + var err error + agent, err = h.agentShareService.GetSharedAgentForTenant(ctx, currentTenantID, callerTenantRole, agentID, sourceTenantID) if err == nil && agent != nil { effectiveTenantID = agent.TenantID customAgent = agent + sharedAgentReadOnly = true logger.Infof(ctx, "Using shared agent: ID=%s, Name=%s, effectiveTenantID=%d (retrieval scope)", customAgent.ID, customAgent.Name, effectiveTenantID) } } - // Fall back to own agent - if customAgent == nil { + // Fall back to an own agent only when no source workspace was requested. + // A rejected shared selector must not silently run a same-ID local builtin. + if customAgent == nil && sourceTenantID == 0 { agent, err := h.customAgentService.GetAgentByID(ctx, agentID) if err == nil { customAgent = agent @@ -486,7 +507,7 @@ func (h *Handler) resolveAgent(ctx context.Context, c *gin.Context, agentID stri customAgent.ID, customAgent.Name, customAgent.IsBuiltin, customAgent.Config.AgentMode, effectiveTenantID) } - return customAgent, effectiveTenantID + return customAgent, effectiveTenantID, sharedAgentReadOnly } // mergeKnowledgeTargets merges request KB/knowledge IDs with @mentioned items into deduplicated slices. diff --git a/internal/handler/session/temporary_document.go b/internal/handler/session/temporary_document.go index 9e113ea40..8fd5ff518 100644 --- a/internal/handler/session/temporary_document.go +++ b/internal/handler/session/temporary_document.go @@ -6,6 +6,7 @@ import ( "mime" "net/http" "path/filepath" + "strconv" "strings" apperrors "github.com/Tencent/WeKnora/internal/errors" @@ -40,10 +41,16 @@ func (h *Handler) UploadTemporaryDocument(c *gin.Context) { } defer file.Close() - agent, _ := h.resolveAgent(ctx, c, c.PostForm("agent_id")) + sourceTenantID, _ := strconv.ParseUint(strings.TrimSpace(c.PostForm("agent_source_tenant_id")), 10, 64) + agent, resourceTenantID, _ := h.resolveAgent(ctx, c, c.PostForm("agent_id"), sourceTenantID) + if sourceTenantID != 0 && agent == nil { + c.Error(apperrors.NewNotFoundError("Shared agent not found")) + return + } ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(fileHeader.Filename)), ".") options := types.TemporaryDocumentCreateOptions{ParserEngine: strings.TrimSpace(c.PostForm("parser_engine"))} if agent != nil { + options.ResourceTenantID = resourceTenantID if len(agent.Config.SupportedFileTypes) > 0 && !containsFileType(agent.Config.SupportedFileTypes, ext) { c.Error(apperrors.NewBadRequestError("file type is not supported by this agent")) return diff --git a/internal/handler/session/types.go b/internal/handler/session/types.go index edf887a3c..6f0dc9a9d 100644 --- a/internal/handler/session/types.go +++ b/internal/handler/session/types.go @@ -47,6 +47,7 @@ type CreateKnowledgeQARequest struct { KnowledgeIds []string `json:"knowledge_ids"` // Selected knowledge ID for this request AgentEnabled bool `json:"agent_enabled"` // Whether agent mode is enabled for this request AgentID string `json:"agent_id"` // Selected custom agent ID (backend resolves shared agent and its workspace from share relation) + AgentSourceTenantID uint64 `json:"agent_source_tenant_id,omitempty"` // Optional disambiguator; backend still verifies the share relation WebSearchEnabled bool `json:"web_search_enabled"` // Whether web search is enabled for this request SummaryModelID string `json:"summary_model_id"` // Optional summary model ID for this request (overrides session default) MCPServiceIDs []string `json:"mcp_service_ids"` // Per-request MCP services selected via @mention diff --git a/internal/middleware/kb_access.go b/internal/middleware/kb_access.go index 9629db547..0624b957a 100644 --- a/internal/middleware/kb_access.go +++ b/internal/middleware/kb_access.go @@ -3,6 +3,7 @@ package middleware import ( "context" stderrors "errors" + "strconv" apprepo "github.com/Tencent/WeKnora/internal/application/repository" "github.com/Tencent/WeKnora/internal/config" @@ -399,7 +400,8 @@ func resolveSharedAgentAccess( ) *KBAccess { agentID := c.Query("agent_id") if agentID != "" { - agent, err := agentShareService.GetSharedAgentForTenant(ctx, tenantID, callerTenantRole, agentID) + sourceTenantID, _ := strconv.ParseUint(c.Query("agent_source_tenant_id"), 10, 64) + agent, err := agentShareService.GetSharedAgentForTenant(ctx, tenantID, callerTenantRole, agentID, sourceTenantID) if err != nil || agent == nil { return nil } diff --git a/internal/middleware/kb_access_test.go b/internal/middleware/kb_access_test.go index 5c709397f..04d6b9468 100644 --- a/internal/middleware/kb_access_test.go +++ b/internal/middleware/kb_access_test.go @@ -109,7 +109,7 @@ type stubAgentShareForGuard struct { kbsViaSomeAgent map[string]bool } -func (s *stubAgentShareForGuard) GetSharedAgentForTenant(_ context.Context, _ uint64, _ types.TenantRole, agentID string) (*types.CustomAgent, error) { +func (s *stubAgentShareForGuard) GetSharedAgentForTenant(_ context.Context, _ uint64, _ types.TenantRole, agentID string, _ ...uint64) (*types.CustomAgent, error) { return s.agents[agentID], nil } diff --git a/internal/types/agent.go b/internal/types/agent.go index 197b21f28..d14a154da 100644 --- a/internal/types/agent.go +++ b/internal/types/agent.go @@ -56,6 +56,10 @@ type AgentConfig struct { // Per-request @mention pins (runtime only; injected as in the user message). PinnedMCPServiceIDs []string `json:"-"` PinnedSkillNames []string `json:"-"` + // SharedAgentReadOnly prevents a shared agent from mutating resources in + // its source workspace. It is set from the verified share relation, never + // inferred from a client-provided tenant ID. + SharedAgentReadOnly bool `json:"-"` // LLM call timeout in seconds (default: 120). Controls the maximum time for a single LLM call. LLMCallTimeout int `json:"llm_call_timeout,omitempty"` diff --git a/internal/types/interfaces/organization.go b/internal/types/interfaces/organization.go index 9f1927332..20eaacd3b 100644 --- a/internal/types/interfaces/organization.go +++ b/internal/types/interfaces/organization.go @@ -96,7 +96,7 @@ type OrganizationRepository interface { // (not user). The 3-dimension cap is applied inside CheckTenantKBPermission // and is the canonical permission gate for shared KBs: // -// effective = min(share.Permission, tenant_org_role, tenant_role_cap) +// effective = min(share.Permission, tenant_org_role, tenant_role_cap) // // where tenant_role_cap pins tenant Viewers to OrgRoleViewer regardless // of the org-level grant — Viewer in your own tenant must always be @@ -181,7 +181,7 @@ type AgentShareService interface { // SetSharedAgentDisabledByMe sets whether the current tenant has "disabled" this shared agent for their conversation dropdown (per-tenant preference; will be revisited in a follow-up PR). SetSharedAgentDisabledByMe(ctx context.Context, tenantID uint64, agentID string, sourceTenantID uint64, disabled bool) error // GetSharedAgentForTenant returns the shared agent by agentID if the caller's tenant has access; used to resolve KB scope for @ mention. - GetSharedAgentForTenant(ctx context.Context, tenantID uint64, callerTenantRole types.TenantRole, agentID string) (*types.CustomAgent, error) + GetSharedAgentForTenant(ctx context.Context, tenantID uint64, callerTenantRole types.TenantRole, agentID string, sourceTenantID ...uint64) (*types.CustomAgent, error) // TenantCanAccessKBViaSomeSharedAgent returns true if the caller's tenant has at least one shared agent that can access the given KB (for opening KB detail from "通过智能体可见" list without passing agent_id). TenantCanAccessKBViaSomeSharedAgent(ctx context.Context, tenantID uint64, callerTenantRole types.TenantRole, kb *types.KnowledgeBase) (bool, error) GetShare(ctx context.Context, shareID string) (*types.AgentShare, error) @@ -205,6 +205,7 @@ type AgentShareRepository interface { ListByOrganization(ctx context.Context, orgID string) ([]*types.AgentShare, error) ListByOrganizations(ctx context.Context, orgIDs []string) ([]*types.AgentShare, error) ListSharedAgentsForTenant(ctx context.Context, tenantID uint64) ([]*types.AgentShare, error) + GetShareByAgentIDAndSourceForTenant(ctx context.Context, tenantID uint64, agentID string, sourceTenantID uint64) (*types.AgentShare, error) CountByOrganizations(ctx context.Context, orgIDs []string) (map[string]int64, error) // GetShareByAgentIDForTenant returns one share for the given agentID that the tenant can access (tenant in org), excluding source_tenant_id == excludeTenantID. GetShareByAgentIDForTenant(ctx context.Context, tenantID uint64, agentID string, excludeTenantID uint64) (*types.AgentShare, error) diff --git a/internal/types/organization.go b/internal/types/organization.go index 230dc48c1..a43035d82 100644 --- a/internal/types/organization.go +++ b/internal/types/organization.go @@ -265,6 +265,10 @@ type SharedAgentInfo struct { SharedAt time.Time `json:"shared_at"` SharedByUserID string `json:"shared_by_user_id,omitempty"` SharedByUsername string `json:"shared_by_username,omitempty"` + // WebSearchReady is resolved against the source workspace without exposing + // its provider list or credentials. Receivers must not compare the agent's + // provider ID with their own workspace resources. + WebSearchReady bool `json:"web_search_ready"` // DisabledByMe: current tenant has hidden this shared agent from their conversation dropdown (per-user preference) DisabledByMe bool `json:"disabled_by_me"` } diff --git a/internal/types/qa_request.go b/internal/types/qa_request.go index 75ead5dda..5335ef77e 100644 --- a/internal/types/qa_request.go +++ b/internal/types/qa_request.go @@ -4,20 +4,21 @@ package types // replacing the previous 14-parameter method signatures. // EventBus is passed separately to avoid circular dependency with the event package. type QARequest struct { - Session *Session // The conversation session - Query string // User query text - AssistantMessageID string // Pre-created assistant message ID - SummaryModelID string // Optional model override; empty = use agent/KB default - CustomAgent *CustomAgent // Optional custom agent for config override - KnowledgeBaseIDs []string // Knowledge base IDs to search (from request + @mentions) - KnowledgeIDs []string // Specific knowledge (file) IDs to search - TagScopes []TagScope // Tag-constrained KB scopes from @mentions - MCPServiceIDs []string // Per-request MCP service IDs from @mentions - SkillNames []string // Per-request preloaded skill names from @mentions - ImageURLs []string // Image URLs for multimodal input - ImageDescription string // VLM-generated image description (fallback for non-vision models) - UserMessageID string // Created user message ID - WebSearchEnabled bool // Whether web search is enabled for this request - QuotedContext string // Quoted message content from IM quote-reply (appended at LLM prompt stage, not used for retrieval) - Attachments MessageAttachments // File attachments (processed and ready for prompt injection) + Session *Session // The conversation session + Query string // User query text + AssistantMessageID string // Pre-created assistant message ID + SummaryModelID string // Optional model override; empty = use agent/KB default + CustomAgent *CustomAgent // Optional custom agent for config override + SharedAgentReadOnly bool // True only when access came from an agent share; source-workspace writes are forbidden + KnowledgeBaseIDs []string // Knowledge base IDs to search (from request + @mentions) + KnowledgeIDs []string // Specific knowledge (file) IDs to search + TagScopes []TagScope // Tag-constrained KB scopes from @mentions + MCPServiceIDs []string // Per-request MCP service IDs from @mentions + SkillNames []string // Per-request preloaded skill names from @mentions + ImageURLs []string // Image URLs for multimodal input + ImageDescription string // VLM-generated image description (fallback for non-vision models) + UserMessageID string // Created user message ID + WebSearchEnabled bool // Whether web search is enabled for this request + QuotedContext string // Quoted message content from IM quote-reply (appended at LLM prompt stage, not used for retrieval) + Attachments MessageAttachments // File attachments (processed and ready for prompt injection) } diff --git a/internal/types/temporary_document.go b/internal/types/temporary_document.go index 999d4f000..8d6bdc91c 100644 --- a/internal/types/temporary_document.go +++ b/internal/types/temporary_document.go @@ -92,8 +92,11 @@ type TemporaryDocumentTaskPayload struct { } type TemporaryDocumentCreateOptions struct { - ASRModelID string `json:"asr_model_id,omitempty"` - ParserEngine string `json:"parser_engine,omitempty"` + // ResourceTenantID is the verified agent source workspace used to resolve + // parser/model dependencies. The document itself remains owned by TenantID. + ResourceTenantID uint64 `json:"resource_tenant_id,omitempty"` + ASRModelID string `json:"asr_model_id,omitempty"` + ParserEngine string `json:"parser_engine,omitempty"` // VLMModelID enables image understanding (caption/OCR) during async parse. // Images use it to produce real text content; scanned/image-only documents // use it as an OCR fallback when ImageUnderstanding is on.